Files
OpenHands/scripts/dev-safe.mjs
T
Rohit Malhotraandopenhands 979e64fe19 feat: add Docker CI to build all-in-one image with agent-server + automation + frontend (#634)
* feat: add Docker CI to build all-in-one image with agent-server + automation + frontend

Adds a GitHub Actions workflow (.github/workflows/docker.yml) that builds and
publishes ghcr.io/openhands/agent-canvas — a single Docker image combining:

  1. Agent Server (ghcr.io/openhands/agent-server base image from SDK repo)
  2. Automation server (pip-installed from openhands-automation)
  3. agent-canvas frontend (static build from this repo)

The automation server is pip-installed rather than copied from its Docker image
because both services share openhands-sdk, fastapi, uvicorn, pydantic, httpx
etc. — installing into the agent-server's Python 3.13 deduplicates all shared
packages. Only automation-specific deps (asyncpg, sqlalchemy, boto3, …) are
added on top.

An entrypoint script starts all three services and a static-server proxy that
unifies them behind a single port (default 8000):
  /api/automation/* → automation backend (:18001)
  /api/*            → agent-server (:18000)
  /*                → static frontend + SPA fallback

Workflow triggers:
  - Push to main: builds and pushes with branch + SHA tags
  - v* tags (releases): also pushes semver tags (1.2.3, 1.2, 1, latest)
  - PRs: builds, pushes SHA-tagged image, updates PR description with
    pull/run instructions (same pattern as the SDK repo)
  - workflow_dispatch: supports overriding base image and automation version

Files added:
  - docker/Dockerfile (multi-stage: frontend build + agent-server base)
  - docker/entrypoint.sh (process manager for all three services)
  - .dockerignore
  - .github/workflows/docker.yml

Co-authored-by: openhands <openhands@all-hands.dev>

* fix: build multi-arch Docker images (amd64 + arm64)

Adds QEMU setup for cross-compilation and defaults the platform matrix
to linux/amd64,linux/arm64 so the image works on both Intel and Apple
Silicon machines.

Co-authored-by: openhands <openhands@all-hands.dev>

* refactor: rewrite Docker workflow to match SDK repo structure

Replace the single-job QEMU approach with the same architecture-matrix
pattern used by the SDK repo's server.yml:

  1. build-and-push-image — matrix over {amd64, arm64} with native runners
     (ubuntu-24.04 for amd64, ubuntu-24.04-arm for arm64). Each job pushes
     arch-suffixed tags (e.g. sha-abc1234-amd64) and uploads build-info
     artifacts.

  2. merge-manifests — downloads both arch build-infos, strips the -amd64
     suffix from amd64 tags to derive manifest tags, and creates multi-arch
     manifests via `docker buildx imagetools create`.

  3. consolidate-build-info — aggregates all build-info and manifest-info
     artifacts into a single JSON summary (PR-only).

  4. update-pr-description — renders the summary into the PR body between
     AGENT_CANVAS_DOCKER_START/END markers.

Native runners avoid the 3-5× slowdown of QEMU emulation for arm64
builds.

Co-authored-by: openhands <openhands@all-hands.dev>

* fix: sanitize branch names in Docker tags (/ is not allowed)

Branch names like 'feat/docker-ci' produce invalid Docker tags because
'/' is forbidden in tag names. Replace '/' with '-' so the tag becomes
'feat-docker-ci-amd64'.

Co-authored-by: openhands <openhands@all-hands.dev>

* fix: default automation to SQLite and fix wait blocking proxy startup

Two bugs:

1. The automation server defaults to PostgreSQL on localhost, which
   doesn't exist in the all-in-one container. Default AUTOMATION_DB_URL
   to sqlite+aiosqlite:// so it works out of the box. Users can override
   with a real Postgres URL for production.

2. The bare 'wait' command waited for ALL background children — including
   the long-running agent-server and automation processes — so the
   static-server/proxy on port 8000 never started. Fix by waiting only
   for the wait_for_port subshell PIDs.

Verified locally: all three services start, endpoints respond correctly,
no more scheduler ConnectionRefusedError.

Co-authored-by: openhands <openhands@all-hands.dev>

* feat: add VOLUME directives for persistence and project mounts

Declare /home/openhands/.openhands (settings, secrets, conversations,
automation SQLite DB) and /projects (user code) as Docker volumes so
data survives container restarts by default. Users should bind-mount
these for durable persistence:

  docker run -v ~/.openhands:/home/openhands/.openhands \
             -v ~/projects:/projects \
             -p 8000:8000 ghcr.io/openhands/agent-canvas

Co-authored-by: openhands <openhands@all-hands.dev>

* fix: set OH_SECRET_KEY default and pre-create persistence dirs

Three issues fixed:

1. OH_SECRET_KEY was not set → agent-server refused to return encrypted
   secrets → conversation creation failed with 503. Set the same static
   default used by dev-safe.mjs / dev-docker.mjs.

2. Persistence dirs (conversations, bash_events, automation DB) were not
   pre-created → the openhands user got PermissionError when the VOLUME
   directive created them as root. Pre-create with correct ownership
   before the USER switch in the Dockerfile.

3. Set OH_PERSISTENCE_DIR, OH_CONVERSATIONS_PATH, OH_BASH_EVENTS_DIR
   defaults in the entrypoint (matching dev-docker.mjs) so data lands
   under the well-known ~/.openhands tree.

Verified locally: all three services start clean, no warnings about
OH_SECRET_KEY, SQLite migrations apply successfully.

Co-authored-by: openhands <openhands@all-hands.dev>

* chore: merge main and remove stale dev-docker.mjs references

Main removed scripts/dev-docker.mjs (Docker is no longer a dependency of
the npm package flow). Update comments in docker.yml, entrypoint.sh, and
AGENTS.md that referenced the deleted file.

Co-authored-by: openhands <openhands@all-hands.dev>

* feat: centralize config into config/defaults.json (single source of truth)

All version pins, port defaults, persistence paths, package names, and
the dev secret key now live in config/defaults.json. Consumers read from
it instead of hardcoding values:

- scripts/dev-safe.mjs: reads via JSON.parse(readFileSync(...))
- scripts/dev-with-automation.mjs: same
- scripts/check-sdk-version-sync.mjs: same (no longer regex-parses JS)
- docker/Dockerfile: config-gen build stage converts JSON to
  /opt/agent-canvas/defaults.env (shell-sourceable)
- docker/entrypoint.sh: sources defaults.env at startup; also adds
  session API key auto-generation so the image doesn't run wide-open
- .github/workflows/docker.yml: reads versions from JSON in a setup
  step (no more hardcoded env vars)

To bump a version, edit config/defaults.json only.

Co-authored-by: openhands <openhands@all-hands.dev>

* fix: address PR review feedback (#634)

- Fix PID tracking bug: move PIDS+=($!) inside if/elif branches so the
  else (automation-not-found) path doesn't add a stale PID
- chmod 600 session API key file to prevent credential leak
- Warn when using insecure default OH_SECRET_KEY in Docker entrypoint
- Add try/catch + field validation for config/defaults.json loading in
  check-sdk-version-sync.mjs
- Fix semver tag parsing: strip pre-release/build metadata, only create
  abbreviated tags (major.minor, major, latest) for stable releases
- Sanitize branch names for Docker tags (tr invalid chars, strip leading
  dot/dash) to handle branches with #, @, spaces, etc.
- Add arch validation before manifest merge (assert both amd64.json and
  arm64.json exist)
- Remove $schema reference to non-existent defaults.schema.json

Co-authored-by: openhands <openhands@all-hands.dev>

* fix: remove hardcoded version defaults from Dockerfile

Replace hardcoded ARG defaults (AGENT_SERVER_IMAGE, AUTOMATION_VERSION)
with empty ARGs. Values are always derived from config/defaults.json:
- CI: reads JSON in the workflow config step, passes --build-arg
- Local: new scripts/docker-build.mjs helper reads JSON and invokes
  docker build with the correct --build-arg values

Added npm run build:docker convenience script.

Co-authored-by: openhands <openhands@all-hands.dev>

* fix: stabilize snapshot tests and auto-generate Docker secret key

Two fixes:

1. **Flaky snapshot tests**: The 'Local pagination fixture' mock conversation
   used a fixed absolute timestamp (PAGINATION_BASE_TIME = May 13, 2026) for
   its created_at/updated_at, while 'Errored Project' used a relative
   timestamp (now - 7d). As real time progressed past the crossover point,
   their sort order in the sidebar flipped, causing 30/73 snapshot diffs on
   every PR. Fix: use relative timestamps (now - 6d) for the pagination
   fixture's conversation listing fields. The internal event timestamps
   (used by pagination tests) still use PAGINATION_BASE_TIME — only the
   sidebar ordering is affected.

2. **Docker OH_SECRET_KEY**: The entrypoint used a static insecure default
   for OH_SECRET_KEY and warned about it. Now mirrors the session API key
   pattern: auto-generate a cryptographic random key on first run, persist
   it to ~/.openhands/agent-canvas/secret-key.txt, and reuse on restart.
   Users can still override via the OH_SECRET_KEY env var. Removed the
   now-unused CONFIG_SECRET_KEY from the Docker defaults.env generation.
   Also deduped STATE_DIR computation (was repeated for session key path).

Co-authored-by: openhands <openhands@all-hands.dev>

* docs: update AGENTS.md with mock timestamp and Docker secret key notes

Co-authored-by: openhands <openhands@all-hands.dev>

* fix: include canvas_ui tool in Docker image

The Docker image was missing the tools/ directory and OH_EXTRA_PYTHON_PATH,
so the agent-server couldn't import canvas_ui_tool.py when the frontend
sent canvas_ui in the conversation tools list. This caused:

  HTTP 500: ToolDefinition 'canvas_ui' is not registered

Fix: COPY tools/ into the image and set OH_EXTRA_PYTHON_PATH in the
entrypoint, matching what scripts/dev-safe.mjs already does for local dev.

Co-authored-by: openhands <openhands@all-hands.dev>

---------

Co-authored-by: openhands <openhands@all-hands.dev>
2026-05-20 04:24:45 +00:00

1069 lines
35 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { spawn } from "node:child_process";
import { randomBytes } from "node:crypto";
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
statSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import net from "node:net";
import { homedir, tmpdir } from "node:os";
import path from "node:path";
import process from "node:process";
import { setTimeout as delay } from "node:timers/promises";
import { fileURLToPath, pathToFileURL } from "node:url";
import {
getProcessTreeSpawnOptions,
isProcessRunning,
signalProcessTree,
} from "./dev-process-utils.mjs";
// ── Centralized config (single source of truth for versions, ports, etc.) ───
const __dev_safe_dirname = path.dirname(fileURLToPath(import.meta.url));
const SHARED_DEFAULTS = JSON.parse(
readFileSync(path.join(__dev_safe_dirname, "..", "config", "defaults.json"), "utf-8"),
);
const DEFAULT_BACKEND_PORT = SHARED_DEFAULTS.ports.agentServer;
const DEFAULT_VITE_PORT = 3001;
const DEFAULT_WAIT_TIMEOUT_MS = 30_000;
const DEFAULT_AGENT_SERVER_PACKAGE = SHARED_DEFAULTS.packages.agentServer;
const AGENT_SERVER_GIT_REPO = "https://github.com/OpenHands/software-agent-sdk";
const LOCAL_AGENT_SERVER_SUBDIRS = [
"openhands-agent-server",
"openhands-sdk",
"openhands-tools",
"openhands-workspace",
];
const DEFAULT_SECRET_KEY = SHARED_DEFAULTS.defaults.secretKey;
const DEFAULT_AGENT_SERVER_VERSION = SHARED_DEFAULTS.versions.agentServer;
const FRONTEND_REQUIRED_BINS = ["cross-env", "react-router"];
/**
* Generate a cryptographically secure random API key.
* Returns a 64-character hex string (256-bit).
*/
export function generateRandomApiKey() {
return randomBytes(32).toString("hex");
}
// Where the auto-generated default session API key is persisted so it stays
// stable across `npm run dev` restarts. Keeping the key stable means the value
// baked into the frontend (VITE_SESSION_API_KEY) and the persisted
// backend-registry entry (`openhands-backends` localStorage) stay in sync
// without users needing to set anything in `.env`.
//
// To rotate the key, delete this file. To pin a key explicitly, export
// SESSION_API_KEY (or OH_SESSION_API_KEYS_0 / VITE_SESSION_API_KEY) -- those
// take precedence over the persisted file.
export const DEFAULT_SESSION_API_KEY_PATH = path.join(
homedir(),
".openhands",
"agent-canvas",
"session-api-key.txt",
);
// Cache so repeated lookups within a single process return the same key,
// keyed by file path so tests can use temp paths in isolation.
const persistedApiKeyCache = new Map();
/**
* Load the persisted default session API key, generating + persisting one if
* the file doesn't exist yet.
*
* Best-effort: if the file can't be written (e.g. read-only home dir), we
* fall back to an in-memory key for this process so dev still works -- the
* key just won't survive a restart.
*
* @param {string} filePath - Where to read/write the key.
* @returns {string} The (hex) session API key.
*/
export function getOrCreatePersistedSessionApiKey(
filePath = DEFAULT_SESSION_API_KEY_PATH,
) {
return getOrCreatePersistedApiKey(filePath, "session");
}
/**
* Load a persisted default API key, generating + persisting one if the file
* doesn't exist yet.
*
* Best-effort: if the file can't be written (e.g. read-only home dir), we
* fall back to an in-memory key for this process so dev still works -- the
* key just won't survive a restart.
*
* @param {string} filePath - Where to read/write the key.
* @param {string} label - Human-readable key label for warning messages.
* @returns {string} The (hex) API key.
*/
export function getOrCreatePersistedApiKey(filePath, label = "API") {
const cached = persistedApiKeyCache.get(filePath);
if (cached) return cached;
// Try to read an existing key.
try {
const existing = readFileSync(filePath, "utf8").trim();
if (existing) {
persistedApiKeyCache.set(filePath, existing);
return existing;
}
// File exists but is empty -- treat as if missing and regenerate.
} catch (error) {
if (!isEnoentError(error)) {
console.warn(
`Could not read persisted ${label} API key from ${filePath}: ${error.message}. Regenerating.`,
);
}
}
// Generate and persist a new key.
const newKey = generateRandomApiKey();
try {
mkdirSync(path.dirname(filePath), { recursive: true });
writeFileSync(filePath, `${newKey}\n`, { mode: 0o600 });
} catch (error) {
console.warn(
`Could not persist ${label} API key to ${filePath}: ${error.message}. Falling back to in-memory key (will not survive restarts).`,
);
}
persistedApiKeyCache.set(filePath, newKey);
return newKey;
}
/**
* Clear the in-memory cache used by {@link getOrCreatePersistedSessionApiKey}.
* Intended for tests that swap the persisted file path between cases.
*/
export function resetPersistedSessionApiKeyCache() {
persistedApiKeyCache.clear();
}
function isEnoentError(error) {
return Boolean(
(error &&
typeof error === "object" &&
"code" in error &&
error.code === "ENOENT") ||
/ENOENT/.test(String(error)),
);
}
/**
* Find a free port, preferring the specified port if available.
*
* Tries the preferred port first; if it's busy, falls back to letting
* the OS assign any available port. This preserves predictable defaults
* while gracefully handling port conflicts.
*
* **Note on race conditions:** There is a small window between when this
* function checks port availability and when the calling service actually
* binds to the port. During this window, another process could theoretically
* grab the port. This is an accepted limitation of the "check-then-use"
* approach. Callers (like agent-server) should handle EADDRINUSE gracefully.
* For Vite, `strictPort: true` ensures a fast failure if this occurs.
*
* @param {number} preferredPort - The port to try first
* @param {string} host - The host to bind to (default: "127.0.0.1")
* @returns {Promise<number>} The actual port that was acquired
*/
export async function findFreePort(preferredPort, host = "127.0.0.1") {
// If preferredPort is 0, skip the check and go straight to OS assignment
if (preferredPort > 0) {
const preferredAvailable = await tryPort(preferredPort, host);
if (preferredAvailable) {
return preferredPort;
}
}
// Fall back to OS-assigned port
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once("error", reject);
server.listen(0, host, () => {
const { port } = server.address();
server.close(() => resolve(port));
});
});
}
/**
* Check if a port is available by attempting to bind to it.
*
* @param {number} port - The port to check
* @param {string} host - The host to bind to
* @returns {Promise<boolean>} True if the port is available
*/
function tryPort(port, host = "127.0.0.1") {
return new Promise((resolve) => {
const server = net.createServer();
server.once("error", () => resolve(false));
server.listen(port, host, () => {
server.close(() => resolve(true));
});
});
}
/**
* Find multiple free ports at once, each preferring its specified default.
*
* Allocates ports sequentially to avoid race conditions between checks.
*
* @param {Array<{name: string, preferred: number}>} portConfigs - Port configurations
* @param {string} host - The host to bind to (default: "127.0.0.1")
* @returns {Promise<Record<string, number>>} Map of name to actual port
*/
export async function findFreePorts(portConfigs, host = "127.0.0.1") {
const result = {};
const usedPorts = new Set();
for (const { name, preferred } of portConfigs) {
// Try preferred if not already taken by a previous allocation
// Skip if preferred is 0 (means "any port") or already used
if (preferred > 0 && !usedPorts.has(preferred)) {
const available = await tryPort(preferred, host);
if (available) {
result[name] = preferred;
usedPorts.add(preferred);
continue;
}
}
// Fall back to OS-assigned port, retrying if we get a collision
let port;
let attempts = 0;
const maxAttempts = 100;
do {
port = await findFreePort(0, host);
if (++attempts > maxAttempts) {
throw new Error(
`Could not allocate unique port for "${name}" after ${maxAttempts} attempts`,
);
}
} while (usedPorts.has(port));
result[name] = port;
usedPorts.add(port);
}
return result;
}
export function formatMissingUvxGuidance(cwd = process.cwd()) {
const readmePath = path.join(cwd, "README.md");
return [
"Failed to start uvx. Make sure uv is installed and on your PATH.",
"",
"To fix this:",
"1. Install uv:",
" curl -LsSf https://astral.sh/uv/install.sh | sh",
"2. Make sure the uv bin dir is on your PATH:",
' export PATH="$HOME/.local/bin:$PATH"',
" command -v uvx",
"",
"Need Windows or another install method? https://docs.astral.sh/uv/getting-started/installation/",
`See the local Quickstart for details: ${readmePath}`,
"",
"Other options:",
"- npm run dev:frontend # use an already running backend",
"- npm run dev:mock # run the frontend with mock APIs",
].join("\n");
}
function npmBinCandidates(binName, platform = process.platform) {
const candidates = [binName];
if (platform === "win32") {
candidates.push(`${binName}.cmd`, `${binName}.ps1`);
}
return candidates;
}
export function getMissingFrontendDependencyBins(
cwd = process.cwd(),
platform = process.platform,
) {
const binDir = path.join(cwd, "node_modules", ".bin");
return FRONTEND_REQUIRED_BINS.filter(
(binName) =>
!npmBinCandidates(binName, platform).some((candidate) =>
existsSync(path.join(binDir, candidate)),
),
);
}
export function formatMissingFrontendDependenciesGuidance(
missingBins,
cwd = process.cwd(),
) {
const missingList = missingBins.join(", ");
return [
"Frontend dependencies are not installed or are incomplete.",
"",
`Missing npm binaries: ${missingList}`,
"",
"Run this from the repository root:",
" npm ci",
"",
`Repository root: ${cwd}`,
].join("\n");
}
export function validateFrontendDependencies(
cwd = process.cwd(),
platform = process.platform,
) {
const missingBins = getMissingFrontendDependencyBins(cwd, platform);
if (missingBins.length > 0) {
throw new Error(
formatMissingFrontendDependenciesGuidance(missingBins, cwd),
);
}
}
/**
* Build the uvx command and arguments for running agent-server.
*
* Environment variables (highest precedence first):
* - OH_AGENT_SERVER_LOCAL_PATH: Absolute path to a software-agent-sdk checkout.
* Runs the local checkout via uvx with editable installs of the workspace
* packages (openhands-sdk, openhands-tools, openhands-workspace) so source
* edits are picked up without a manual reinstall. The agent-server itself
* is rebuilt from local source on each invocation (--reinstall).
* - OH_AGENT_SERVER_GIT_REF: Git commit SHA or branch name
* - OH_AGENT_SERVER_VERSION: Specific PyPI version (e.g., "1.22.1")
*
* If none are set, defaults to the released version specified by
* DEFAULT_AGENT_SERVER_VERSION. Set OH_AGENT_SERVER_GIT_REF to use a
* git branch or commit instead.
*
* @param {Record<string, string | undefined>} env
* @returns {{ command: string, args: string[], source: string }}
*/
export function buildAgentServerCommand(env = process.env) {
const localPath = env.OH_AGENT_SERVER_LOCAL_PATH;
const gitRef = env.OH_AGENT_SERVER_GIT_REF;
const version = env.OH_AGENT_SERVER_VERSION;
const uvxArgs = [];
let source = "";
if (localPath) {
if (!path.isAbsolute(localPath)) {
throw new Error(
`OH_AGENT_SERVER_LOCAL_PATH must be an absolute path, got: ${localPath}`,
);
}
uvxArgs.push(
"--reinstall",
"--from",
path.join(localPath, "openhands-agent-server"),
"--with-editable",
path.join(localPath, "openhands-sdk"),
"--with-editable",
path.join(localPath, "openhands-tools"),
"--with-editable",
path.join(localPath, "openhands-workspace"),
"agent-server",
);
source = `local (${localPath})`;
} else if (gitRef) {
// Use git ref with subdirectory syntax for uv workspace monorepo
// The software-agent-sdk repo has packages in subdirectories:
// openhands-agent-server/, openhands-tools/, openhands-workspace/
const baseGitUrl = `git+${AGENT_SERVER_GIT_REPO}@${gitRef}`;
uvxArgs.push(
"--from",
`${baseGitUrl}#subdirectory=openhands-agent-server`,
"--with",
`${baseGitUrl}#subdirectory=openhands-tools`,
"--with",
`${baseGitUrl}#subdirectory=openhands-workspace`,
"agent-server",
);
source = `git (${gitRef})`;
} else if (version) {
// Use specific PyPI version: uvx --from openhands-agent-server==version agent-server
// The package name differs from the executable name, so we need --from syntax
// Pin all SDK packages to the same version for consistency
uvxArgs.push(
"--from",
`${DEFAULT_AGENT_SERVER_PACKAGE}==${version}`,
"--with",
`openhands-tools==${version}`,
"--with",
`openhands-workspace==${version}`,
"agent-server",
);
source = `PyPI (${version})`;
} else {
// Default to released PyPI version
// Pin all SDK packages to the same version for consistency
uvxArgs.push(
"--from",
`${DEFAULT_AGENT_SERVER_PACKAGE}==${DEFAULT_AGENT_SERVER_VERSION}`,
"--with",
`openhands-tools==${DEFAULT_AGENT_SERVER_VERSION}`,
"--with",
`openhands-workspace==${DEFAULT_AGENT_SERVER_VERSION}`,
"agent-server",
);
source = `PyPI (${DEFAULT_AGENT_SERVER_VERSION}, default)`;
}
return {
command: "uvx",
args: uvxArgs,
source,
};
}
function parsePort(value, fallback) {
if (value == null || value === "") {
return fallback;
}
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new Error(`Invalid port: ${value}`);
}
return parsed;
}
/**
* Build safe dev configuration (synchronous version).
*
* Uses the port values from environment variables or defaults WITHOUT checking
* port availability. Use this when:
* - You need synchronous config (e.g., for test setup, config inspection)
* - Ports are already known to be available (e.g., specified via env vars)
* - You're building config objects for downstream use, not starting services
*
* For scripts that actually start services (dev-safe.mjs main, dev-with-automation.mjs),
* use {@link buildSafeDevConfigAsync} instead to handle port conflicts gracefully.
*
* @param {string} cwd - Current working directory
* @param {Record<string, string | undefined>} env - Environment variables
* @returns {SafeDevConfig} Configuration object
*/
export function buildSafeDevConfig(cwd = process.cwd(), env = process.env) {
const backendPort = parsePort(
env.OH_CANVAS_SAFE_BACKEND_PORT,
DEFAULT_BACKEND_PORT,
);
const vscodePort = parsePort(env.OH_CANVAS_SAFE_VSCODE_PORT, backendPort + 1);
return buildConfigFromPorts({ backendPort, vscodePort }, cwd, env);
}
/**
* Build safe dev configuration with dynamic port allocation.
*
* Tries preferred ports first; if busy, finds available alternatives.
* This is the recommended entry point for scripts that start services.
*
* @param {string} cwd - Current working directory
* @param {Record<string, string | undefined>} env - Environment variables
* @returns {Promise<SafeDevConfig>} Configuration object with allocated ports
*/
export async function buildSafeDevConfigAsync(
cwd = process.cwd(),
env = process.env,
) {
// Get preferred ports from env or defaults
const preferredBackendPort = parsePort(
env.OH_CANVAS_SAFE_BACKEND_PORT,
DEFAULT_BACKEND_PORT,
);
const preferredVscodePort = parsePort(
env.OH_CANVAS_SAFE_VSCODE_PORT,
preferredBackendPort + 1,
);
// Find available ports, preferring the defaults
const ports = await findFreePorts([
{ name: "backend", preferred: preferredBackendPort },
{ name: "vscode", preferred: preferredVscodePort },
]);
// Log if we're using non-default ports
if (ports.backend !== preferredBackendPort) {
console.log(
` ℹ Port ${preferredBackendPort} busy, using ${ports.backend} for agent-server`,
);
}
if (ports.vscode !== preferredVscodePort) {
console.log(
` ℹ Port ${preferredVscodePort} busy, using ${ports.vscode} for vscode`,
);
}
return buildConfigFromPorts(
{ backendPort: ports.backend, vscodePort: ports.vscode },
cwd,
env,
);
}
/**
* @typedef {object} SafeDevConfig
* @property {string} cwd
* @property {number} backendPort
* @property {number} vscodePort
* @property {string} stateDir
* @property {string} tmuxTmpDir
* @property {string} conversationsPath
* @property {string} workspacesPath
* @property {string} bashEventsDir
* @property {string} backendBaseUrl
* @property {string} backendHost
* @property {string} workingDir
* @property {string} secretKey
* @property {string} sessionApiKey
* @property {string} canvasToolsDir
*/
/**
* Internal helper to build config from already-resolved ports.
* @param {{backendPort: number, vscodePort: number}} ports
* @param {string} cwd
* @param {Record<string, string | undefined>} env
* @returns {SafeDevConfig}
*/
function buildConfigFromPorts(ports, cwd, env) {
const { backendPort, vscodePort } = ports;
const stateDir = path.resolve(
cwd,
env.OH_CANVAS_SAFE_STATE_DIR ||
path.join(homedir(), ".openhands", "agent-canvas"),
);
const conversationsPath = path.join(stateDir, "conversations");
const workspacesPath = path.join(stateDir, "workspaces");
// Use provided secret key or default for local development
const secretKey = env.OH_SECRET_KEY || DEFAULT_SECRET_KEY;
// Use provided session API key or fall back to a key persisted to
// ~/.openhands/agent-canvas/session-api-key.txt. Persisting on disk keeps
// the agent-server, the Vite-baked VITE_SESSION_API_KEY, and any
// `openhands-backends` localStorage entries the frontend has cached all
// pointing at the same value across dev restarts.
//
// Check multiple env vars that may be used:
// - SESSION_API_KEY: Common name
// - OH_SESSION_API_KEYS_0: Used by agent-server V1 config
// - VITE_SESSION_API_KEY: Used by frontend config
// OH_SESSION_API_KEY_PATH overrides the persisted file path (used by tests).
const persistedKeyPath =
env.OH_SESSION_API_KEY_PATH || DEFAULT_SESSION_API_KEY_PATH;
const sessionApiKey =
env.SESSION_API_KEY ||
env.OH_SESSION_API_KEYS_0 ||
env.VITE_SESSION_API_KEY ||
getOrCreatePersistedSessionApiKey(persistedKeyPath);
// Host directory containing Agent-Canvas-specific Python tools (e.g. the
// canvas_ui tool). Added to OH_EXTRA_PYTHON_PATH below so the agent-server
// can import the modules listed in `tool_module_qualnames`. Lives at
// <repo-root>/tools relative to this script.
const canvasToolsDir = fileURLToPath(new URL("../tools", import.meta.url));
return {
cwd,
backendPort,
vscodePort,
stateDir,
tmuxTmpDir: path.join(tmpdir(), "openhands-agent-canvas-tmux"),
conversationsPath,
workspacesPath,
bashEventsDir: path.join(stateDir, "bash_events"),
backendBaseUrl: `http://127.0.0.1:${backendPort}`,
backendHost: `127.0.0.1:${backendPort}`,
workingDir: env.VITE_WORKING_DIR || workspacesPath,
secretKey,
sessionApiKey,
canvasToolsDir,
};
}
/**
* Build the environment variables object for spawning the agent-server process.
*
* This is exported so downstream consumers (e.g., automation service) can use
* the same env vars without duplicating the mapping logic.
*
* @param {ReturnType<typeof buildSafeDevConfig>} config - Config from buildSafeDevConfig
* @returns {Record<string, string>} Environment variables for agent-server
*/
export function buildAgentServerEnv(config) {
return {
TMUX_TMPDIR: config.tmuxTmpDir,
OH_CONVERSATIONS_PATH: config.conversationsPath,
OH_BASH_EVENTS_DIR: config.bashEventsDir,
OH_VSCODE_PORT: String(config.vscodePort),
OH_SECRET_KEY: config.secretKey,
// Use OH_SESSION_API_KEYS_0 for agent-server V1 config format
OH_SESSION_API_KEYS_0: config.sessionApiKey,
// Alias for the agent-server's own URL. The agent-server itself sets
// OH_INTERNAL_SERVER_URL at startup, but downstream consumers (the
// OpenHands SDK boilerplate emitted by automation prompt/plugin
// presets) read AGENT_SERVER_URL — the canonical SDK name. Mirror it
// here so automation runs work without each tarball having to know
// about the OH_-prefixed variant.
//
// We deliberately do NOT set a SESSION_API_KEY alias: the SDK's
// sanitized_env() would strip it from bash subprocesses anyway, and
// a follow-up change to the automation preset reads
// OH_SESSION_API_KEYS_0 directly (which is already in env).
AGENT_SERVER_URL: config.backendBaseUrl,
// Make the host tools/ directory importable so the agent-server can
// resolve modules listed in tool_module_qualnames (e.g. canvas_ui_tool).
OH_EXTRA_PYTHON_PATH: config.canvasToolsDir,
};
}
/**
* Build a structured description of the dev-stack services that are
* reachable from inside the agent's sandbox. The frontend forwards this
* (verbatim, as a JSON string in `VITE_RUNTIME_SERVICES_INFO`) and renders
* it into the system prompt via `AgentContext.system_message_suffix`, so
* the agent sees a `<RUNTIME_SERVICES>` block listing what's available
* without having to probe.
*
* URLs are written from the *agent's* point of view. The agent-server
* runs on the host, so the host alias is "localhost".
*
* @param {object} options
* @param {string} [options.mode] - Human-readable dev mode label (e.g. "dev:safe").
* @param {string} [options.agentHostAlias="localhost"] - Hostname the agent
* uses to reach services running on the host machine.
* @param {number} [options.agentServerPort] - Port the agent-server listens on.
* Required at runtime; the function throws if missing because the resulting
* URL would otherwise bake `undefined` into the agent's system prompt.
* Typed as optional only so TypeScript callers can negative-test the guard.
* @param {number} [options.ingressPort] - Ingress port (omit if no ingress).
* @param {number} [options.frontendPort] - Frontend port (Vite dev server
* or static-file server). Omit if no frontend is exposed.
* @param {number} [options.vitePort] - Deprecated alias for `frontendPort`,
* accepted for backward compat with older launchers. Remove after one release.
* @param {"vite"|"static"} [options.frontendKind="vite"] - Whether the
* frontend port hosts Vite or a static build. Only affects the
* description shown to the agent.
* @param {object} [options.automation] - Automation backend info. Skipped
* entirely if `.port` is missing, so passing `{}` is safe.
* @param {number} [options.automation.port] - Automation backend port.
* @param {string} [options.automation.apiPrefix="/api/automation"] - Path
* prefix all automation routes are mounted under.
* @param {string} [options.automation.authEnvVar="OPENHANDS_AUTOMATION_API_KEY"]
* - Env var holding the API key.
* @returns {object} A JSON-serializable runtime services info object.
*/
export function buildRuntimeServicesInfo(options) {
const {
mode,
agentHostAlias = "localhost",
agentServerPort,
ingressPort,
// Accept legacy `vitePort` for one release so external callers keep working.
vitePort,
frontendPort = vitePort,
frontendKind = "vite",
automation,
} = options;
if (agentServerPort === undefined || agentServerPort === null) {
// Without this the URL becomes `http://localhost:undefined` and ends up
// verbatim in the agent's system prompt, which is worse than failing fast.
throw new Error(
"buildRuntimeServicesInfo: agentServerPort is required " +
"(otherwise the agent_server URL would be `http://localhost:undefined`).",
);
}
const services = {
agent_server: {
description:
"The OpenHands Agent Server this agent is running inside. " +
"Tool calls (terminal, file_editor, browser, etc.) execute here.",
// From the agent's POV, the agent-server it's *inside* is on
// localhost, regardless of where the host is.
url_from_agent: `http://localhost:${agentServerPort}`,
},
};
if (ingressPort !== undefined) {
services.ingress = {
description:
"Unified entry point. Routes /api/automation/* to the automation " +
"backend, /api/* and /sockets to the agent-server, and /* to the " +
"frontend.",
url_from_agent: `http://${agentHostAlias}:${ingressPort}`,
};
}
if (frontendPort !== undefined) {
services.frontend = {
kind: frontendKind,
description:
frontendKind === "static"
? "Static-file server hosting the agent-canvas production build."
: "Vite dev server hosting the agent-canvas frontend.",
url_from_agent: `http://${agentHostAlias}:${frontendPort}`,
};
}
// Require an explicit port so we don't bake `:undefined` into the
// automation URL when the caller passes `automation: {}`.
if (automation?.port !== undefined && automation.port !== null) {
const apiPrefix = automation.apiPrefix ?? "/api/automation";
const authEnvVar = automation.authEnvVar ?? "OPENHANDS_AUTOMATION_API_KEY";
const baseUrl = `http://${agentHostAlias}:${automation.port}`;
services.automation = {
description:
"OpenHands Automations service. All routes are mounted under " +
`'${apiPrefix}'. Authenticate with header ` +
`'X-API-Key: $${authEnvVar}'.`,
url_from_agent: baseUrl,
api_prefix: apiPrefix,
docs_url: `${baseUrl}${apiPrefix}/docs`,
openapi_url: `${baseUrl}${apiPrefix}/openapi.json`,
auth_env_var: authEnvVar,
};
}
return {
mode,
agent_host_alias: agentHostAlias,
services,
};
}
export function buildNpmScriptCommand(
scriptName,
platform = process.platform,
env = process.env,
nodeExecPath = process.execPath,
) {
if (env.npm_execpath) {
return {
command: env.npm_node_execpath || nodeExecPath,
args: [env.npm_execpath, "run", scriptName],
};
}
if (platform === "win32") {
return {
command: env.ComSpec || "cmd.exe",
args: ["/d", "/s", "/c", "npm", "run", scriptName],
};
}
return {
command: "npm",
args: ["run", scriptName],
};
}
export function validateLocalAgentServerPath(localPath) {
if (!path.isAbsolute(localPath)) {
throw new Error(
`OH_AGENT_SERVER_LOCAL_PATH must be an absolute path, got: ${localPath}`,
);
}
if (!existsSync(localPath)) {
throw new Error(`OH_AGENT_SERVER_LOCAL_PATH does not exist: ${localPath}`);
}
for (const subdir of LOCAL_AGENT_SERVER_SUBDIRS) {
const subdirPath = path.join(localPath, subdir);
if (!existsSync(subdirPath)) {
throw new Error(
`OH_AGENT_SERVER_LOCAL_PATH is missing expected workspace package '${subdir}': ${subdirPath}`,
);
}
}
}
async function waitForServer(url, timeoutMs = DEFAULT_WAIT_TIMEOUT_MS) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
try {
const response = await fetch(url);
if (response.ok) {
return;
}
} catch {
// Keep polling until timeout.
}
await delay(500);
}
throw new Error(`Timed out waiting for agent-server at ${url}`);
}
function spawnProcess(command, args, options = {}) {
const child = spawn(
command,
args,
getProcessTreeSpawnOptions({
stdio: "inherit",
...options,
}),
);
child.once("error", (error) => {
if (isEnoentError(error) && command === "uvx") {
console.error(formatMissingUvxGuidance(options?.cwd));
} else if (isEnoentError(error)) {
console.error(
`Failed to start ${command}. Make sure it is installed and on your PATH.`,
);
} else {
console.error(`Failed to start ${command}:`, error);
}
});
return child;
}
async function main() {
console.log("Starting isolated agent-server + frontend dev stack...");
validateFrontendDependencies();
console.log("Frontend dependencies found.");
console.log("Allocating ports...");
// Use async config builder with dynamic port allocation
const config = await buildSafeDevConfigAsync();
if (process.env.OH_AGENT_SERVER_LOCAL_PATH) {
validateLocalAgentServerPath(process.env.OH_AGENT_SERVER_LOCAL_PATH);
}
for (const dir of [
config.stateDir,
config.tmuxTmpDir,
config.conversationsPath,
config.workspacesPath,
config.bashEventsDir,
]) {
mkdirSync(dir, { recursive: true });
}
const agentServerCmd = buildAgentServerCommand();
const secretKeySource = process.env.OH_SECRET_KEY
? "custom (from OH_SECRET_KEY)"
: "default (for local development)";
const sessionKeySource =
process.env.SESSION_API_KEY ||
process.env.OH_SESSION_API_KEYS_0 ||
process.env.VITE_SESSION_API_KEY
? "custom (from env)"
: `persisted (${
process.env.OH_SESSION_API_KEY_PATH || DEFAULT_SESSION_API_KEY_PATH
})`;
console.log(`- agent-server: ${agentServerCmd.source}`);
console.log(`- backend: ${config.backendBaseUrl}`);
console.log(`- vscode port: ${config.vscodePort}`);
console.log(`- working dir: ${config.workingDir}`);
console.log(`- isolated state dir: ${config.stateDir}`);
console.log(`- secret key: ${secretKeySource}`);
console.log(`- session API key: ${sessionKeySource}`);
console.log("");
const backend = spawnProcess(
agentServerCmd.command,
[
...agentServerCmd.args,
"--host",
"127.0.0.1",
"--port",
String(config.backendPort),
],
{
cwd: config.cwd,
env: {
...process.env,
...buildAgentServerEnv(config),
},
},
);
let shuttingDown = false;
let frontend = null;
const shutdown = (signal = "SIGTERM") => {
if (shuttingDown) {
return;
}
shuttingDown = true;
if (frontend) {
signalProcessTree(frontend, signal);
}
signalProcessTree(backend, signal);
setTimeout(() => {
if (frontend && isProcessRunning(frontend)) {
signalProcessTree(frontend, "SIGKILL");
}
if (isProcessRunning(backend)) {
signalProcessTree(backend, "SIGKILL");
}
process.exit(process.exitCode ?? 0);
}, 3000);
};
process.on("SIGINT", () => shutdown("SIGINT"));
process.on("SIGTERM", () => shutdown("SIGTERM"));
const backendErrored = new Promise((_, reject) => {
backend.once("error", (error) => reject(error));
});
const backendExited = new Promise((_, reject) => {
backend.once("exit", (code, signal) => {
if (!shuttingDown) {
reject(
new Error(
`agent-server exited before startup completed (code=${code ?? "null"}, signal=${signal ?? "null"})`,
),
);
}
});
});
try {
await Promise.race([
waitForServer(`${config.backendBaseUrl}/server_info`),
backendErrored,
backendExited,
]);
} catch (error) {
shutdown();
throw error;
}
const frontendCommand = buildNpmScriptCommand("dev:frontend");
const runtimeServicesInfo = buildRuntimeServicesInfo({
mode: "dev:safe",
agentServerPort: config.backendPort,
});
frontend = spawnProcess(frontendCommand.command, frontendCommand.args, {
cwd: config.cwd,
env: {
...process.env,
VITE_BACKEND_HOST: config.backendHost,
VITE_BACKEND_BASE_URL: config.backendBaseUrl,
VITE_WORKING_DIR: config.workingDir,
// Pass session API key so frontend can authenticate with agent-server
VITE_SESSION_API_KEY: config.sessionApiKey,
// Inform the frontend (and downstream, the agent's system prompt) about
// which services are available in this dev stack.
VITE_RUNTIME_SERVICES_INFO: JSON.stringify(runtimeServicesInfo),
},
});
frontend.once("exit", (code) => {
shutdown();
process.exitCode = code ?? 0;
});
backend.once("exit", (code) => {
if (!shuttingDown) {
console.error(`agent-server exited unexpectedly with code ${code ?? 0}`);
shutdown();
process.exitCode = code ?? 1;
}
});
}
// ─────────────────────────────────────────────────────────────────────────────
// Conversation lease cleanup
// ─────────────────────────────────────────────────────────────────────────────
/**
* Returns true if `host:port` accepts a TCP connection within `timeoutMs`.
* Used to detect a live agent-server we shouldn't disturb.
*/
export function isPortBusy(port, host = "127.0.0.1", timeoutMs = 500) {
return new Promise((resolve) => {
const socket = new net.Socket();
let settled = false;
const finish = (busy) => {
if (settled) return;
settled = true;
socket.destroy();
resolve(busy);
};
socket.setTimeout(timeoutMs);
socket.once("connect", () => finish(true));
socket.once("timeout", () => finish(false));
socket.once("error", () => finish(false));
socket.connect(port, host);
});
}
/**
* Remove stale `owner_lease.json` files under `conversationsDir` so a
* freshly spawned agent-server can claim ownership and re-load every
* existing conversation.
*
* Why this is needed: each conversation directory carries an
* `owner_lease.json` that locks it to a single agent-server's
* `owner_instance_id` for a 45 s TTL refreshed by heartbeat. On
* graceful shutdown the agent-server unlinks its leases; on a hard
* kill (or a fast restart, well under 45 s) the leases linger. A new
* agent-server with a fresh `owner_instance_id` will then raise
* `ConversationLeaseHeldError` for each conversation at startup load
* and skip it entirely — `/api/conversations/search` returns `[]`
* even though the meta files are right there on disk.
*
* The caller MUST verify (e.g. with `isPortBusy`) that no agent-server
* is currently bound to the backend port before calling this — there
* is no other reliable way to tell a stale lease from an actively
* renewed one.
*
* Returns the number of lease files unlinked.
*/
export function releaseStaleConversationLeases(conversationsDir) {
if (!existsSync(conversationsDir)) return 0;
let removed = 0;
for (const name of readdirSync(conversationsDir)) {
const convDir = path.join(conversationsDir, name);
let isDir = false;
try {
isDir = statSync(convDir).isDirectory();
} catch {
continue;
}
if (!isDir) continue;
const leasePath = path.join(convDir, "owner_lease.json");
if (!existsSync(leasePath)) continue;
try {
unlinkSync(leasePath);
removed += 1;
} catch {
// Best-effort: the new agent-server will simply skip this
// conversation as before. Don't fail the whole start.
}
}
return removed;
}
if (
process.argv[1] &&
import.meta.url === pathToFileURL(process.argv[1]).href
) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
});
}