Files
OpenHands/scripts/dev-safe.mjs
T
665a258b80 feat(acp): inline live model picker for ACP conversations (#769) (#832)
* feat(acp): inline live model picker for ACP conversations (#769)

Converge ACP model selection onto the native LLM-profile inline picker UX
with live mid-conversation switching, replacing the display-only popover.

- Bump @openhands/typescript-client 1.23.3 -> 1.24.0 (adds switchAcpModel).
- AgentServerConversationService.switchAcpModel(conversationId, model): POST
  /switch_acp_model via ConversationClient, with switchProfile's local-only guard.
- useSwitchAcpModel hook: live switch for a running ACP session; for the
  home/no-session case, persist the choice as the agent-settings default
  (agent_settings_diff { acp_model }) so the next conversation inherits it.
- ChatInputModel popover becomes a picker over the provider's available_models
  (check on the effective model), local backend only; cloud / custom-provider /
  native surfaces keep the display + Settings link.
- New i18n key MODEL$AVAILABLE_MODELS.
- Tests for the hook (live vs settings-default branches) and the picker.

Local backend only (matches native switching); custom/unknown providers and any
app_server route remain out of scope per #769.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(acp): open the model picker on click (don't self-close via click-outside)

The inline picker's trigger button sits outside the popover element, so the
document click-outside handler (useClickOutsideElement) treated the opening
click as an "outside" click and closed the popover in the same interaction —
clicking the chip appeared to do nothing. (A programmatic el.click() worked by
fluke: the popover isn't rendered yet when that click bubbles, so the ref is
null and the close is skipped.)

Pass the trigger button as the hook's ignoreOutsideClickRef so a click on the
chip toggles the popover instead of being treated as an outside click.

Validated end-to-end against a local agent-server 1.24.0: the picker opens and
lists the provider's available_models, and selecting one writes the default via
PATCH /settings (home case), with the chip updating to the new model.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(acp): drop disableToast in useSwitchAcpModel so switch errors surface

useSwitchLlmProfile sets meta.disableToast because it's wrapped by
useSwitchLlmProfileAndLog, which re-surfaces errors via its own onError.
useSwitchAcpModel is called directly (no such wrapper / no onError), so
disableToast was silently swallowing failed switches and settings writes
(e.g. a 409 before the first message, network errors, the cloud guard).

Remove it and let the global mutation error toast report failures — simpler
and gives the user feedback when a switch doesn't take.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(acp): share chat input model picker state

* chore: address PR review feedback (#832)

- Add unit test for useChatInputModelState pinning its branching contract,
  incl. the active-ACP getAcpProvider lookup (was home-only in old component).
- Document why the overflow model submenu uses overflow-y-auto (scroll long
  model lists) rather than overflow-visible — no floating children to clip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: address PR review feedback (#832)

- Wrap the 'Available models' section label in a presentational <li> so it
  is a valid child of the ContextMenu <ul> (was a bare <div>).
- Drop unnecessary 'as never' casts in use-switch-acp-model tests now that
  the real return types (Promise<void>, Promise<boolean>) are honored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(acp): bump agent-server pin to 1.24.0 for /switch_acp_model

The inline ACP model picker POSTs to /api/conversations/{id}/switch_acp_model,
which is new in openhands-agent-server 1.24.0. The PR description already
lists agent-server:1.24.0 as a dependency, but config/defaults.json was
left at 1.23.1, so local dev (npm run dev) and Docker installs would 404
on every model switch attempt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(settings): always land on /settings/agent from /settings

The fallback order in ``getFirstAvailablePath`` put ``/settings/llm``
first whenever ``hide_llm_settings`` was off, so clicking Settings sent
the user to the LLM page. For ACP users that page is disabled and
``redirectIfAcpActive`` only catches them when the *personal* settings
already say ``agent_kind === "acp"`` — being in an ACP conversation
with non-ACP personal settings (the common case during the inline
picker flow) bypassed the guard and dumped them on /settings/llm.

Make ``/settings/agent`` the unconditional first fallback. It is
always available (no feature flag hides it), houses the agent-kind
picker, and the left nav still gets OpenHands users to LLM in one
click — so one extra click for non-ACP users buys a much simpler
routing surface and kills the ACP misroute.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(settings/agent): clear command when switching to Custom preset

Selecting "Custom" in the agent preset dropdown reset ``acpModel`` and
flipped ``isCustomAcpModel`` but left ``commandText`` untouched. On the
next render, ``detectPreset(commandText, ACP_PROVIDERS)`` still matched
the previous provider's ``default_command`` and snapped the dropdown
back off "Custom" — the toggle never stayed on Custom.

Clear ``commandText`` in the Custom branch so ``detectPreset`` falls
through to ``ACP_CUSTOM_PRESET_KEY`` on the next render and the dropdown
stays where the user put it. Empty command also matches the intended
"user supplies their own" semantics of the preset.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(settings): mark Verification page as disabledByAcp

The Verification page writes ``confirmation_mode`` and
``security_analyzer`` into ``conversation_settings_diff``. The ACP
agent loop never reads either: ``openhands/sdk/agent/acp_agent.py``
has zero references to ``confirmation_policy`` or
``security_analyzer``, and the only runtime readers
(``openhands/sdk/agent/agent.py:844,855``) live on the native
``Agent`` class — not on ``ACPAgent``. The backend accepts the values
and stores them on conversation state, but the ACP subprocess never
consults them.

So the page presents real-looking knobs that silently do nothing for
ACP users. Mark it ``disabledByAcp: true`` — same pattern as
``/settings/llm`` and ``/settings/condenser`` — so it greys out in the
nav and the existing route guard at ``src/routes/settings.tsx:47-51``
bounces direct visits to ``/settings/agent``.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ci): bump doc/script SDK version examples to 1.24.0

The docs-version-sync test enforces that every documented agent-server
version example matches ``config/defaults.json:versions.agentServer``.
The previous commit bumped that pin from 1.23.1 to 1.24.0 for the
``/switch_acp_model`` route, but left the example references in
AGENTS.md, ``scripts/dev-safe.mjs``, and ``scripts/check-sdk-version-sync.mjs``
behind — the drift-detector caught it as ``test-and-build`` failure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ci): bump remaining hard-coded 1.23.1 to 1.24.0

``__tests__/scripts/dev-safe.test.ts`` asserts ``buildAgentServerCommand``'s
default ``uvx`` args literally include ``openhands-agent-server==1.23.1`` and
matching ``openhands-{sdk,tools,workspace}==1.23.1``. The CI fix in the prior
commit only updated docs and example references; the central pin bump in
``config/defaults.json`` flowed through to this test's runtime expectation but
the literal expectations were never updated. Bump them.

Also bump the ``MOCK_AGENT_SERVER_VERSION`` placeholder in
``src/mocks/settings-handlers.ts`` for consistency with the central pin —
no test asserts on it, but leaving the mock at 1.23.1 invites future
drift confusion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Debug Agent <debug@example.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 16:42:17 +02:00

1094 lines
36 KiB
JavaScript
Raw Blame History

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