Files
OpenHands/scripts/dev-safe.mjs
T
Graham Neubigandopenhands 62871d3a7b rename agent-server-gui to agent-canvas (#121)
- npm package: @openhands/agent-server-gui -> @openhands/agent-canvas
- README/DEVELOPMENT/AGENTS/codereview guide updated to new name
- GitHub URL in onboarding screen + tests updated
- dev launcher env vars renamed: OH_GUI_SAFE_* -> OH_CANVAS_SAFE_*
- default state dir: ~/.openhands/agent-server-gui -> ~/.openhands/agent-canvas
- i18n strings replace 'GUI' phrasing with 'Agent Canvas' across settings,
  upgrade, onboarding, and unavailable copy
- Test fixtures, working-dir paths, and library-consumer smoke updated

Co-authored-by: openhands <openhands@all-hands.dev>
2026-05-06 13:07:01 -04:00

386 lines
11 KiB
JavaScript

import { spawn } from "node:child_process";
import { mkdirSync } from "node:fs";
import { homedir } from "node:os";
import path from "node:path";
import process from "node:process";
import { setTimeout as delay } from "node:timers/promises";
import { pathToFileURL } from "node:url";
const DEFAULT_BACKEND_PORT = 18000;
const DEFAULT_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";
// Default secret key for local development (DO NOT use in production)
const DEFAULT_SECRET_KEY = "openhands-dev-secret-key-change-in-prod";
// Default to main branch until settings persistence APIs are in a released version.
// TODO: Once SDK PR #3060 is released, change this to null and let it use PyPI.
const DEFAULT_GIT_REF = "main";
function isEnoentError(error) {
return Boolean(
(error &&
typeof error === "object" &&
"code" in error &&
error.code === "ENOENT") ||
/ENOENT/.test(String(error)),
);
}
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:
* - OH_AGENT_SERVER_VERSION: Specific PyPI version (e.g., "1.18.0")
* - OH_AGENT_SERVER_GIT_REF: Git commit SHA or branch name (takes precedence over version)
*
* If neither is set, defaults to main branch until settings persistence APIs
* are released. Set OH_AGENT_SERVER_VERSION to use a released version.
*
* @param {Record<string, string | undefined>} env
* @returns {{ command: string, args: string[], source: string }}
*/
export function buildAgentServerCommand(env = process.env) {
const gitRef = env.OH_AGENT_SERVER_GIT_REF;
const version = env.OH_AGENT_SERVER_VERSION;
const uvxArgs = [];
let source = "";
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
uvxArgs.push(
"--from",
`${DEFAULT_AGENT_SERVER_PACKAGE}==${version}`,
"--with",
"openhands-tools",
"--with",
"openhands-workspace",
"agent-server",
);
source = `PyPI (${version})`;
} else if (DEFAULT_GIT_REF) {
// Default to git ref when no version specified (until APIs are released)
const baseGitUrl = `git+${AGENT_SERVER_GIT_REPO}@${DEFAULT_GIT_REF}`;
uvxArgs.push(
"--from",
`${baseGitUrl}#subdirectory=openhands-agent-server`,
"--with",
`${baseGitUrl}#subdirectory=openhands-tools`,
"--with",
`${baseGitUrl}#subdirectory=openhands-workspace`,
"agent-server",
);
source = `git (${DEFAULT_GIT_REF}, default)`;
} else {
// Use latest released version: uvx --from openhands-agent-server agent-server
// The package name differs from the executable name, so we need --from syntax
uvxArgs.push(
"--from",
DEFAULT_AGENT_SERVER_PACKAGE,
"--with",
"openhands-tools",
"--with",
"openhands-workspace",
"agent-server",
);
source = "PyPI (latest)";
}
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;
}
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,
);
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;
return {
cwd,
backendPort,
vscodePort,
stateDir,
tmuxTmpDir: path.join(stateDir, "tmux"),
conversationsPath,
workspacesPath,
bashEventsDir: path.join(stateDir, "bash_events"),
backendBaseUrl: `http://127.0.0.1:${backendPort}`,
backendHost: `127.0.0.1:${backendPort}`,
workingDir: env.VITE_WORKING_DIR || workspacesPath,
secretKey,
};
}
/**
* 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,
};
}
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],
};
}
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() {
const config = buildSafeDevConfig();
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)";
console.log("Starting isolated agent-server + frontend dev stack...");
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("");
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,
},
});
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;
}
});
}
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);
});
}