mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-07 13:38:55 +08:00
* feat: update SDK to 1.22.0 and add CI version sync check
- Update DEFAULT_AGENT_SERVER_VERSION from 1.21.1 to 1.22.0 in dev-safe.mjs
- Update SDK version references in AGENTS.md
- Add scripts/check-sdk-version-sync.mjs to verify automation project uses
matching SDK versions for openhands-sdk, openhands-tools, openhands-workspace,
and openhands-agent-server
- Add .github/workflows/sdk-version-sync.yml CI workflow with:
- Path-filtered PR/push triggers for version-related file changes
- repository_dispatch triggers (sdk-version-check, sdk-release) for
external repos to notify when SDK deps change
- workflow_dispatch with optional version override
- Scheduled runs every 6 hours to catch upstream changes
- PyPI version checking support (--check-pypi flag)
The check script supports:
- EXPECTED_SDK_VERSION env var override for CI triggers
- --check-pypi flag to also display latest PyPI versions
- --help for usage documentation
To trigger from external repos (e.g., OpenHands/automation or SDK repo):
curl -X POST -H "Authorization: token \$GITHUB_TOKEN" \\
https://api.github.com/repos/OpenHands/agent-canvas/dispatches \\
-d '{"event_type": "sdk-version-check"}'
* fix: check released PyPI version instead of GitHub main branch
The SDK version sync check now fetches dependencies from the released
openhands-automation package on PyPI (version specified by
DEFAULT_AUTOMATION_VERSION in dev-with-automation.mjs) rather than
fetching pyproject.toml from the GitHub main branch.
This ensures we're checking the actual released version that users
would install, not the development version on main.
* fix: address review feedback for SDK version sync check
- Add env var overrides for automation package name and version
- Add retry logic with exponential backoff for PyPI API failures
- Add semantic version normalization for comparing versions
- Fix repository_dispatch to use client_payload.version
- Improve regex to handle parenthesized dependency formats
- Add comprehensive test coverage for helper functions
* fix: add type casts for dynamic module import in tests
* chore: update automation version to 1.0.0a2
- Update DEFAULT_AUTOMATION_VERSION in dev-with-automation.mjs
- Update AGENTS.md documentation
- Update test expectation
---------
Co-authored-by: openhands <openhands@all-hands.dev>
833 lines
26 KiB
JavaScript
833 lines
26 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 { 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.22.0";
|
||
|
||
/**
|
||
* 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` / `npm run dev:dangerously-dockerless` /
|
||
// `npm run dev:docker` 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 persistedSessionApiKeyCache = 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,
|
||
) {
|
||
const cached = persistedSessionApiKeyCache.get(filePath);
|
||
if (cached) return cached;
|
||
|
||
// Try to read an existing key.
|
||
try {
|
||
const existing = readFileSync(filePath, "utf8").trim();
|
||
if (existing) {
|
||
persistedSessionApiKeyCache.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 session 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 session API key to ${filePath}: ${error.message}. Falling back to in-memory key (will not survive restarts).`,
|
||
);
|
||
}
|
||
persistedSessionApiKeyCache.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() {
|
||
persistedSessionApiKeyCache.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");
|
||
}
|
||
|
||
/**
|
||
* 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.0")
|
||
*
|
||
* 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 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);
|
||
|
||
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,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 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)"
|
||
: `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;
|
||
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);
|
||
});
|
||
}
|