Files
OpenHands/scripts/dev-safe.mjs
T
Rohit Malhotraandopenhands 3207d90e72 feat: add dynamic port allocation with preferred port fallback (#223)
* feat: add dynamic port allocation with preferred port fallback

Implement dynamic port allocation for dev entrypoint scripts to gracefully
handle port conflicts. When a preferred port is busy, the system automatically
finds an alternative available port.

Changes:
- Add findFreePort() and findFreePorts() utilities to dev-safe.mjs
- Add buildSafeDevConfigAsync() for async config with dynamic allocation
- Update dev-with-automation.mjs to use async buildConfig with dynamic ports
- Update dev-static.mjs to use async buildConfig
- Add strictPort: true to vite.config.ts to fail-fast on conflicts
- Update tests for async buildConfig

The utilities try the preferred/default ports first, falling back to
OS-assigned ports only when needed. This preserves predictable defaults
while gracefully handling port conflicts.

Closes #222

* fix: address review feedback - add max retry, document race condition, improve tests

- Add max retry count (100 attempts) to port allocation loop to prevent
  infinite loops
- Fix findFreePort to handle preferredPort=0 correctly by skipping the
  port check and going straight to OS assignment
- Document race condition limitation in findFreePort JSDoc (accepted
  limitation with guidance on handling EADDRINUSE)
- Clarify JSDoc for buildSafeDevConfig vs buildSafeDevConfigAsync with
  clear guidance on when to use each
- Remove misleading 'must be after prereq check' comment
- Add comprehensive tests for findFreePort, findFreePorts, and
  buildSafeDevConfigAsync using actual port blocking
- Improve buildConfig tests with port uniqueness verification and
  fallback tests using high ports
- Use high ports (19xxx range) in tests to avoid conflicts with
  system services

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

---------

Co-authored-by: openhands <openhands@all-hands.dev>
2026-05-09 14:46:10 -04:00

750 lines
23 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,
statSync,
unlinkSync,
} from "node:fs";
import net from "node:net";
import { homedir } from "node:os";
import path from "node:path";
import process from "node:process";
import { setTimeout as delay } from "node:timers/promises";
import { pathToFileURL } from "node:url";
const DEFAULT_BACKEND_PORT = 18000;
const DEFAULT_VITE_PORT = 3001;
const DEFAULT_WAIT_TIMEOUT_MS = 30_000;
const DEFAULT_AGENT_SERVER_PACKAGE = "openhands-agent-server";
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",
];
// Default secret key for local development (DO NOT use in production)
// This is kept static because it's used for encrypting/decrypting persisted settings
const DEFAULT_SECRET_KEY = "openhands-dev-secret-key-change-in-prod";
// Default agent-server version (released PyPI version)
// Set OH_AGENT_SERVER_GIT_REF to use a git branch/SHA instead
const DEFAULT_AGENT_SERVER_VERSION = "1.21.1";
/**
* Generate a cryptographically secure random API key.
* Returns a 64-character hex string (256-bit).
*/
export function generateRandomApiKey() {
return randomBytes(32).toString("hex");
}
// Auto-generate a random session API key for this dev session.
// This ensures the agent-server API is authenticated even in local dev.
// Set SESSION_API_KEY env var to use a consistent key across restarts.
const DEFAULT_SESSION_API_KEY = generateRandomApiKey();
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");
}
/**
* 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.21.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
*/
/**
* 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 auto-generated random key for this session
// 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
const sessionApiKey =
env.SESSION_API_KEY ||
env.OH_SESSION_API_KEYS_0 ||
env.VITE_SESSION_API_KEY ||
DEFAULT_SESSION_API_KEY;
return {
cwd,
backendPort,
vscodePort,
stateDir,
tmuxTmpDir: path.join(stateDir, "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,
};
}
/**
* 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,
};
}
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, { 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...");
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)"
: "auto-generated (random)";
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;
frontend?.kill(signal);
backend.kill(signal);
};
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");
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,
},
});
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);
});
}