Download scripts/dev-with-automation.mjs from SaylorTwift/openhands: direct link, hf CLI and curl.
- Browser
- Download file 63.3 kB
-
https://huggingface.co/SaylorTwift/openhands/resolve/main/scripts/dev-with-automation.mjs
- Command line
-
hf download hf://SaylorTwift/openhands/scripts/dev-with-automation.mjs
-
curl -L -o dev-with-automation.mjs https://huggingface.co/SaylorTwift/openhands/resolve/main/scripts/dev-with-automation.mjs
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); | |
| }); | |
| } | |