openhands / scripts /dev-with-automation.mjs
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
c0af099 verified
Raw
History Blame Contribute Delete
63.3 kB
/**
* Development Stack with Automation Service
*
* Extends agent-canvas's dev-safe.mjs to additionally run the OpenHands Automation
* backend via uvx. No cloning required - runs directly from git reference.
*
* Uses a standalone ingress proxy to route traffic to multiple backends.
*
* Architecture:
* β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
* β”‚ http://localhost:8000 (Ingress Proxy) β”‚
* β”‚ /api/automation/* β†’ Automation Backend β”‚
* β”‚ /api/*, /sockets β†’ Agent Server β”‚
* β”‚ /* β†’ Vite Dev Server β”‚
* β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
* β”‚ β”‚ β”‚
* β–Ό β–Ό β–Ό
* β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
* β”‚ Vite β”‚ β”‚ Agent Server β”‚ β”‚ Automation β”‚
* β”‚ :3001 β”‚ β”‚ (uvx) :18000 β”‚ β”‚ Backend (uvx) β”‚
* β”‚ β”‚ β”‚ β”‚ β”‚ :18001 β”‚
* β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
*
* Usage:
* node scripts/dev-with-automation.mjs
* node scripts/dev-with-automation.mjs --automation-ref feat/my-branch
* node scripts/dev-with-automation.mjs --port 12000
*
* Environment variables:
* - PORT: Ingress port (default: 8000)
* - OH_AUTOMATION_GIT_REF: Git ref for automation (overrides default version)
* - OH_AGENT_SERVER_LOCAL_PATH: Absolute path to a local software-agent-sdk
* checkout. Highest precedence for agent-server source selection: rebuilds
* the agent-server from local source and installs openhands-sdk,
* openhands-tools and openhands-workspace as editable so source edits are
* picked up without manual reinstall.
* - OH_AGENT_SERVER_GIT_REF: Git ref for agent-server
* Secrets:
* The session API key is automatically seeded into agent-server secrets
* as OPENHANDS_AUTOMATION_API_KEY, making it available to agents in conversations.
* Both the agent-server and automation backend use the same key value
* and the same `X-Session-API-Key` header for authentication.
* AUTOMATION_KV_SECRET is derived from the session key if not set explicitly,
* enabling the KV store out of the box for local development.
*/
import { spawn, spawnSync } from "node:child_process";
import { mkdirSync, existsSync, readFileSync } from "node:fs";
import { join, resolve, dirname, isAbsolute } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { homedir } from "node:os";
import { setTimeout as delay } from "node:timers/promises";
import process from "node:process";
import {
assertPortsFree,
buildAgentServerCommand,
buildSafeDevConfig,
buildAgentServerEnv,
buildNpmScriptCommand,
buildRuntimeServicesInfo,
formatMissingUvxGuidance,
validateFrontendDependencies,
validateLocalAgentServerPath,
} from "./dev-safe.mjs";
import {
createShutdownHookRegistry,
getProcessTreeSpawnOptions,
isProcessRunning,
resolveWindowsCommand,
signalProcessTree,
} from "./dev-process-utils.mjs";
import { fileLog, stripAnsi } from "./logger.mjs";
const __dirname = dirname(fileURLToPath(import.meta.url));
const projectRoot = resolve(__dirname, "..");
// ── Centralized config (single source of truth for versions, ports, etc.) ───
const SHARED_DEFAULTS = JSON.parse(
readFileSync(join(projectRoot, "config", "defaults.json"), "utf-8"),
);
const DEFAULT_AUTOMATION_REPO = "https://github.com/OpenHands/automation";
const DEFAULT_AUTOMATION_PACKAGE = SHARED_DEFAULTS.packages.automation;
const DEFAULT_AUTOMATION_VERSION = SHARED_DEFAULTS.versions.automation;
const DEFAULT_AUTOMATION_SDK_VERSION = SHARED_DEFAULTS.versions.agentServer;
const DEFAULT_BACKEND_PORT = SHARED_DEFAULTS.ports.agentServer;
const DEFAULT_AUTOMATION_PORT = SHARED_DEFAULTS.ports.automation;
const DEFAULT_POSTHOG_API_KEY = SHARED_DEFAULTS.telemetry.posthogApiKey;
const DEFAULT_POSTHOG_HOST = SHARED_DEFAULTS.telemetry.posthogHost;
// ═══════════════════════════════════════════════════════════════════════════
// Terminal Styling
// ═══════════════════════════════════════════════════════════════════════════
const c = {
reset: "\x1b[0m",
bold: "\x1b[1m",
dim: "\x1b[2m",
red: "\x1b[31m",
green: "\x1b[32m",
yellow: "\x1b[33m",
blue: "\x1b[34m",
magenta: "\x1b[35m",
cyan: "\x1b[36m",
};
function logService(name, message, color = c.reset) {
const ts = new Date().toISOString().split("T")[1].split(".")[0];
console.log(`${c.dim}${ts}${c.reset} ${color}[${name}]${c.reset} ${message}`);
fileLog("info", `[${name}] ${stripAnsi(message)}`);
}
function logStep(step, message) {
console.log(`${c.cyan}[${step}]${c.reset} ${message}`);
fileLog("info", `[${step}] ${message}`);
}
function logSuccess(message) {
console.log(`${c.green}βœ“${c.reset} ${message}`);
fileLog("info", `βœ“ ${message}`);
}
function logError(message) {
console.error(`${c.red}βœ—${c.reset} ${message}`);
fileLog("error", `βœ— ${stripAnsi(message)}`);
}
/**
* Parse one JSON log line produced by the SDK's JsonFormatter and return a
* single-line human-readable string + an appropriate ANSI color.
*
* Returns null for non-JSON lines so callers can fall back to the raw text.
*
* @param {string} rawLine
* @returns {{ text: string; color: string } | null}
*/
function parseAgentServerLogLine(rawLine) {
try {
const obj = JSON.parse(rawLine);
if (!obj.levelname || obj.message === undefined) return null;
const level = obj.levelname.padEnd(8);
const location =
obj.filename && obj.lineno ? ` ${obj.filename}:${obj.lineno}` : "";
const text = `${level} ${obj.message}${location}`;
const lvl = obj.levelname;
const color =
lvl === "DEBUG"
? c.dim
: lvl === "WARNING"
? c.yellow
: lvl === "ERROR" || lvl === "CRITICAL"
? c.red
: c.blue;
return { text, color };
} catch {
return null;
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Configuration
// ═══════════════════════════════════════════════════════════════════════════
function parseArgs() {
const args = process.argv.slice(2);
const config = {
port: null,
automationGitRef: null,
automationRepo: null,
verbose: false,
static: false,
dynamic: false,
staticDir: null,
skipBuild: false,
public: false,
frontendOnly: false,
backendOnly: false,
};
for (let i = 0; i < args.length; i++) {
switch (args[i]) {
case "-p":
case "--port":
config.port = parseInt(args[++i], 10);
break;
case "--automation-ref":
config.automationGitRef = args[++i];
break;
case "--automation-repo":
config.automationRepo = args[++i];
break;
case "-v":
case "--verbose":
config.verbose = true;
break;
case "--static":
config.static = true;
break;
case "--dynamic":
config.dynamic = true;
break;
case "--static-dir":
config.staticDir = args[++i];
break;
case "--skip-build":
config.skipBuild = true;
break;
case "--public":
config.public = true;
break;
case "--frontend-only":
config.frontendOnly = true;
break;
case "--backend-only":
config.backendOnly = true;
break;
case "-h":
case "--help":
showHelp();
process.exit(0);
}
}
return config;
}
function showHelp() {
console.log(`
Agent Canvas + Automation Development Stack
Runs agent-canvas with the automation backend (via uvx, no clone needed).
Uses a standalone ingress proxy to route traffic.
USAGE:
node scripts/dev-with-automation.mjs [options]
OPTIONS:
-p, --port <port> Ingress port (default: 8000)
--automation-ref <ref> Git ref for automation (branch/tag/SHA)
--automation-repo <url> Git repo URL (default: ${DEFAULT_AUTOMATION_REPO})
--static Serve an existing production build instead of Vite
--static-dir <dir> Static build directory (default: build/)
--skip-build Reuse build/ when the launcher builds static assets
--dynamic Force Vite dev server when a wrapper defaults static
--frontend-only Start only the frontend behind ingress
--backend-only Start only agent-server + automation behind ingress
-v, --verbose Show detailed output
-h, --help Show this help
ENVIRONMENT VARIABLES:
PORT Alternative to --port
OH_AUTOMATION_GIT_REF Git ref for automation (overrides default version)
OH_AUTOMATION_VERSION Specific PyPI version for automation (default: ${DEFAULT_AUTOMATION_VERSION})
OH_AUTOMATION_LOCAL_PATH Absolute path to a local automation checkout (overridden only by --automation-git-ref)
OH_AGENT_SERVER_LOCAL_PATH Absolute path to a local software-agent-sdk checkout (highest precedence)
OH_AGENT_SERVER_GIT_REF Git ref for agent-server SDK (overrides default version)
OH_AGENT_SERVER_VERSION Specific PyPI version for agent-server
OH_SECRET_KEY Secret key for sessions
SECRETS:
The session API key is automatically seeded into agent-server secrets
as OPENHANDS_AUTOMATION_API_KEY, making it available to agents in conversations.
Both backends (agent-server and automation) share the same key value.
AUTOMATION_KV_SECRET defaults to the session key so the KV store works
out of the box; override with an explicit value for stronger isolation.
ACCESS POINTS:
Main UI: http://localhost:PORT/
API Docs: http://localhost:PORT/api/automation/docs
`);
}
/**
* Fail fast on an unusable OH_AUTOMATION_LOCAL_PATH instead of letting
* `uv run --project <bad path>` exit on its own -- that leaves the rest of the
* stack up and the automations UI just reporting "backend unavailable", with
* nothing pointing at the env var. Mirrors validateLocalAgentServerPath.
*/
function validateLocalAutomationPath(localPath) {
if (!isAbsolute(localPath)) {
throw new Error(
`OH_AUTOMATION_LOCAL_PATH must be an absolute path, got: ${localPath}`,
);
}
if (!existsSync(localPath)) {
throw new Error(`OH_AUTOMATION_LOCAL_PATH does not exist: ${localPath}`);
}
const projectFile = join(localPath, "pyproject.toml");
if (!existsSync(projectFile)) {
throw new Error(
`OH_AUTOMATION_LOCAL_PATH is not a Python project (no pyproject.toml): ${projectFile}`,
);
}
}
/**
* Build the uvx command for running automation backend.
*
* Environment variables (highest precedence first):
* - OH_AUTOMATION_LOCAL_PATH: Absolute path to a local checkout
* - OH_AUTOMATION_GIT_REF: Git commit SHA or branch name
* - OH_AUTOMATION_VERSION: Specific PyPI version (e.g., "1.0.0a1")
*
* If none are set, defaults to the released version specified by
* DEFAULT_AUTOMATION_VERSION. Set OH_AUTOMATION_GIT_REF to use a
* git branch or commit instead.
*/
function buildAutomationCommand(env = process.env) {
const localPath = env.OH_AUTOMATION_LOCAL_PATH;
const gitRef = env.OH_AUTOMATION_GIT_REF;
const version = env.OH_AUTOMATION_VERSION;
const repoUrl = env.OH_AUTOMATION_REPO || DEFAULT_AUTOMATION_REPO;
const uvxArgs = [];
let source = "";
if (localPath) {
// Run straight from a local checkout via `uv run --project`, so
// uncommitted working-tree changes are picked up. Outranks the other
// automation env vars, mirroring OH_AGENT_SERVER_LOCAL_PATH for the
// agent-server SDK; buildConfig drops it when --automation-git-ref asks
// for a specific ref.
return {
command: "uv",
args: [
"run",
"--project",
localPath,
"uvicorn",
"openhands.automation.app:app",
],
source: `local (${localPath})`,
};
}
if (gitRef) {
// Use git ref - refresh to ensure latest commit is fetched
const gitUrl = `git+${repoUrl}@${gitRef}`;
uvxArgs.push(
"--refresh",
"--from",
gitUrl,
"uvicorn",
"openhands.automation.app:app",
);
source = `git (${gitRef})`;
} else if (version) {
// Use specific PyPI version
uvxArgs.push(
"--from",
`${DEFAULT_AUTOMATION_PACKAGE}==${version}`,
"uvicorn",
"openhands.automation.app:app",
);
source = `PyPI (${version})`;
} else {
// Default to released PyPI version
uvxArgs.push(
"--from",
`${DEFAULT_AUTOMATION_PACKAGE}==${DEFAULT_AUTOMATION_VERSION}`,
"uvicorn",
"openhands.automation.app:app",
);
source = `PyPI (${DEFAULT_AUTOMATION_VERSION}, default)`;
}
return {
command: "uvx",
args: uvxArgs,
source,
};
}
async function buildConfig(args, env = process.env) {
// Apply args to env for buildAutomationCommand
if (args.automationGitRef) {
env.OH_AUTOMATION_GIT_REF = args.automationGitRef;
// An explicit flag outranks an ambient env var. Otherwise someone with
// OH_AUTOMATION_LOCAL_PATH exported in their shell profile would run their
// own working tree while believing they were reproducing against the ref
// they just passed.
if (env.OH_AUTOMATION_LOCAL_PATH) {
logStep(
"automation",
`--automation-git-ref ${args.automationGitRef} overrides OH_AUTOMATION_LOCAL_PATH (${env.OH_AUTOMATION_LOCAL_PATH})`,
);
delete env.OH_AUTOMATION_LOCAL_PATH;
}
}
if (args.automationRepo) {
env.OH_AUTOMATION_REPO = args.automationRepo;
}
const frontendOnly = Boolean(args.frontendOnly);
const backendOnly = Boolean(args.backendOnly);
if (frontendOnly && backendOnly) {
throw new Error(
"--frontend-only and --backend-only cannot be used together",
);
}
const launchFrontend = !backendOnly;
const launchAgentServer = !frontendOnly;
const launchAutomation = !frontendOnly;
const isPublic = args.public;
if (isPublic && frontendOnly) {
throw new Error("--public cannot be used with --frontend-only");
}
// In public mode, LOCAL_BACKEND_API_KEY is required β€” without it the
// auth screen has nothing to validate against.
if (isPublic && !env.LOCAL_BACKEND_API_KEY) {
logError(
"PUBLIC MODE requires LOCAL_BACKEND_API_KEY environment variable.\n" +
" Example: LOCAL_BACKEND_API_KEY=my-secret npm run dev -- --public",
);
process.exit(1);
}
// Preferred ports (from env or defaults).
// OH_CANVAS_SAFE_BACKEND_PORT / OH_CANVAS_SAFE_AUTOMATION_PORT /
// OH_CANVAS_SAFE_VITE_PORT allow tests (and advanced users) to redirect
// internal service ports without affecting the production default.
const preferredIngressPort = args.port || parseInt(env.PORT, 10) || 8000;
const preferredBackendPort =
parseInt(env.OH_CANVAS_SAFE_BACKEND_PORT, 10) || DEFAULT_BACKEND_PORT;
const preferredAutomationPort =
parseInt(env.OH_CANVAS_SAFE_AUTOMATION_PORT, 10) || DEFAULT_AUTOMATION_PORT;
const preferredVitePort = parseInt(env.OH_CANVAS_SAFE_VITE_PORT, 10) || 3001;
// Fail fast if any preferred port for a service in this mode is already in use.
const requiredPorts = [{ name: "ingress", port: preferredIngressPort }];
if (launchAgentServer) {
requiredPorts.push({ name: "agent-server", port: preferredBackendPort });
}
if (launchAutomation) {
requiredPorts.push({ name: "automation", port: preferredAutomationPort });
}
if (launchFrontend) {
requiredPorts.push({ name: "frontend", port: preferredVitePort });
}
logStep("ports", "Checking ports...");
await assertPortsFree(requiredPorts);
const vscodePort = preferredBackendPort + 1000;
// API key β€” shared by both agent-server and automation backend.
// Both validate it via the `X-Session-API-Key` header.
// LOCAL_BACKEND_API_KEY is the single user-facing env var: if set it's
// used directly; otherwise one is auto-generated and persisted.
const stateDir =
env.OH_CANVAS_SAFE_STATE_DIR ||
join(homedir(), ".openhands", "agent-canvas");
const safeConfig = buildSafeDevConfig(projectRoot, {
...env,
OH_CANVAS_SAFE_STATE_DIR: stateDir,
OH_CANVAS_SAFE_BACKEND_PORT: preferredBackendPort.toString(),
OH_CANVAS_SAFE_VSCODE_PORT: vscodePort.toString(),
});
const sessionApiKey = safeConfig.sessionApiKey;
if (isPublic) {
logService(
"auth",
"PUBLIC MODE β€” key will NOT be injected into the frontend",
c.yellow,
);
logService(
"auth",
"Users must paste the LOCAL_BACKEND_API_KEY in the browser",
c.dim,
);
}
return {
// Ingress port (main entry point)
ingressPort: preferredIngressPort,
// Service ports (internal)
agentServerPort: preferredBackendPort,
autoBackendPort: preferredAutomationPort,
vitePort: preferredVitePort,
vscodePort,
// Prefix the editor is served under on the ingress origin. Carried on the
// config so the route table and the agent-server env are built from one
// value (see getLocalServiceRoutes / buildAgentServerEnv).
vscodeBasePath: safeConfig.vscodeBasePath,
// Paths
canvasPath: projectRoot,
// Data directories (same as dev-safe.mjs)
stateDir,
// Only bake the host-side workspace path when this launcher also starts
// the agent-server that can read it. In frontend-only mode the backend may
// be a tunnel/remote service, so leave VITE_WORKING_DIR unset unless the
// user explicitly supplied a backend-relative value.
viteWorkingDir: launchAgentServer
? safeConfig.workingDir
: env.VITE_WORKING_DIR,
// Auth β€” single key for both backends
sessionApiKey,
// Public mode β€” the session key should NOT be baked into the frontend
isPublic,
frontendOnly,
backendOnly,
launchFrontend,
launchAgentServer,
launchAutomation,
verbose: args.verbose,
};
}
// ═══════════════════════════════════════════════════════════════════════════
// Prerequisites & Setup
// ═══════════════════════════════════════════════════════════════════════════
function commandExists(cmd) {
const result =
process.platform === "win32"
? spawnSync("where.exe", [cmd], { stdio: "pipe" })
: spawnSync("sh", ["-c", `command -v ${cmd}`], { stdio: "pipe" });
return result.status === 0;
}
function checkPrerequisites({
checkUvx = true,
checkNpm = true,
checkFrontendDependencies = true,
} = {}) {
logStep("1/2", "Checking prerequisites...");
if (checkUvx) {
if (!commandExists("uvx")) {
const uvxGuidance = formatMissingUvxGuidance(projectRoot);
console.error(uvxGuidance);
fileLog("error", stripAnsi(uvxGuidance));
process.exit(1);
}
logSuccess("uvx found");
}
if (checkNpm) {
if (!commandExists("npm")) {
logError("npm is required but not found");
process.exit(1);
}
logSuccess("npm found");
}
if (checkFrontendDependencies) {
try {
validateFrontendDependencies(projectRoot);
} catch (error) {
logError(error instanceof Error ? error.message : String(error));
process.exit(1);
}
logSuccess("frontend dependencies found");
}
}
function ensureDirectories(config) {
const dirs = [
config.stateDir,
// Both agent-server and automation use storage; create it unconditionally
// whenever either backend service runs (i.e. not frontend-only).
...(!config.frontendOnly ? [join(config.stateDir, "storage")] : []),
];
if (config.launchAgentServer) {
dirs.push(
join(config.stateDir, "dev_conversations"),
join(config.stateDir, "workspaces"),
join(config.stateDir, "bash_events"),
);
}
if (config.launchAutomation) {
dirs.push(
// Automation DB directory β€” matches docker/entrypoint.sh mkdir -p behaviour.
dirname(
join(dirname(config.stateDir), SHARED_DEFAULTS.paths.automationDb),
),
);
}
for (const dir of dirs) {
mkdirSync(dir, { recursive: true });
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Process Management
// ═══════════════════════════════════════════════════════════════════════════
const processes = new Map();
const shutdownHooks = createShutdownHookRegistry((err) => {
logService("cleanup", `Cleanup hook failed: ${err.message}`, c.yellow);
});
// Optional external listener for every service log line. Set by `main()` from
// its `onServiceLog` option so embedded launchers (e.g. the Electron desktop
// app) can stream uvx download / install progress to their loading window
// without touching the terminal logging path. Receives `(name, line, level)`
// where `level` is one of "stdout" | "stderr" | "info" | "warn" | "error".
let serviceLogListener = null;
export function setServiceLogListener(listener) {
serviceLogListener = typeof listener === "function" ? listener : null;
}
function emitServiceLog(name, line, level) {
if (!serviceLogListener) return;
try {
serviceLogListener(name, line, level);
} catch {
// Never let a listener bug crash the dev stack.
}
}
function registerShutdownHook(hook) {
return shutdownHooks.add(hook);
}
function spawnService(name, command, args, options = {}) {
const proc = spawn(
resolveWindowsCommand(command),
args,
getProcessTreeSpawnOptions({
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env, ...options.env },
cwd: options.cwd,
}),
);
const color = options.color || c.reset;
const parseLogLine = options.parseLogLine;
proc.stdout.on("data", (data) => {
data
.toString()
.split("\n")
.filter(Boolean)
.forEach((line) => {
const trimmed = line.trim();
const parsed = parseLogLine ? parseLogLine(trimmed) : null;
logService(
name,
parsed ? parsed.text : trimmed,
parsed ? parsed.color : color,
);
emitServiceLog(name, trimmed, "stdout");
});
});
proc.stderr.on("data", (data) => {
data
.toString()
.split("\n")
.filter(Boolean)
.forEach((line) => {
const trimmed = line.trim();
const parsed = parseLogLine ? parseLogLine(trimmed) : null;
logService(
name,
parsed ? parsed.text : trimmed,
parsed ? parsed.color : c.yellow,
);
emitServiceLog(name, trimmed, "stderr");
});
});
proc.on("error", (error) => {
logError(`${name} failed to start: ${error.message}`);
emitServiceLog(name, `failed to start: ${error.message}`, "error");
});
proc.on("exit", (code, _signal) => {
if (code !== 0 && code !== null && !shuttingDown) {
logService(name, `Exited with code ${code}`, c.red);
emitServiceLog(name, `exited with code ${code}`, "error");
}
processes.delete(name);
});
processes.set(name, proc);
return proc;
}
async function waitForService(name, url, timeoutMs = 30000) {
const start = Date.now();
let lastError = null;
while (Date.now() - start < timeoutMs) {
try {
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
if (res.ok) {
logService(name, `Ready at ${url}`, c.green);
return true;
}
} catch (err) {
lastError = err;
// Keep trying
}
await delay(500);
}
const elapsed = Math.round((Date.now() - start) / 1000);
logService(name, `Timeout waiting for ${url} after ${elapsed}s`, c.red);
if (lastError) {
logService(name, `Last error: ${lastError.message}`, c.dim);
}
return false;
}
// ═══════════════════════════════════════════════════════════════════════════
// Service Starters
// ═══════════════════════════════════════════════════════════════════════════
const AUTOMATION_ROUTE_PREFIX = "/api/automation";
const AGENT_SERVER_ROUTE_PREFIXES = [
"/api",
"/sockets",
"/server_info",
"/health",
"/ready",
"/alive",
"/docs",
"/redoc",
"/openapi.json",
];
// This launcher starts the agent-server with `--host 127.0.0.1`, but localhost
// can resolve to ::1 first (notably on Windows), so every request this process
// or the automation backend makes to it must address IPv4 explicitly.
function getAgentServerBaseUrl(config) {
return `http://127.0.0.1:${config.agentServerPort}`;
}
function getLocalServiceRoutes(config) {
const routes = [];
// These services bind to IPv4 loopback, but localhost can resolve to ::1.
if (config.launchAutomation) {
routes.push([
AUTOMATION_ROUTE_PREFIX,
`http://127.0.0.1:${config.autoBackendPort}`,
]);
}
if (config.launchAgentServer) {
for (const prefix of AGENT_SERVER_ROUTE_PREFIXES) {
routes.push([prefix, getAgentServerBaseUrl(config)]);
}
// The editor is a separate process on its own port, but it is reached
// through the same origin as the canvas so no second port has to be
// published. The prefix is deliberately preserved rather than stripped:
// agent-server launches openvscode-server with `--server-base-path`, so
// the editor generates its own HTTP and WebSocket URLs beneath the prefix
// and only answers there. `createRouter` matches the longest prefix and
// the proxy forwards the original path, so both are already handled.
if (config.vscodeBasePath) {
routes.push([
config.vscodeBasePath,
`http://127.0.0.1:${config.vscodePort}`,
]);
}
}
return routes;
}
function buildRouteArgs(routes) {
return routes.flatMap(([prefix, url]) => ["--route", `${prefix}=${url}`]);
}
/**
* The editor prefix, if this mode serves it, as `--no-referrer-prefix` args.
*
* agent-server hands the editor a connection token derived from its session
* key and advertises it in the URL's query string, so the workbench document
* must not leak a Referer to the subresources it loads.
*/
function getNoReferrerPrefixArgs(config) {
if (!config.launchAgentServer || !config.vscodeBasePath) return [];
return ["--no-referrer-prefix", config.vscodeBasePath];
}
/**
* The editor prefix, if this mode serves it, as `--vscode-base-path` args.
*
* Gated on exactly the same condition as the editor route in
* `getLocalServiceRoutes`, because they answer the same question: an origin
* advertises the editor if and only if it routes it. static-server enforces
* that pairing at startup, so a future edit that breaks it fails loudly rather
* than shipping a control that opens the SPA.
*/
function getVSCodeAdvertiseArgs(config) {
if (!config.launchAgentServer || !config.vscodeBasePath) return [];
return ["--vscode-base-path", config.vscodeBasePath];
}
/**
* Build --reject-prefix args for the static server.
* In frontend-only mode, API paths that have no backend should return 503
* instead of being SPA-fallbacked to index.html.
*/
function getRejectPrefixes(config) {
const prefixes = [];
if (!config.launchAutomation) {
prefixes.push(AUTOMATION_ROUTE_PREFIX);
}
if (!config.launchAgentServer) {
for (const prefix of AGENT_SERVER_ROUTE_PREFIXES) {
prefixes.push(prefix);
}
// No agent-server means no editor behind this prefix either. Reject it
// rather than SPA-fallbacking to index.html, which would answer an editor
// request with the canvas shell.
if (config.vscodeBasePath) {
prefixes.push(config.vscodeBasePath);
}
}
return prefixes;
}
function buildRejectPrefixArgs(prefixes) {
return prefixes.flatMap((prefix) => ["--reject-prefix", prefix]);
}
function getFrontendBackend(config) {
return config.launchFrontend ? `http://localhost:${config.vitePort}` : null;
}
function buildViteBackendEnv(config, env = process.env) {
// VITE_BACKEND_HOST tells the Vite dev-server proxy (vite.config.ts) where
// to forward /api, /sockets, etc. It is NOT read by the frontend at
// runtime, so it is safe to keep as an absolute address.
//
// VITE_BACKEND_BASE_URL is intentionally left unset so the frontend falls
// back to window.location.origin (same-origin) at runtime β€” matching the
// behaviour of dev:static / agent-canvas and keeping the dev server
// portable across localhost, LAN hosts, SSH tunnels, and ngrok.
const backendHost = config.launchAgentServer
? `127.0.0.1:${config.ingressPort}`
: (env.VITE_BACKEND_HOST ??
env.VITE_BACKEND_BASE_URL?.replace(/^https?:\/\//, "") ??
"127.0.0.1:8000");
const env_out = { VITE_BACKEND_HOST: backendHost };
// If the user supplied VITE_BACKEND_BASE_URL with an https:// scheme and
// did not explicitly set VITE_USE_TLS, propagate the HTTPS intent so the
// Vite proxy forwards over TLS instead of plain HTTP.
if (
!config.launchAgentServer &&
env.VITE_BACKEND_BASE_URL?.startsWith("https://") &&
env.VITE_USE_TLS === undefined
) {
env_out.VITE_USE_TLS = "true";
}
return env_out;
}
function buildAgentServerAutomationEnv(config) {
return {
// Make the session API key available to terminal commands spawned by the
// agent-server as OPENHANDS_AUTOMATION_API_KEY. The launcher also seeds
// this into Settings > Secrets, but agents commonly create automations
// with a curl command that references `$OPENHANDS_AUTOMATION_API_KEY`;
// exposing it here keeps that path working even before/without
// secret-registry env expansion.
OPENHANDS_AUTOMATION_API_KEY: config.sessionApiKey,
};
}
function buildAutomationTelemetryEnv(env = process.env) {
const telemetryDisabled = env.VITE_DO_NOT_TRACK === "1";
const apiKey =
env.AUTOMATION_POSTHOG_API_KEY ||
env.VITE_POSTHOG_API_KEY ||
(telemetryDisabled ? "" : DEFAULT_POSTHOG_API_KEY);
if (!apiKey) return {};
return {
AUTOMATION_POSTHOG_API_KEY: apiKey,
AUTOMATION_POSTHOG_HOST:
env.AUTOMATION_POSTHOG_HOST ||
env.VITE_POSTHOG_HOST ||
DEFAULT_POSTHOG_HOST,
};
}
function startAgentServer(config) {
logService(
"agent-server",
`Starting on port ${config.agentServerPort}...`,
c.blue,
);
const agentServerCmd = buildAgentServerCommand(process.env);
logService("agent-server", `Using ${agentServerCmd.source}`, c.dim);
// Build safe config for agent-server env vars
const safeConfig = buildSafeDevConfig(config.canvasPath, {
...process.env,
OH_CANVAS_SAFE_STATE_DIR: config.stateDir,
OH_CANVAS_SAFE_BACKEND_PORT: config.agentServerPort.toString(),
OH_CANVAS_SAFE_VSCODE_PORT: config.vscodePort.toString(),
});
const agentServerEnv = {
// Opt into prefix-mode: `getLocalServiceRoutes` registers the matching
// route on both the static server and the ingress, so the prefix this
// advertises resolves to the editor port on the canvas origin.
...buildAgentServerEnv(safeConfig, {
vscodeBasePath: config.vscodeBasePath,
}),
...buildAgentServerAutomationEnv(config),
OPENHANDS_REMOTE_WS_READY_REQUIRED:
process.env.OPENHANDS_REMOTE_WS_READY_REQUIRED || "false",
// Ensure the agent-server uses the resolved key from config. This is
// LOCAL_BACKEND_API_KEY when set, or the auto-generated persisted key.
OH_SESSION_API_KEYS_0: config.sessionApiKey,
// Emit structured JSON log lines instead of Rich-formatted output.
// Rich wraps long messages across multiple lines and prepends its own
// timestamp; LOG_JSON=true produces one JSON object per record which
// parseAgentServerLogLine re-formats into a clean single-line entry.
LOG_JSON: "true",
};
spawnService(
"agent-server",
agentServerCmd.command,
[
...agentServerCmd.args,
"--host",
"127.0.0.1",
"--port",
String(config.agentServerPort),
],
{
cwd: safeConfig.workspacesPath,
env: agentServerEnv,
color: c.blue,
parseLogLine: parseAgentServerLogLine,
},
);
}
function startAutomationBackend(config) {
logService(
"automation",
`Starting on port ${config.autoBackendPort}...`,
c.green,
);
const automationCmd = buildAutomationCommand(process.env);
logService("automation", `Using ${automationCmd.source}`, c.dim);
spawnService(
"automation",
automationCmd.command,
[
...automationCmd.args,
"--host",
"127.0.0.1",
"--port",
config.autoBackendPort.toString(),
],
{
cwd: config.stateDir,
env: {
// Force UTF-8 for all Python file I/O (same reason as agent-server;
// see buildAgentServerEnv in dev-safe.mjs).
PYTHONUTF8: "1",
OPENHANDS_REMOTE_WS_READY_REQUIRED:
process.env.OPENHANDS_REMOTE_WS_READY_REQUIRED || "false",
// The URL the automation backend itself uses to call the
// agent-server's REST API (tarball upload + bash dispatch).
//
// Priority:
// 1. AUTOMATION_AGENT_SERVER_URL explicitly set in the user's env
// 2. `127.0.0.1:<agentServerPort>`
AUTOMATION_AGENT_SERVER_URL:
process.env.AUTOMATION_AGENT_SERVER_URL ||
getAgentServerBaseUrl(config),
// The URL exported into the in-sandbox bash chain as
// `AGENT_SERVER_URL` (read by main.py / setup.sh to call back into
// the agent-server).
//
// Priority:
// 1. AUTOMATION_SANDBOX_AGENT_SERVER_URL explicitly set in env
// 2. launcher-provided value
// 3. unset β€” backend falls back to AUTOMATION_AGENT_SERVER_URL
...(process.env.AUTOMATION_SANDBOX_AGENT_SERVER_URL ||
config.sandboxAgentServerUrl
? {
AUTOMATION_SANDBOX_AGENT_SERVER_URL:
process.env.AUTOMATION_SANDBOX_AGENT_SERVER_URL ||
config.sandboxAgentServerUrl,
}
: {}),
AUTOMATION_AGENT_SERVER_API_KEY: config.sessionApiKey,
// ~/.openhands/automation/automations.db β€” matches docker/entrypoint.sh.
AUTOMATION_DB_URL: `sqlite+aiosqlite:///${join(dirname(config.stateDir), SHARED_DEFAULTS.paths.automationDb)}`,
// The automation backend uses this as its publicly-reachable base
// URL: it's appended to callback URLs and injected into each
// sandbox as `AUTOMATION_API_URL` (consumed by setup.sh for
// /sdk-version and by the SDK for run completion).
// Priority:
// 1. AUTOMATION_BASE_URL explicitly set in the user's env
// 2. launcher-provided host
// 3. `localhost`
AUTOMATION_BASE_URL:
process.env.AUTOMATION_BASE_URL ||
`http://${config.automationApiHost ?? "localhost"}:${config.ingressPort}`,
// The dispatcher resolves this path and embeds it into a
// `mkdir -p ...` shell command executed by the agent-server.
// Priority:
// 1. AUTOMATION_WORKSPACE_BASE explicitly set in the user's env
// 2. `automationWorkspaceBase` option passed by the launcher
// 3. host-side default under config.stateDir
AUTOMATION_WORKSPACE_BASE:
process.env.AUTOMATION_WORKSPACE_BASE ||
config.automationWorkspaceBase ||
join(config.stateDir, "workspaces"),
// Session API key for self-hosted auth β€” shared with agent-server via X-Session-API-Key header
AUTOMATION_LOCAL_API_KEY: config.sessionApiKey,
...buildAutomationTelemetryEnv(),
// KV store secret β€” required for automations to use the built-in
// key-value store for state persistence between runs. Used for JWT
// signing and value encryption.
// Priority:
// 1. AUTOMATION_KV_SECRET explicitly set in the user's env
// 2. sessionApiKey β€” convenient zero-config default for local dev
AUTOMATION_KV_SECRET:
process.env.AUTOMATION_KV_SECRET || config.sessionApiKey,
// CORS: allow localhost origins for dev, unless explicitly overridden.
AUTOMATION_CORS_ORIGINS:
process.env.AUTOMATION_CORS_ORIGINS ||
`http://localhost:${config.ingressPort},http://127.0.0.1:${config.ingressPort},http://localhost:3001,http://127.0.0.1:3001`,
FILE_STORE: "local",
LOCAL_STORAGE_PATH: join(config.stateDir, "storage"),
OPENHANDS_SUPPRESS_BANNER: "1",
},
color: c.green,
},
);
}
// ═══════════════════════════════════════════════════════════════════════════
// Main
// ═══════════════════════════════════════════════════════════════════════════
let shuttingDown = false;
function shutdown() {
if (shuttingDown) return;
shuttingDown = true;
console.log("");
console.log(`${c.yellow}Shutting down...${c.reset}`);
fileLog("info", "Shutting down...");
for (const [name, proc] of processes) {
logService(name, "Stopping...", c.dim);
signalProcessTree(proc, "SIGTERM");
}
setTimeout(() => {
for (const [name, proc] of processes) {
if (isProcessRunning(proc)) {
logService(name, "Force stopping...", c.dim);
signalProcessTree(proc, "SIGKILL");
}
}
shutdownHooks.run();
process.exit(0);
}, 3000);
}
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
process.on("SIGHUP", shutdown);
function startIngress(config) {
logService("ingress", `Starting on port ${config.ingressPort}...`, c.yellow);
const ingressScript = join(projectRoot, "scripts", "ingress.mjs");
const frontendBackend = getFrontendBackend(config);
const runtimeServicesInfo = config.launchAgentServer
? JSON.stringify(buildAutomationRuntimeServicesInfo(config))
: null;
spawnService(
"ingress",
"node",
[
ingressScript,
"--port",
config.ingressPort.toString(),
...(runtimeServicesInfo
? ["--runtime-services-info", runtimeServicesInfo]
: []),
...buildRouteArgs(getLocalServiceRoutes(config)),
...getNoReferrerPrefixArgs(config),
...(frontendBackend ? ["--default", frontendBackend] : []),
],
{
cwd: projectRoot,
color: c.yellow,
},
);
}
/**
* Build the JSON-serializable runtime services info for an automation
* stack. Backend-serving processes append this to `/server_info` so any
* frontend connected to the backend can populate the agent's
* `<RUNTIME_SERVICES>` system-prompt block.
*/
export function buildAutomationRuntimeServicesInfo(config) {
return buildRuntimeServicesInfo({
mode: config.mode ?? "dev:automation",
agentHostAlias: config.agentHostAlias ?? "localhost",
agentServerPort: config.agentServerPort,
ingressPort: config.ingressPort,
frontendPort: config.launchFrontend ? config.vitePort : undefined,
// The same port hosts Vite in dynamic mode and a static-file server
// in static mode. The launcher records this on the config so the
// description shown to the agent matches reality.
frontendKind: config.frontendKind ?? "vite",
automation: config.launchAutomation
? { port: config.autoBackendPort }
: undefined,
});
}
function startVite(config) {
logService("vite", `Starting on port ${config.vitePort}...`, c.magenta);
const frontendCommand = buildNpmScriptCommand("dev:frontend");
const viteEnv = {
// Full-stack mode points Vite at this launcher's ingress. Frontend-only
// mode uses the separately running backend ingress instead.
...buildViteBackendEnv(config),
VITE_FRONTEND_PORT: config.vitePort.toString(),
};
if (config.viteWorkingDir) {
viteEnv.VITE_WORKING_DIR = config.viteWorkingDir;
}
// Vite serves the HTML for this mode's browser origin, so this is where the
// editor-capability advertisement has to be baked. The ingress in front of it
// routes the prefix but is a pure proxy β€” it injects nothing into the
// document, so it cannot tell the frontend what it serves.
//
// Both variables or neither: `vite.config.ts` only registers the editor proxy
// when it has a target as well as a prefix, and this stack has two supported
// browser origins β€” the ingress and Vite's own port, which is why the latter
// is in AUTOMATION_CORS_ORIGINS. On the ingress the prefix is routed by the
// ingress itself; on the Vite origin only this proxy can serve it. Baking the
// prefix alone would advertise an editor on the Vite origin whose URL then
// falls through to the SPA β€” the dead button this gating exists to prevent.
if (config.launchAgentServer && config.vscodeBasePath) {
viteEnv.VITE_VSCODE_BASE_PATH = config.vscodeBasePath;
viteEnv.VITE_VSCODE_TARGET = `http://127.0.0.1:${config.vscodePort}`;
}
// In local mode, bake the session key into the frontend so the user
// never has to paste it. In public mode, omit the key and set
// VITE_AUTH_REQUIRED so the frontend shows the API key entry screen
// immediately (no network round-trip needed).
if (config.launchAgentServer && config.isPublic) {
viteEnv.VITE_AUTH_REQUIRED = "true";
} else if (config.launchAgentServer) {
viteEnv.VITE_SESSION_API_KEY = config.sessionApiKey;
}
spawnService("vite", frontendCommand.command, frontendCommand.args, {
cwd: config.canvasPath,
env: viteEnv,
color: c.magenta,
});
}
/**
* Seed the session API key into agent-server's secrets store as
* OPENHANDS_AUTOMATION_API_KEY so agents can authenticate with the
* automation backend in curl commands during conversations.
*
* Includes retry logic to handle slow server startup or transient failures.
*
* @param {object} config - Configuration object with agentServerPort, sessionApiKey
* @param {object} options - Options for retry behavior
* @param {number} options.maxRetries - Maximum number of retry attempts (default: 5)
* @param {number} options.retryDelayMs - Delay between retries in ms (default: 2000)
* @param {number} options.timeoutMs - Request timeout in ms (default: 10000)
* @returns {Promise<boolean>} True if seeding succeeded, false otherwise
*/
async function seedAutomationSecret(config, options = {}) {
const { maxRetries = 5, retryDelayMs = 2000, timeoutMs = 10000 } = options;
const secretName = "OPENHANDS_AUTOMATION_API_KEY";
const secretDescription =
"API key for authenticating with the automation backend";
logService("secrets", `Seeding ${secretName} into agent-server...`, c.dim);
const url = `${getAgentServerBaseUrl(config)}/api/settings/secrets`;
const body = JSON.stringify({
name: secretName,
value: config.sessionApiKey,
description: secretDescription,
});
const headers = {
"Content-Type": "application/json",
// Include session API key if configured
...(config.sessionApiKey && { "X-Session-API-Key": config.sessionApiKey }),
};
let lastError = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(url, {
method: "PUT",
headers,
body,
signal: AbortSignal.timeout(timeoutMs),
});
if (response.ok) {
logService("secrets", `${secretName} seeded successfully`, c.green);
return true;
}
const text = await response.text();
lastError = `HTTP ${response.status}: ${text}`;
// Don't retry on authentication errors - they won't resolve with retries
if (response.status === 401 || response.status === 403) {
logService(
"secrets",
`Warning: Failed to seed secret (${response.status}): ${text}`,
c.yellow,
);
return false;
}
// Retry on server errors or service unavailable
if (attempt < maxRetries) {
logService(
"secrets",
`Retry ${attempt}/${maxRetries} after ${response.status}...`,
c.dim,
);
await delay(retryDelayMs);
}
} catch (err) {
lastError = err.message;
// Connection errors likely mean server isn't ready - wait and retry
if (attempt < maxRetries) {
logService(
"secrets",
`Retry ${attempt}/${maxRetries}: ${err.message}`,
c.dim,
);
await delay(retryDelayMs);
}
}
}
logService(
"secrets",
`Warning: Failed to seed secret after ${maxRetries} attempts: ${lastError}`,
c.yellow,
);
return false;
}
function printBanner(config) {
const stackName = config.frontendOnly
? "Agent Canvas Frontend Stack"
: config.backendOnly
? "Agent Canvas Backend Stack"
: "Agent Canvas + Automation Stack";
// padEnd counts invisible ANSI escape bytes as visible characters, so we
// compute the visible length separately and pad with spaces accordingly.
const ansiEscape = String.fromCharCode(27);
const ansiRe = new RegExp(`${ansiEscape}\\[[0-9;]*m`, "g");
const ansiPadEnd = (str, targetVisible) => {
const visible = str.replace(ansiRe, "").length;
return str + " ".repeat(Math.max(0, targetVisible - visible));
};
// The box has 62-char inner width; each content line needs 63 visible chars
// before the trailing border (1 leading β•‘ + 62 inner).
const BOX_INNER = 63;
console.log("");
console.log(
`${c.green}${c.bold}╔══════════════════════════════════════════════════════════════╗${c.reset}`,
);
console.log(
ansiPadEnd(
`${c.green}${c.bold}β•‘${c.reset} ${c.bold}${stackName}${c.reset}`,
BOX_INNER,
) + `${c.green}${c.bold}β•‘${c.reset}`,
);
console.log(
`${c.green}${c.bold}╠══════════════════════════════════════════════════════════════╣${c.reset}`,
);
console.log(
`${c.green}${c.bold}β•‘${c.reset} ${c.green}${c.bold}β•‘${c.reset}`,
);
console.log(
ansiPadEnd(
`${c.green}${c.bold}β•‘${c.reset} Ingress: ${c.cyan}http://localhost:${config.ingressPort}/${c.reset}`,
BOX_INNER,
) + `${c.green}${c.bold}β•‘${c.reset}`,
);
if (config.launchFrontend) {
console.log(
ansiPadEnd(
`${c.green}${c.bold}β•‘${c.reset} Main UI: ${c.cyan}http://localhost:${config.ingressPort}/${c.reset}`,
BOX_INNER,
) + `${c.green}${c.bold}β•‘${c.reset}`,
);
}
if (config.launchAutomation) {
console.log(
ansiPadEnd(
`${c.green}${c.bold}β•‘${c.reset} API Docs: ${c.cyan}http://localhost:${config.ingressPort}/api/automation/docs${c.reset}`,
BOX_INNER,
) + `${c.green}${c.bold}β•‘${c.reset}`,
);
}
console.log(
`${c.green}${c.bold}β•‘${c.reset} ${c.green}${c.bold}β•‘${c.reset}`,
);
console.log(
`${c.green}${c.bold}β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•${c.reset}`,
);
console.log("");
console.log(`${c.dim}State directory: ${config.stateDir}${c.reset}`);
console.log(`${c.dim}Press Ctrl+C to stop${c.reset}`);
console.log("");
// Write a compact plain-text summary to the log file.
const summary = [
`${stackName} β€” started`,
` Ingress: http://localhost:${config.ingressPort}/`,
...(config.launchFrontend
? [` Main UI: http://localhost:${config.ingressPort}/`]
: []),
...(config.launchAutomation
? [
` API Docs: http://localhost:${config.ingressPort}/api/automation/docs`,
]
: []),
` State directory: ${config.stateDir}`,
];
fileLog("info", summary.join("\n"));
}
async function main(options = {}) {
const {
bannerTitle = "Agent Canvas + Automation Development Stack",
startAgentServer: startAgentServerOverride,
extraPrereqs,
viteWorkingDir,
// Path used as `AUTOMATION_WORKSPACE_BASE` by the automation backend.
// Defaults to a host-side path under config.stateDir.
automationWorkspaceBase,
// Host used in `AUTOMATION_BASE_URL` (the URL the automation sandbox
// uses to call back into the automation backend). Defaults to `localhost`.
automationApiHost,
// Value exported as `AUTOMATION_SANDBOX_AGENT_SERVER_URL` to the
// automation backend. This is the URL the in-sandbox bash chain uses
// to reach the agent-server. When unset the backend falls back to
// AUTOMATION_AGENT_SERVER_URL.
sandboxAgentServerUrl,
staticMode: staticModeOverride,
defaultStaticMode = false,
buildStaticFrontend,
staticDir: staticDirOverride,
// Hostname the agent uses to reach services running on the host.
agentHostAlias = "localhost",
// Human-readable label for the dev mode, surfaced in the agent's
// <RUNTIME_SERVICES> system-prompt block.
mode = "dev:automation",
// When true, enable public mode (require LOCAL_BACKEND_API_KEY,
// don't bake session key into frontend).
isPublic: isPublicOverride,
// When true, skip the npm prerequisite check. Used by the Electron desktop
// launcher where npm is not needed at runtime in static mode.
skipNpmCheck = false,
// How long to wait for the agent-server's `/server_info` to return 200
// before continuing. Defaults to 60 s, which is fine for warm-cache dev
// workflows. The Electron desktop launcher bumps this to several minutes
// because first-launch on a fresh machine runs `uvx` to download Python
// and install `openhands-agent-server` from PyPI, which can take much
// longer than 60 s on a slow network.
agentServerReadyTimeoutMs = 60_000,
// Optional `(name, line, level)` callback that receives every service log
// line (stdout, stderr, and lifecycle events) emitted by any spawned
// backend process. Used by the Electron loading screen to surface uvx
// download / install progress to the user. `level` is one of
// "stdout" | "stderr" | "info" | "warn" | "error".
onServiceLog,
} = options;
// Install the listener early so log lines emitted before the first
// `spawnService` call (e.g. by future setup steps) are also captured.
setServiceLogListener(onServiceLog);
const args = parseArgs();
// Allow options to override CLI args for public mode
if (isPublicOverride != null) {
args.public = isPublicOverride;
}
// Allow options to override CLI args (for bin/agent-canvas.mjs)
const useStaticMode =
staticModeOverride ??
(args.dynamic ? false : args.static || defaultStaticMode);
const staticDir =
staticDirOverride ?? args.staticDir ?? join(projectRoot, "build");
const modeLabel = useStaticMode && !args.backendOnly ? "(Static)" : "";
const titleWithMode = modeLabel ? `${bannerTitle} ${modeLabel}` : bannerTitle;
console.log("");
console.log(`${c.cyan}${c.bold}${titleWithMode}${c.reset}`);
console.log("");
fileLog("info", titleWithMode);
// Setup phase
checkPrerequisites({
checkUvx: !args.frontendOnly,
// Static-mode + backend-only has no frontend to build, so npm is not
// required β€” unless the caller provides a custom buildStaticFrontend hook.
// The Electron desktop launcher passes `skipNpmCheck: true` because the
// packaged binary serves a pre-built static frontend and never invokes
// npm at runtime, so we suppress the check unconditionally there.
checkNpm:
!skipNpmCheck &&
((!useStaticMode && !args.backendOnly) ||
typeof buildStaticFrontend === "function"),
checkFrontendDependencies:
(!useStaticMode && !args.backendOnly) ||
typeof buildStaticFrontend === "function",
});
// Fail fast on an obviously bad OH_AGENT_SERVER_LOCAL_PATH so we don't waste
// time allocating ports / generating keys / launching uvx with a path that
// would only produce a cryptic build error. Mirrors dev-safe.mjs and
// dev-extra-backend.mjs.
if (!args.frontendOnly && process.env.OH_AGENT_SERVER_LOCAL_PATH) {
try {
validateLocalAgentServerPath(process.env.OH_AGENT_SERVER_LOCAL_PATH);
} catch (error) {
logError(error instanceof Error ? error.message : String(error));
process.exit(1);
}
}
// Same for the automation checkout -- skipped when --automation-git-ref was
// passed, since buildConfig drops the env var in favor of the explicit flag.
if (
!args.frontendOnly &&
!args.automationGitRef &&
process.env.OH_AUTOMATION_LOCAL_PATH
) {
try {
validateLocalAutomationPath(process.env.OH_AUTOMATION_LOCAL_PATH);
} catch (error) {
logError(error instanceof Error ? error.message : String(error));
process.exit(1);
}
}
// Build config with dynamic port allocation
const config = await buildConfig(args);
if (viteWorkingDir) config.viteWorkingDir = viteWorkingDir;
if (automationWorkspaceBase) {
config.automationWorkspaceBase = automationWorkspaceBase;
}
if (automationApiHost) {
config.automationApiHost = automationApiHost;
}
if (sandboxAgentServerUrl) {
config.sandboxAgentServerUrl = sandboxAgentServerUrl;
}
// Stamp the dev-mode label, host alias, and frontend kind on the config
// so downstream helpers (Vite spawn, static build) can produce a
// runtime-services info object describing what the agent can reach.
config.mode = mode;
config.agentHostAlias = agentHostAlias;
config.frontendKind = useStaticMode ? "static" : "vite";
ensureDirectories(config);
if (typeof extraPrereqs === "function") {
extraPrereqs(config);
}
if (
config.launchFrontend &&
useStaticMode &&
typeof buildStaticFrontend === "function"
) {
buildStaticFrontend(config, args);
}
// In static mode, verify build exists after any launcher-managed build.
if (config.launchFrontend && useStaticMode && !existsSync(staticDir)) {
logError(`Static directory not found: ${staticDir}`);
logError(`Run 'npm run build' first to create the static files.`);
process.exit(1);
}
// Start services phase
logStep("2/2", "Starting services...");
let agentServerReady = false;
// 1. Start agent-server first (automation depends on it).
//
// Readiness timeout defaults to 60 s, which is fine for `npm run dev` against
// a warm uvx cache. The Electron desktop launcher overrides this via the
// `agentServerReadyTimeoutMs` option because first-launch on a fresh machine
// runs `uvx` to download Python + install `openhands-agent-server` from PyPI,
// which can take several minutes. Dropping the user into a half-booted UI
// before that completes triggers axios "Request timeout" popups on the first
// SPA fetch that hits an unbound port 18000.
if (config.launchAgentServer) {
const agentServerStarter = startAgentServerOverride ?? startAgentServer;
agentServerStarter(config);
agentServerReady = await waitForService(
"agent-server",
`${getAgentServerBaseUrl(config)}/server_info`,
agentServerReadyTimeoutMs,
);
}
// 2. Seed automation API key into agent-server secrets
// This makes the key available to agents during conversations
// Note: seedAutomationSecret has its own retry logic if server is still warming up
if (config.launchAutomation && agentServerReady) {
await seedAutomationSecret(config);
} else if (config.launchAutomation) {
logService(
"secrets",
"Skipping secret seeding - agent-server not ready",
c.yellow,
);
}
// 3. Start automation backend
if (config.launchAutomation) {
startAutomationBackend(config);
}
// 4. Start frontend server (Vite dev server OR static server)
if (config.launchFrontend) {
if (useStaticMode) {
startStaticFrontend(config, staticDir);
} else {
startVite(config);
}
}
// 5. Wait for services to be ready
await delay(2000);
// 6. Start ingress proxy (routes traffic only to running services)
startIngress(config);
// Wait for ingress to start
await delay(1000);
printBanner(config);
// Return the resolved config + readiness signal so embedded launchers can
// (a) build URLs from the actual allocated ports and (b) decide whether to
// show an error to the user when the agent-server never came up.
return { config, agentServerReady };
}
function startStaticFrontend(config, staticDir) {
logService("static", `Starting on port ${config.vitePort}...`, c.magenta);
logService("static", `Serving from: ${staticDir}`, c.dim);
// Build the runtime-services info JSON so static-server can append it to
// /server_info. The static-server also injects the old window global for
// compatibility with previously built frontend bundles.
const runtimeServicesInfo = config.launchAgentServer
? JSON.stringify(buildAutomationRuntimeServicesInfo(config))
: null;
const staticServerScript = join(projectRoot, "scripts", "static-server.mjs");
spawnService(
"static",
"node",
[
staticServerScript,
"--dir",
staticDir,
"--port",
String(config.vitePort),
...(process.env.VITE_BASE_PATH
? ["--base-path", process.env.VITE_BASE_PATH]
: []),
// In local mode, inject the API key so the pre-built frontend can
// authenticate transparently. In public mode, pass --auth-required
// so the frontend shows the API key entry screen instead.
...(config.launchAgentServer && !config.isPublic && config.sessionApiKey
? ["--session-api-key", config.sessionApiKey]
: []),
...(config.launchAgentServer && config.isPublic
? ["--auth-required"]
: []),
// Inject runtime-services info so the agent knows what's reachable.
...(runtimeServicesInfo
? ["--runtime-services-info", runtimeServicesInfo]
: []),
// Proxy routes only to services that this launch mode started.
...buildRouteArgs(getLocalServiceRoutes(config)),
// Only the static server injects into the document, so only it can tell
// the frontend this origin serves the editor. The ingress routes the same
// prefix but proxies the HTML through untouched.
...getVSCodeAdvertiseArgs(config),
...getNoReferrerPrefixArgs(config),
// Reject known API prefixes that have no backend β€” returns 503
// instead of SPA-fallbacking to index.html.
...buildRejectPrefixArgs(getRejectPrefixes(config)),
],
{
cwd: config.canvasPath,
color: c.magenta,
},
);
}
// ═══════════════════════════════════════════════════════════════════════════
// Exports for testing
// ═══════════════════════════════════════════════════════════════════════════
export {
buildAgentServerAutomationEnv,
buildAutomationCommand,
buildAutomationTelemetryEnv,
buildConfig,
buildRouteArgs,
buildViteBackendEnv,
getAgentServerBaseUrl,
getFrontendBackend,
getLocalServiceRoutes,
getNoReferrerPrefixArgs,
getRejectPrefixes,
getVSCodeAdvertiseArgs,
main,
registerShutdownHook,
spawnService,
commandExists,
validateLocalAutomationPath,
logService,
logStep,
logSuccess,
logError,
c,
DEFAULT_AUTOMATION_REPO,
DEFAULT_AUTOMATION_PACKAGE,
DEFAULT_AUTOMATION_VERSION,
DEFAULT_AUTOMATION_SDK_VERSION,
DEFAULT_BACKEND_PORT,
DEFAULT_AUTOMATION_PORT,
};
// ═══════════════════════════════════════════════════════════════════════════
// Main entry point (only when run directly, not when imported)
// ═══════════════════════════════════════════════════════════════════════════
// Check if this module is the main entry point
const isMainModule =
process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
if (isMainModule) {
main().catch((err) => {
logError(`Fatal error: ${err.message}`);
if (err.stack) {
console.error(c.dim + err.stack + c.reset);
fileLog("error", err.stack);
}
process.exit(1);
});
}