| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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, ".."); |
|
|
| |
| 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; |
|
|
| |
| |
| |
|
|
| 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)}`); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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; |
| } |
| } |
|
|
| |
| |
| |
|
|
| 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 |
| `); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| 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}`, |
| ); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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) { |
| |
| |
| |
| |
| |
| return { |
| command: "uv", |
| args: [ |
| "run", |
| "--project", |
| localPath, |
| "uvicorn", |
| "openhands.automation.app:app", |
| ], |
| source: `local (${localPath})`, |
| }; |
| } |
|
|
| if (gitRef) { |
| |
| const gitUrl = `git+${repoUrl}@${gitRef}`; |
| uvxArgs.push( |
| "--refresh", |
| "--from", |
| gitUrl, |
| "uvicorn", |
| "openhands.automation.app:app", |
| ); |
| source = `git (${gitRef})`; |
| } else if (version) { |
| |
| uvxArgs.push( |
| "--from", |
| `${DEFAULT_AUTOMATION_PACKAGE}==${version}`, |
| "uvicorn", |
| "openhands.automation.app:app", |
| ); |
| source = `PyPI (${version})`; |
| } else { |
| |
| 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) { |
| |
| if (args.automationGitRef) { |
| env.OH_AUTOMATION_GIT_REF = args.automationGitRef; |
| |
| |
| |
| |
| 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"); |
| } |
|
|
| |
| |
| 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); |
| } |
|
|
| |
| |
| |
| |
| 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; |
|
|
| |
| 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; |
|
|
| |
| |
| |
| |
| 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 { |
| |
| ingressPort: preferredIngressPort, |
|
|
| |
| agentServerPort: preferredBackendPort, |
| autoBackendPort: preferredAutomationPort, |
| vitePort: preferredVitePort, |
| vscodePort, |
| |
| |
| |
| vscodeBasePath: safeConfig.vscodeBasePath, |
|
|
| |
| canvasPath: projectRoot, |
|
|
| |
| stateDir, |
| |
| |
| |
| |
| viteWorkingDir: launchAgentServer |
| ? safeConfig.workingDir |
| : env.VITE_WORKING_DIR, |
|
|
| |
| sessionApiKey, |
|
|
| |
| isPublic, |
|
|
| frontendOnly, |
| backendOnly, |
| launchFrontend, |
| launchAgentServer, |
| launchAutomation, |
|
|
| verbose: args.verbose, |
| }; |
| } |
|
|
| |
| |
| |
|
|
| 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, |
| |
| |
| ...(!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( |
| |
| dirname( |
| join(dirname(config.stateDir), SHARED_DEFAULTS.paths.automationDb), |
| ), |
| ); |
| } |
|
|
| for (const dir of dirs) { |
| mkdirSync(dir, { recursive: true }); |
| } |
| } |
|
|
| |
| |
| |
|
|
| const processes = new Map(); |
| const shutdownHooks = createShutdownHookRegistry((err) => { |
| logService("cleanup", `Cleanup hook failed: ${err.message}`, c.yellow); |
| }); |
|
|
| |
| |
| |
| |
| |
| 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 { |
| |
| } |
| } |
|
|
| 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; |
| |
| } |
| 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; |
| } |
|
|
| |
| |
| |
|
|
| const AUTOMATION_ROUTE_PREFIX = "/api/automation"; |
| const AGENT_SERVER_ROUTE_PREFIXES = [ |
| "/api", |
| "/sockets", |
| "/server_info", |
| "/health", |
| "/ready", |
| "/alive", |
| "/docs", |
| "/redoc", |
| "/openapi.json", |
| ]; |
|
|
| |
| |
| |
| function getAgentServerBaseUrl(config) { |
| return `http://127.0.0.1:${config.agentServerPort}`; |
| } |
|
|
| function getLocalServiceRoutes(config) { |
| const routes = []; |
|
|
| |
| 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)]); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| 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}`]); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| function getNoReferrerPrefixArgs(config) { |
| if (!config.launchAgentServer || !config.vscodeBasePath) return []; |
| return ["--no-referrer-prefix", config.vscodeBasePath]; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function getVSCodeAdvertiseArgs(config) { |
| if (!config.launchAgentServer || !config.vscodeBasePath) return []; |
| return ["--vscode-base-path", config.vscodeBasePath]; |
| } |
|
|
| |
| |
| |
| |
| |
| 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); |
| } |
| |
| |
| |
| 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) { |
| |
| |
| |
| |
| |
| |
| |
| |
| 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 ( |
| !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 { |
| |
| |
| |
| |
| |
| |
| 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); |
|
|
| |
| 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 = { |
| |
| |
| |
| ...buildAgentServerEnv(safeConfig, { |
| vscodeBasePath: config.vscodeBasePath, |
| }), |
| ...buildAgentServerAutomationEnv(config), |
| OPENHANDS_REMOTE_WS_READY_REQUIRED: |
| process.env.OPENHANDS_REMOTE_WS_READY_REQUIRED || "false", |
| |
| |
| OH_SESSION_API_KEYS_0: config.sessionApiKey, |
| |
| |
| |
| |
| 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: { |
| |
| |
| PYTHONUTF8: "1", |
| OPENHANDS_REMOTE_WS_READY_REQUIRED: |
| process.env.OPENHANDS_REMOTE_WS_READY_REQUIRED || "false", |
| |
| |
| |
| |
| |
| |
| AUTOMATION_AGENT_SERVER_URL: |
| process.env.AUTOMATION_AGENT_SERVER_URL || |
| getAgentServerBaseUrl(config), |
| |
| |
| |
| |
| |
| |
| |
| |
| ...(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, |
| |
| AUTOMATION_DB_URL: `sqlite+aiosqlite:///${join(dirname(config.stateDir), SHARED_DEFAULTS.paths.automationDb)}`, |
| |
| |
| |
| |
| |
| |
| |
| |
| AUTOMATION_BASE_URL: |
| process.env.AUTOMATION_BASE_URL || |
| `http://${config.automationApiHost ?? "localhost"}:${config.ingressPort}`, |
| |
| |
| |
| |
| |
| |
| AUTOMATION_WORKSPACE_BASE: |
| process.env.AUTOMATION_WORKSPACE_BASE || |
| config.automationWorkspaceBase || |
| join(config.stateDir, "workspaces"), |
| |
| AUTOMATION_LOCAL_API_KEY: config.sessionApiKey, |
| ...buildAutomationTelemetryEnv(), |
| |
| |
| |
| |
| |
| |
| AUTOMATION_KV_SECRET: |
| process.env.AUTOMATION_KV_SECRET || config.sessionApiKey, |
| |
| 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, |
| }, |
| ); |
| } |
|
|
| |
| |
| |
|
|
| 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, |
| }, |
| ); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| 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, |
| |
| |
| |
| 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 = { |
| |
| |
| ...buildViteBackendEnv(config), |
| VITE_FRONTEND_PORT: config.vitePort.toString(), |
| }; |
| if (config.viteWorkingDir) { |
| viteEnv.VITE_WORKING_DIR = config.viteWorkingDir; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| if (config.launchAgentServer && config.vscodeBasePath) { |
| viteEnv.VITE_VSCODE_BASE_PATH = config.vscodeBasePath; |
| viteEnv.VITE_VSCODE_TARGET = `http://127.0.0.1:${config.vscodePort}`; |
| } |
|
|
| |
| |
| |
| |
| 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, |
| }); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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", |
| |
| ...(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}`; |
|
|
| |
| if (response.status === 401 || response.status === 403) { |
| logService( |
| "secrets", |
| `Warning: Failed to seed secret (${response.status}): ${text}`, |
| c.yellow, |
| ); |
| return false; |
| } |
|
|
| |
| if (attempt < maxRetries) { |
| logService( |
| "secrets", |
| `Retry ${attempt}/${maxRetries} after ${response.status}...`, |
| c.dim, |
| ); |
| await delay(retryDelayMs); |
| } |
| } catch (err) { |
| lastError = err.message; |
|
|
| |
| 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"; |
|
|
| |
| |
| 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)); |
| }; |
| |
| |
| 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(""); |
|
|
| |
| 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, |
| |
| |
| automationWorkspaceBase, |
| |
| |
| automationApiHost, |
| |
| |
| |
| |
| sandboxAgentServerUrl, |
| staticMode: staticModeOverride, |
| defaultStaticMode = false, |
| buildStaticFrontend, |
| staticDir: staticDirOverride, |
| |
| agentHostAlias = "localhost", |
| |
| |
| mode = "dev:automation", |
| |
| |
| isPublic: isPublicOverride, |
| |
| |
| skipNpmCheck = false, |
| |
| |
| |
| |
| |
| |
| agentServerReadyTimeoutMs = 60_000, |
| |
| |
| |
| |
| |
| onServiceLog, |
| } = options; |
|
|
| |
| |
| setServiceLogListener(onServiceLog); |
|
|
| const args = parseArgs(); |
|
|
| |
| if (isPublicOverride != null) { |
| args.public = isPublicOverride; |
| } |
|
|
| |
| 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); |
|
|
| |
| checkPrerequisites({ |
| checkUvx: !args.frontendOnly, |
| |
| |
| |
| |
| |
| checkNpm: |
| !skipNpmCheck && |
| ((!useStaticMode && !args.backendOnly) || |
| typeof buildStaticFrontend === "function"), |
| checkFrontendDependencies: |
| (!useStaticMode && !args.backendOnly) || |
| typeof buildStaticFrontend === "function", |
| }); |
|
|
| |
| |
| |
| |
| 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); |
| } |
| } |
|
|
| |
| |
| 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); |
| } |
| } |
|
|
| |
| 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; |
| } |
| |
| |
| |
| 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); |
| } |
|
|
| |
| 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); |
| } |
|
|
| |
| logStep("2/2", "Starting services..."); |
|
|
| let agentServerReady = false; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| if (config.launchAgentServer) { |
| const agentServerStarter = startAgentServerOverride ?? startAgentServer; |
| agentServerStarter(config); |
|
|
| agentServerReady = await waitForService( |
| "agent-server", |
| `${getAgentServerBaseUrl(config)}/server_info`, |
| agentServerReadyTimeoutMs, |
| ); |
| } |
|
|
| |
| |
| |
| if (config.launchAutomation && agentServerReady) { |
| await seedAutomationSecret(config); |
| } else if (config.launchAutomation) { |
| logService( |
| "secrets", |
| "Skipping secret seeding - agent-server not ready", |
| c.yellow, |
| ); |
| } |
|
|
| |
| if (config.launchAutomation) { |
| startAutomationBackend(config); |
| } |
|
|
| |
| if (config.launchFrontend) { |
| if (useStaticMode) { |
| startStaticFrontend(config, staticDir); |
| } else { |
| startVite(config); |
| } |
| } |
|
|
| |
| await delay(2000); |
|
|
| |
| startIngress(config); |
|
|
| |
| await delay(1000); |
|
|
| printBanner(config); |
|
|
| |
| |
| |
| return { config, agentServerReady }; |
| } |
|
|
| function startStaticFrontend(config, staticDir) { |
| logService("static", `Starting on port ${config.vitePort}...`, c.magenta); |
| logService("static", `Serving from: ${staticDir}`, c.dim); |
|
|
| |
| |
| |
| 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] |
| : []), |
| |
| |
| |
| ...(config.launchAgentServer && !config.isPublic && config.sessionApiKey |
| ? ["--session-api-key", config.sessionApiKey] |
| : []), |
| ...(config.launchAgentServer && config.isPublic |
| ? ["--auth-required"] |
| : []), |
| |
| ...(runtimeServicesInfo |
| ? ["--runtime-services-info", runtimeServicesInfo] |
| : []), |
| |
| ...buildRouteArgs(getLocalServiceRoutes(config)), |
| |
| |
| |
| ...getVSCodeAdvertiseArgs(config), |
| ...getNoReferrerPrefixArgs(config), |
| |
| |
| ...buildRejectPrefixArgs(getRejectPrefixes(config)), |
| ], |
| { |
| cwd: config.canvasPath, |
| color: c.magenta, |
| }, |
| ); |
| } |
|
|
| |
| |
| |
|
|
| 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, |
| }; |
|
|
| |
| |
| |
|
|
| |
| 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); |
| }); |
| } |
|
|