Files
OpenHands/scripts/dev-safe.mjs
T
b570b4a2f6 fix: buildNpmScriptCommand always uses cmd.exe on Windows (#734)
* fix: buildNpmScriptCommand always uses cmd.exe on Windows

On Windows, npm sets npm_execpath to a path like
  C:\Program Files\nodejs\node_modules\npm\bin\npm-cli.js
which contains spaces. buildNpmScriptCommand was returning that
path as a spawn argument with the full node.exe path as the command.
spawnService uses shell:true on Windows, so Node.js passes the
unquoted command to cmd.exe:

  cmd.exe /d /s /c C:\Program Files\nodejs\node.exe ...

cmd.exe splits on the space and fails with
  'C:\Program' is not recognized as an internal or external command
causing Vite to exit with code 1 immediately after npm run dev.

Fix: check platform === 'win32' BEFORE checking npm_execpath so
Windows always uses the safe cmd.exe /d /s /c npm run <script> form.

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

* fix: buildNpmScriptCommand always uses cmd.exe on Windows

On Windows, npm sets npm_execpath to a path like
  C:\Program Files\nodejs\node_modules\npm\bin\npm-cli.js
which contains spaces. buildNpmScriptCommand was returning that
path as a spawn argument with the full node.exe path as the command.
spawnService uses shell:true on Windows, so Node.js passes the
unquoted command to cmd.exe:

  cmd.exe /d /s /c C:\Program Files\nodejs\node.exe ...

cmd.exe splits on the space and fails with
  'C:\Program' is not recognized as an internal or external command
causing Vite to exit with code 1 immediately after npm run dev.

Fix: check platform === 'win32' BEFORE checking npm_execpath so
Windows always uses the safe cmd.exe /d /s /c npm run <script> form.

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

* fix: AnimatePresence mode=wait expects one child, not two

ChatStatusIndicator had two separately-keyed motion.span children inside
<AnimatePresence mode="wait">. framer-motion's wait mode expects exactly ONE
child to exit before the next enters; two children trigger the repeated warning:
  "attempting to animate multiple children within AnimatePresence,
   but its mode is set to 'wait'"

Fix: wrap both elements in a single motion.span with unified key={status}
and className="contents" (CSS display:contents preserves flex layout).

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

* fix: set PYTHONUTF8=1 in agent-server/automation env on Windows

Python on Windows defaults to the system ANSI codepage (cp1252). The agent-server
writes metadata JSON containing emoji (e.g. U+2705 ✅) that cp1252 cannot encode,
producing UnicodeEncodeError → POST /api/conversations 500. Old UTF-8 conversation
files also fail to load at startup (UnicodeDecodeError). PYTHONUTF8=1 enables
Python's UTF-8 mode (PEP 540) for the process, matching Linux/macOS behaviour.

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

* fix: set PYTHONUTF8=1 in agent-server/automation env on Windows

Python on Windows defaults to the system ANSI codepage (cp1252). The agent-server
writes metadata JSON containing emoji (e.g. U+2705 ✅) that cp1252 cannot encode,
producing UnicodeEncodeError → POST /api/conversations 500. Old UTF-8 conversation
files also fail to load at startup (UnicodeDecodeError). PYTHONUTF8=1 enables
Python's UTF-8 mode (PEP 540) for the process, matching Linux/macOS behaviour.

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

* refactor: remove unrelated file

---------

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-05-28 16:29:55 +07:00

1094 lines
36 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.23.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-sdk==${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-sdk==${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 {
// Force Python to use UTF-8 for all file I/O and streams.
//
// On Windows, Python defaults to the system ANSI codepage (e.g. cp1252).
// The agent-server writes conversation metadata JSON that can contain
// emoji (e.g. ✅ U+2705) which cp1252 cannot encode, producing:
// UnicodeEncodeError: 'charmap' codec can't encode character '\u2705'
// Setting PYTHONUTF8=1 enables Python's UTF-8 mode (PEP 540) for the
// entire agent-server process, matching the behaviour on Linux/macOS
// where the locale is already UTF-8.
// This is a no-op on Linux/macOS where the locale is already UTF-8.
PYTHONUTF8: "1",
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,
) {
// On Windows, always use cmd.exe regardless of whether npm_execpath is set.
// npm_execpath points to a path like
// "C:\Program Files\nodejs\node_modules\npm\bin\npm-cli.js" which contains
// spaces. When that path is passed as an argument with shell:true in
// spawnService, cmd.exe splits on the space and tries to run "C:\Program"
// as a command, producing "not recognized as an internal or external command".
// Using "npm" via cmd.exe avoids the problem entirely.
if (platform === "win32") {
return {
command: env.ComSpec || "cmd.exe",
args: ["/d", "/s", "/c", "npm", "run", scriptName],
};
}
if (env.npm_execpath) {
return {
command: env.npm_node_execpath || nodeExecPath,
args: [env.npm_execpath, "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);
});
}