mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-06 12:33:43 +08:00
* 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>
1069 lines
35 KiB
JavaScript
1069 lines
35 KiB
JavaScript
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);
|
||
});
|
||
}
|