mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-07 14:58:39 +08:00
* Add npm publish workflow and release infrastructure - Add .github/workflows/npm-publish.yml for automated npm publishing on GitHub releases - Update CI to verify library build (npm run build:lib) and package contents - Add CHANGELOG.md for version history tracking - Update README.md with npm installation and usage documentation Closes #197 Co-authored-by: openhands <openhands@all-hands.dev> * correct package version * chore: update npm-publish workflow for trusted publishing - Remove NODE_AUTH_TOKEN secret dependency - Keep id-token: write permission for OIDC - Add provenance flag for npm attestations - Add comment explaining trusted publisher setup on npmjs.com Co-authored-by: openhands <openhands@all-hands.dev> * feat: add CLI entry point for npx execution - Add bin/agent-canvas.mjs as executable CLI - Add bin field to package.json for npm bin linking - Include bin/ and build/ directories in published files - CLI serves the built application with SPA routing support - Supports --port, --host, and --help options Co-authored-by: openhands <openhands@all-hands.dev> * refactor: consolidate npm executable to use dev-docker infrastructure - bin/agent-canvas.mjs now uses dev-with-automation.mjs main() with dev-docker.mjs's Docker-specific agent-server starter - Added --static and --static-dir support to dev-with-automation.mjs so the npm executable serves pre-built static assets instead of Vite - Added startStaticFrontend() function that uses static-server.mjs - npm executable runs full stack: Docker agent-server + uvx automation backend + static frontend + ingress proxy Co-authored-by: openhands <openhands@all-hands.dev> * fix: include scripts/ in npm package files The bin/agent-canvas.mjs executable imports from scripts/dev-with-automation.mjs and scripts/dev-docker.mjs, so the scripts directory must be included in the published package. Co-authored-by: openhands <openhands@all-hands.dev> * fix: address review comments - Fix CHANGELOG.md version mismatch: 1.6.0 -> 1.0.0-alpha.1 to match package.json - Add NODE_AUTH_TOKEN env var to npm-publish workflow for authentication - Add CLI entry point mention to CHANGELOG Co-authored-by: openhands <openhands@all-hands.dev> * fix: use OIDC trusted publishing (no NPM_TOKEN needed) npm trusted publishing with OIDC doesn't require NODE_AUTH_TOKEN. Instead it uses short-lived OIDC tokens generated by GitHub Actions. Requirements: - id-token: write permission (already set) - npm CLI 11.5.1+ (added npm install -g npm@latest step) - Trusted publisher configured on npmjs.com See: https://docs.npmjs.com/trusted-publishers/ Co-authored-by: openhands <openhands@all-hands.dev> * chore: bump version to 1.0.0-alpha.2 Co-authored-by: openhands <openhands@all-hands.dev> * Build app assets before npm publish Co-authored-by: openhands <openhands@all-hands.dev> * fix: use Node 24 for npm trusted publishing Trusted publishing requires Node 22.14.0+ and npm 11.5.1+. Node 24 ships with npm 11.x which meets the requirement. Node 22.12.0 (previous) ships with npm 10.x which doesn't support OIDC. Also removed the manual npm upgrade step since Node 24 includes a compatible npm version by default. Co-authored-by: openhands <openhands@all-hands.dev> * chore: align all workflows to Node 24 and regenerate lockfile - Update ci.yml to use Node 24 - Update sdk-version-sync.yml to use Node 24 - Regenerate package-lock.json with npm 11.12.1 All workflows now use Node 24 which ships with npm 11.x, required for OIDC trusted publishing (npm 11.5.1+). Co-authored-by: openhands <openhands@all-hands.dev> * fix: remove incorrect LLM env vars from CLI help LLM_MODEL and LLM_API_KEY were listed in the help text but aren't actually used by the scripts. LLM settings are configured through the web UI settings page instead. Co-authored-by: openhands <openhands@all-hands.dev> * fix: address PR review feedback Critical fixes: - Guard prepare script to only run in dev context (check for ../.git) - Add missing existsSync import in dev-with-automation.mjs Workflow improvements: - Update checkout/setup-node actions to v6 for consistency - Add npm version validation (must be 11.5.1+ for trusted publishing) - Add package version validation (must match release tag) CLI improvements: - Add try-catch for dynamic imports with helpful error message - Use console.error directly instead of imported logError/c Documentation: - Fix README export names: ChatInterface→ChatPanel, Terminal→TerminalPanel - Add dist/ to .gitignore Co-authored-by: openhands <openhands@all-hands.dev> * ci: trigger npm publish on tag push instead of release Simpler workflow - just push a tag like v1.0.0-alpha.2 to publish. Co-authored-by: openhands <openhands@all-hands.dev> * chore: remove tarball and add *.tgz to gitignore Co-authored-by: openhands <openhands@all-hands.dev> * fix: npm publish errors 1. Fix bin path - remove './' prefix (npm pkg fix) 2. Add --tag for prerelease versions (alpha/beta/rc) Co-authored-by: openhands <openhands@all-hands.dev> * fix: add repository field for npm provenance verification npm provenance requires repository.url to match the GitHub Actions source. Also added description, homepage, and bugs fields. Co-authored-by: openhands <openhands@all-hands.dev> --------- Co-authored-by: openhands <openhands@all-hands.dev>
851 lines
31 KiB
JavaScript
851 lines
31 KiB
JavaScript
/**
|
|
* Development Stack with Automation Service
|
|
*
|
|
* Extends agent-canvas's dev-safe.mjs to additionally run the OpenHands Automation
|
|
* backend via uvx. No cloning required - runs directly from git reference.
|
|
*
|
|
* Uses a standalone ingress proxy to route traffic to multiple backends.
|
|
*
|
|
* Architecture:
|
|
* ┌──────────────────────────────────────────────────────────────────────────┐
|
|
* │ http://localhost:8000 (Ingress Proxy) │
|
|
* │ /api/automation/* → Automation Backend │
|
|
* │ /api/*, /sockets → Agent Server │
|
|
* │ /* → Vite Dev Server │
|
|
* └──────────────────────────────────────────────────────────────────────────┘
|
|
* │ │ │
|
|
* ▼ ▼ ▼
|
|
* ┌─────────────┐ ┌───────────────┐ ┌──────────────────┐
|
|
* │ Vite │ │ Agent Server │ │ Automation │
|
|
* │ :3001 │ │ (uvx) :18000 │ │ Backend (uvx) │
|
|
* │ │ │ │ │ :18001 │
|
|
* └─────────────┘ └───────────────┘ └──────────────────┘
|
|
*
|
|
* Usage:
|
|
* node scripts/dev-with-automation.mjs
|
|
* node scripts/dev-with-automation.mjs --automation-ref feat/my-branch
|
|
* node scripts/dev-with-automation.mjs --port 12000
|
|
*
|
|
* Environment variables:
|
|
* - PORT: Ingress port (default: 8000)
|
|
* - OH_AUTOMATION_GIT_REF: Git ref for automation (default: main)
|
|
* - OH_AGENT_SERVER_GIT_REF: Git ref for agent-server
|
|
* - AUTOMATION_LOCAL_API_KEY: Custom API key for automation backend auth
|
|
*
|
|
* Secrets:
|
|
* The automation API key is automatically seeded into agent-server secrets
|
|
* as OPENHANDS_AUTOMATION_API_KEY, making it available to agents in conversations.
|
|
*/
|
|
|
|
import { spawn, spawnSync } from "node:child_process";
|
|
import { mkdirSync, existsSync } from "node:fs";
|
|
import { join, resolve, dirname } from "node:path";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
import { homedir } from "node:os";
|
|
import { setTimeout as delay } from "node:timers/promises";
|
|
import process from "node:process";
|
|
|
|
import {
|
|
buildAgentServerCommand,
|
|
buildSafeDevConfig,
|
|
buildAgentServerEnv,
|
|
buildNpmScriptCommand,
|
|
formatMissingUvxGuidance,
|
|
generateRandomApiKey,
|
|
findFreePorts,
|
|
} from "./dev-safe.mjs";
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const projectRoot = resolve(__dirname, "..");
|
|
|
|
const DEFAULT_AUTOMATION_REPO = "https://github.com/OpenHands/automation";
|
|
const DEFAULT_AUTOMATION_PACKAGE = "openhands-automation";
|
|
// Default automation version (released PyPI version)
|
|
// Set OH_AUTOMATION_GIT_REF to use a git branch/SHA instead
|
|
const DEFAULT_AUTOMATION_VERSION = "1.0.0a2";
|
|
const DEFAULT_BACKEND_PORT = 18000;
|
|
const DEFAULT_AUTOMATION_PORT = 18001;
|
|
|
|
// Auto-generate a random API key for this dev session.
|
|
// This ensures services share the same key during a single invocation,
|
|
// but each restart gets a fresh key for better security isolation.
|
|
// Set AUTOMATION_LOCAL_API_KEY env var to use a consistent key across restarts.
|
|
const DEFAULT_LOCAL_API_KEY = generateRandomApiKey();
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Terminal Styling
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
const c = {
|
|
reset: "\x1b[0m",
|
|
bold: "\x1b[1m",
|
|
dim: "\x1b[2m",
|
|
red: "\x1b[31m",
|
|
green: "\x1b[32m",
|
|
yellow: "\x1b[33m",
|
|
blue: "\x1b[34m",
|
|
magenta: "\x1b[35m",
|
|
cyan: "\x1b[36m",
|
|
};
|
|
|
|
function logService(name, message, color = c.reset) {
|
|
const ts = new Date().toISOString().split("T")[1].split(".")[0];
|
|
console.log(`${c.dim}${ts}${c.reset} ${color}[${name}]${c.reset} ${message}`);
|
|
}
|
|
|
|
function logStep(step, message) {
|
|
console.log(`${c.cyan}[${step}]${c.reset} ${message}`);
|
|
}
|
|
|
|
function logSuccess(message) {
|
|
console.log(`${c.green}✓${c.reset} ${message}`);
|
|
}
|
|
|
|
function logError(message) {
|
|
console.error(`${c.red}✗${c.reset} ${message}`);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Configuration
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
function parseArgs() {
|
|
const args = process.argv.slice(2);
|
|
const config = {
|
|
port: null,
|
|
automationGitRef: null,
|
|
automationRepo: null,
|
|
verbose: false,
|
|
static: false,
|
|
staticDir: null,
|
|
};
|
|
|
|
for (let i = 0; i < args.length; i++) {
|
|
switch (args[i]) {
|
|
case "-p":
|
|
case "--port":
|
|
config.port = parseInt(args[++i], 10);
|
|
break;
|
|
case "--automation-ref":
|
|
config.automationGitRef = args[++i];
|
|
break;
|
|
case "--automation-repo":
|
|
config.automationRepo = args[++i];
|
|
break;
|
|
case "-v":
|
|
case "--verbose":
|
|
config.verbose = true;
|
|
break;
|
|
case "--static":
|
|
config.static = true;
|
|
break;
|
|
case "--static-dir":
|
|
config.staticDir = args[++i];
|
|
break;
|
|
case "-h":
|
|
case "--help":
|
|
showHelp();
|
|
process.exit(0);
|
|
}
|
|
}
|
|
|
|
return config;
|
|
}
|
|
|
|
function showHelp() {
|
|
console.log(`
|
|
Agent Canvas + Automation Development Stack
|
|
|
|
Runs agent-canvas with the automation backend (via uvx, no clone needed).
|
|
Uses a standalone ingress proxy to route traffic.
|
|
|
|
USAGE:
|
|
node scripts/dev-with-automation.mjs [options]
|
|
|
|
OPTIONS:
|
|
-p, --port <port> Ingress port (default: 8000)
|
|
--automation-ref <ref> Git ref for automation (branch/tag/SHA)
|
|
--automation-repo <url> Git repo URL (default: ${DEFAULT_AUTOMATION_REPO})
|
|
-v, --verbose Show detailed output
|
|
-h, --help Show this help
|
|
|
|
ENVIRONMENT VARIABLES:
|
|
PORT Alternative to --port
|
|
OH_AUTOMATION_GIT_REF Git ref for automation (overrides default version)
|
|
OH_AUTOMATION_VERSION Specific PyPI version for automation (default: ${DEFAULT_AUTOMATION_VERSION})
|
|
OH_AGENT_SERVER_GIT_REF Git ref for agent-server SDK (overrides default version)
|
|
OH_AGENT_SERVER_VERSION Specific PyPI version for agent-server
|
|
OH_SECRET_KEY Secret key for sessions
|
|
AUTOMATION_LOCAL_API_KEY Custom API key for automation backend auth
|
|
|
|
SECRETS:
|
|
The automation API key is automatically seeded into agent-server secrets
|
|
as OPENHANDS_AUTOMATION_API_KEY, making it available to agents in conversations.
|
|
|
|
ACCESS POINTS:
|
|
Main UI: http://localhost:PORT/
|
|
API Docs: http://localhost:PORT/api/automation/docs
|
|
`);
|
|
}
|
|
|
|
/**
|
|
* Build the uvx command for running automation backend.
|
|
*
|
|
* Environment variables (highest precedence first):
|
|
* - OH_AUTOMATION_GIT_REF: Git commit SHA or branch name
|
|
* - OH_AUTOMATION_VERSION: Specific PyPI version (e.g., "1.0.0a1")
|
|
*
|
|
* If none are set, defaults to the released version specified by
|
|
* DEFAULT_AUTOMATION_VERSION. Set OH_AUTOMATION_GIT_REF to use a
|
|
* git branch or commit instead.
|
|
*/
|
|
function buildAutomationCommand(env = process.env) {
|
|
const gitRef = env.OH_AUTOMATION_GIT_REF;
|
|
const version = env.OH_AUTOMATION_VERSION;
|
|
const repoUrl = env.OH_AUTOMATION_REPO || DEFAULT_AUTOMATION_REPO;
|
|
|
|
const uvxArgs = [];
|
|
let source = "";
|
|
|
|
if (gitRef) {
|
|
// Use git ref - refresh to ensure latest commit is fetched
|
|
const gitUrl = `git+${repoUrl}@${gitRef}`;
|
|
uvxArgs.push("--refresh", "--from", gitUrl, "uvicorn", "openhands.automation.app:app");
|
|
source = `git (${gitRef})`;
|
|
} else if (version) {
|
|
// Use specific PyPI version
|
|
uvxArgs.push("--from", `${DEFAULT_AUTOMATION_PACKAGE}==${version}`, "uvicorn", "openhands.automation.app:app");
|
|
source = `PyPI (${version})`;
|
|
} else {
|
|
// Default to released PyPI version
|
|
uvxArgs.push("--from", `${DEFAULT_AUTOMATION_PACKAGE}==${DEFAULT_AUTOMATION_VERSION}`, "uvicorn", "openhands.automation.app:app");
|
|
source = `PyPI (${DEFAULT_AUTOMATION_VERSION}, default)`;
|
|
}
|
|
|
|
return {
|
|
command: "uvx",
|
|
args: uvxArgs,
|
|
source,
|
|
};
|
|
}
|
|
|
|
async function buildConfig(args, env = process.env) {
|
|
// Apply args to env for buildAutomationCommand
|
|
if (args.automationGitRef) {
|
|
env.OH_AUTOMATION_GIT_REF = args.automationGitRef;
|
|
}
|
|
if (args.automationRepo) {
|
|
env.OH_AUTOMATION_REPO = args.automationRepo;
|
|
}
|
|
|
|
// Preferred ports (from env or defaults)
|
|
const preferredIngressPort = args.port || parseInt(env.PORT, 10) || 8000;
|
|
const preferredBackendPort = DEFAULT_BACKEND_PORT;
|
|
const preferredAutomationPort = DEFAULT_AUTOMATION_PORT;
|
|
const preferredVitePort = 3001;
|
|
|
|
// Find available ports, preferring the defaults
|
|
logStep("ports", "Allocating ports...");
|
|
const ports = await findFreePorts([
|
|
{ name: "ingress", preferred: preferredIngressPort },
|
|
{ name: "backend", preferred: preferredBackendPort },
|
|
{ name: "automation", preferred: preferredAutomationPort },
|
|
{ name: "vite", preferred: preferredVitePort },
|
|
]);
|
|
|
|
// Log any port changes
|
|
if (ports.ingress !== preferredIngressPort) {
|
|
logService("ports", `Port ${preferredIngressPort} busy, using ${ports.ingress} for ingress`, c.yellow);
|
|
}
|
|
if (ports.backend !== preferredBackendPort) {
|
|
logService("ports", `Port ${preferredBackendPort} busy, using ${ports.backend} for agent-server`, c.yellow);
|
|
}
|
|
if (ports.automation !== preferredAutomationPort) {
|
|
logService("ports", `Port ${preferredAutomationPort} busy, using ${ports.automation} for automation`, c.yellow);
|
|
}
|
|
if (ports.vite !== preferredVitePort) {
|
|
logService("ports", `Port ${preferredVitePort} busy, using ${ports.vite} for vite`, c.yellow);
|
|
}
|
|
|
|
const vscodePort = ports.backend + 1000;
|
|
|
|
// Local API key for automation backend auth
|
|
const localApiKey = env.AUTOMATION_LOCAL_API_KEY || DEFAULT_LOCAL_API_KEY;
|
|
|
|
// Session API key for agent-server auth
|
|
// Build a preliminary safe config to get the auto-generated session key
|
|
// This ensures both agent-server and frontend use the same key
|
|
const stateDir = join(homedir(), ".openhands", "agent-canvas");
|
|
const safeConfig = buildSafeDevConfig(projectRoot, {
|
|
...env,
|
|
OH_CANVAS_SAFE_STATE_DIR: stateDir,
|
|
OH_CANVAS_SAFE_BACKEND_PORT: ports.backend.toString(),
|
|
OH_CANVAS_SAFE_VSCODE_PORT: vscodePort.toString(),
|
|
});
|
|
const sessionApiKey = safeConfig.sessionApiKey;
|
|
|
|
return {
|
|
// Ingress port (main entry point)
|
|
ingressPort: ports.ingress,
|
|
|
|
// Service ports (internal)
|
|
agentServerPort: ports.backend,
|
|
autoBackendPort: ports.automation,
|
|
vitePort: ports.vite,
|
|
vscodePort,
|
|
|
|
// Paths
|
|
canvasPath: projectRoot,
|
|
|
|
// Data directories (same as dev-safe.mjs)
|
|
stateDir,
|
|
|
|
// Auth
|
|
localApiKey,
|
|
sessionApiKey,
|
|
|
|
verbose: args.verbose,
|
|
};
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Prerequisites & Setup
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
function commandExists(cmd) {
|
|
const result =
|
|
process.platform === "win32"
|
|
? spawnSync("where.exe", [cmd], { stdio: "pipe" })
|
|
: spawnSync("sh", ["-c", `command -v ${cmd}`], { stdio: "pipe" });
|
|
|
|
return result.status === 0;
|
|
}
|
|
|
|
function checkPrerequisites() {
|
|
logStep("1/2", "Checking prerequisites...");
|
|
|
|
if (!commandExists("uvx")) {
|
|
console.error(formatMissingUvxGuidance(projectRoot));
|
|
process.exit(1);
|
|
}
|
|
logSuccess("uvx found");
|
|
|
|
if (!commandExists("npm")) {
|
|
logError("npm is required but not found");
|
|
process.exit(1);
|
|
}
|
|
logSuccess("npm found");
|
|
}
|
|
|
|
function ensureDirectories(config) {
|
|
const dirs = [
|
|
config.stateDir,
|
|
join(config.stateDir, "conversations"),
|
|
join(config.stateDir, "workspaces"),
|
|
join(config.stateDir, "bash_events"),
|
|
join(config.stateDir, "storage"),
|
|
];
|
|
|
|
for (const dir of dirs) {
|
|
mkdirSync(dir, { recursive: true });
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Process Management
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
const processes = new Map();
|
|
|
|
function spawnService(name, command, args, options = {}) {
|
|
const proc = spawn(command, args, {
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
env: { ...process.env, ...options.env },
|
|
cwd: options.cwd,
|
|
shell: process.platform === "win32",
|
|
});
|
|
|
|
const color = options.color || c.reset;
|
|
|
|
proc.stdout.on("data", (data) => {
|
|
data
|
|
.toString()
|
|
.split("\n")
|
|
.filter(Boolean)
|
|
.forEach((line) => {
|
|
logService(name, line.trim(), color);
|
|
});
|
|
});
|
|
|
|
proc.stderr.on("data", (data) => {
|
|
data
|
|
.toString()
|
|
.split("\n")
|
|
.filter(Boolean)
|
|
.forEach((line) => {
|
|
logService(name, line.trim(), c.yellow);
|
|
});
|
|
});
|
|
|
|
proc.on("error", (error) => {
|
|
logError(`${name} failed to start: ${error.message}`);
|
|
});
|
|
|
|
proc.on("exit", (code, signal) => {
|
|
if (code !== 0 && code !== null && !shuttingDown) {
|
|
logService(name, `Exited with code ${code}`, c.red);
|
|
}
|
|
processes.delete(name);
|
|
});
|
|
|
|
processes.set(name, proc);
|
|
return proc;
|
|
}
|
|
|
|
async function waitForService(name, url, timeoutMs = 30000) {
|
|
const start = Date.now();
|
|
let lastError = null;
|
|
|
|
while (Date.now() - start < timeoutMs) {
|
|
try {
|
|
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
|
|
if (res.ok) {
|
|
logService(name, `Ready at ${url}`, c.green);
|
|
return true;
|
|
}
|
|
} catch (err) {
|
|
lastError = err;
|
|
// Keep trying
|
|
}
|
|
await delay(500);
|
|
}
|
|
|
|
const elapsed = Math.round((Date.now() - start) / 1000);
|
|
logService(name, `Timeout waiting for ${url} after ${elapsed}s`, c.red);
|
|
if (lastError) {
|
|
logService(name, `Last error: ${lastError.message}`, c.dim);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Service Starters
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
function startAgentServer(config) {
|
|
logService("agent-server", `Starting on port ${config.agentServerPort}...`, c.blue);
|
|
|
|
const agentServerCmd = buildAgentServerCommand(process.env);
|
|
logService("agent-server", `Using ${agentServerCmd.source}`, c.dim);
|
|
|
|
// Build safe config for agent-server env vars
|
|
const safeConfig = buildSafeDevConfig(config.canvasPath, {
|
|
...process.env,
|
|
OH_CANVAS_SAFE_STATE_DIR: config.stateDir,
|
|
OH_CANVAS_SAFE_BACKEND_PORT: config.agentServerPort.toString(),
|
|
OH_CANVAS_SAFE_VSCODE_PORT: config.vscodePort.toString(),
|
|
});
|
|
|
|
const agentServerEnv = buildAgentServerEnv(safeConfig);
|
|
|
|
spawnService(
|
|
"agent-server",
|
|
agentServerCmd.command,
|
|
[...agentServerCmd.args, "--host", "127.0.0.1", "--port", String(config.agentServerPort)],
|
|
{
|
|
cwd: safeConfig.workspacesPath,
|
|
env: agentServerEnv,
|
|
color: c.blue,
|
|
}
|
|
);
|
|
}
|
|
|
|
function startAutomationBackend(config) {
|
|
logService("automation", `Starting on port ${config.autoBackendPort}...`, c.green);
|
|
|
|
const automationCmd = buildAutomationCommand(process.env);
|
|
logService("automation", `Using ${automationCmd.source}`, c.dim);
|
|
|
|
spawnService(
|
|
"automation",
|
|
automationCmd.command,
|
|
[
|
|
...automationCmd.args,
|
|
"--host", "127.0.0.1",
|
|
"--port", config.autoBackendPort.toString(),
|
|
],
|
|
{
|
|
cwd: config.stateDir,
|
|
env: {
|
|
AUTOMATION_AGENT_SERVER_URL: `http://localhost:${config.agentServerPort}`,
|
|
AUTOMATION_DB_URL: `sqlite+aiosqlite:///${join(config.stateDir, "automations.db")}`,
|
|
AUTOMATION_BASE_URL: `http://localhost:${config.ingressPort}`,
|
|
AUTOMATION_WORKSPACE_BASE: join(config.stateDir, "workspaces"),
|
|
// Local API key for self-hosted auth (no cloud API needed)
|
|
AUTOMATION_LOCAL_API_KEY: config.localApiKey,
|
|
// CORS: allow localhost origins for dev
|
|
AUTOMATION_CORS_ORIGINS: `http://localhost:${config.ingressPort},http://127.0.0.1:${config.ingressPort},http://localhost:3001,http://127.0.0.1:3001`,
|
|
FILE_STORE: "local",
|
|
LOCAL_STORAGE_PATH: join(config.stateDir, "storage"),
|
|
OPENHANDS_SUPPRESS_BANNER: "1",
|
|
},
|
|
color: c.green,
|
|
}
|
|
);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Main
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
let shuttingDown = false;
|
|
|
|
function shutdown() {
|
|
if (shuttingDown) return;
|
|
shuttingDown = true;
|
|
|
|
console.log("");
|
|
console.log(`${c.yellow}Shutting down...${c.reset}`);
|
|
|
|
for (const [name, proc] of processes) {
|
|
logService(name, "Stopping...", c.dim);
|
|
proc.kill("SIGTERM");
|
|
}
|
|
|
|
setTimeout(() => {
|
|
for (const [, proc] of processes) {
|
|
if (!proc.killed) {
|
|
proc.kill("SIGKILL");
|
|
}
|
|
}
|
|
process.exit(0);
|
|
}, 3000);
|
|
}
|
|
|
|
process.on("SIGINT", shutdown);
|
|
process.on("SIGTERM", shutdown);
|
|
|
|
function startIngress(config) {
|
|
logService("ingress", `Starting on port ${config.ingressPort}...`, c.yellow);
|
|
|
|
const ingressScript = join(projectRoot, "scripts", "ingress.mjs");
|
|
|
|
spawnService(
|
|
"ingress",
|
|
"node",
|
|
[
|
|
ingressScript,
|
|
"--port", config.ingressPort.toString(),
|
|
"--route", `/api/automation=http://localhost:${config.autoBackendPort}`,
|
|
"--route", `/api=http://localhost:${config.agentServerPort}`,
|
|
"--route", `/sockets=http://localhost:${config.agentServerPort}`,
|
|
"--route", `/server_info=http://localhost:${config.agentServerPort}`,
|
|
"--route", `/health=http://localhost:${config.agentServerPort}`,
|
|
"--route", `/ready=http://localhost:${config.agentServerPort}`,
|
|
"--route", `/alive=http://localhost:${config.agentServerPort}`,
|
|
"--default", `http://localhost:${config.vitePort}`,
|
|
],
|
|
{
|
|
cwd: projectRoot,
|
|
color: c.yellow,
|
|
}
|
|
);
|
|
}
|
|
|
|
function startVite(config) {
|
|
logService("vite", `Starting on port ${config.vitePort}...`, c.magenta);
|
|
|
|
const frontendCommand = buildNpmScriptCommand("dev:frontend");
|
|
|
|
spawnService("vite", frontendCommand.command, frontendCommand.args, {
|
|
cwd: config.canvasPath,
|
|
env: {
|
|
// Point Vite at the ingress (so client-side fetches work)
|
|
VITE_BACKEND_HOST: `127.0.0.1:${config.ingressPort}`,
|
|
VITE_BACKEND_BASE_URL: `http://127.0.0.1:${config.ingressPort}`,
|
|
VITE_WORKING_DIR: config.viteWorkingDir ?? join(config.stateDir, "workspaces"),
|
|
VITE_FRONTEND_PORT: config.vitePort.toString(),
|
|
// Session API key for frontend to authenticate with agent-server
|
|
VITE_SESSION_API_KEY: config.sessionApiKey,
|
|
// Automation API key for frontend to authenticate with automation backend
|
|
VITE_AUTOMATION_API_KEY: config.localApiKey,
|
|
// Session API key for agent-server auth (when SESSION_API_KEY is set)
|
|
...(config.sessionApiKey && { VITE_SESSION_API_KEY: config.sessionApiKey }),
|
|
},
|
|
color: c.magenta,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Seed the automation API key into agent-server's secrets store.
|
|
* This makes the key available to agents during conversations.
|
|
*
|
|
* Includes retry logic to handle slow server startup or transient failures.
|
|
*
|
|
* @param {object} config - Configuration object with agentServerPort, localApiKey, sessionApiKey
|
|
* @param {object} options - Options for retry behavior
|
|
* @param {number} options.maxRetries - Maximum number of retry attempts (default: 5)
|
|
* @param {number} options.retryDelayMs - Delay between retries in ms (default: 2000)
|
|
* @param {number} options.timeoutMs - Request timeout in ms (default: 10000)
|
|
* @returns {Promise<boolean>} True if seeding succeeded, false otherwise
|
|
*/
|
|
async function seedAutomationSecret(config, options = {}) {
|
|
const {
|
|
maxRetries = 5,
|
|
retryDelayMs = 2000,
|
|
timeoutMs = 10000,
|
|
} = options;
|
|
|
|
const secretName = "OPENHANDS_AUTOMATION_API_KEY";
|
|
const secretDescription = "API key for authenticating with the automation backend";
|
|
|
|
logService("secrets", `Seeding ${secretName} into agent-server...`, c.dim);
|
|
|
|
const url = `http://localhost:${config.agentServerPort}/api/settings/secrets`;
|
|
const body = JSON.stringify({
|
|
name: secretName,
|
|
value: config.localApiKey,
|
|
description: secretDescription,
|
|
});
|
|
|
|
const headers = {
|
|
"Content-Type": "application/json",
|
|
// Include session API key if configured
|
|
...(config.sessionApiKey && { "X-Session-API-Key": config.sessionApiKey }),
|
|
};
|
|
|
|
let lastError = null;
|
|
|
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
try {
|
|
const response = await fetch(url, {
|
|
method: "PUT",
|
|
headers,
|
|
body,
|
|
signal: AbortSignal.timeout(timeoutMs),
|
|
});
|
|
|
|
if (response.ok) {
|
|
logService("secrets", `${secretName} seeded successfully`, c.green);
|
|
return true;
|
|
}
|
|
|
|
const text = await response.text();
|
|
lastError = `HTTP ${response.status}: ${text}`;
|
|
|
|
// Don't retry on authentication errors - they won't resolve with retries
|
|
if (response.status === 401 || response.status === 403) {
|
|
logService("secrets", `Warning: Failed to seed secret (${response.status}): ${text}`, c.yellow);
|
|
return false;
|
|
}
|
|
|
|
// Retry on server errors or service unavailable
|
|
if (attempt < maxRetries) {
|
|
logService("secrets", `Retry ${attempt}/${maxRetries} after ${response.status}...`, c.dim);
|
|
await delay(retryDelayMs);
|
|
}
|
|
} catch (err) {
|
|
lastError = err.message;
|
|
|
|
// Connection errors likely mean server isn't ready - wait and retry
|
|
if (attempt < maxRetries) {
|
|
logService("secrets", `Retry ${attempt}/${maxRetries}: ${err.message}`, c.dim);
|
|
await delay(retryDelayMs);
|
|
}
|
|
}
|
|
}
|
|
|
|
logService("secrets", `Warning: Failed to seed secret after ${maxRetries} attempts: ${lastError}`, c.yellow);
|
|
return false;
|
|
}
|
|
|
|
function printBanner(config) {
|
|
console.log("");
|
|
console.log(
|
|
`${c.green}${c.bold}╔══════════════════════════════════════════════════════════════╗${c.reset}`
|
|
);
|
|
console.log(
|
|
`${c.green}${c.bold}║${c.reset} ${c.bold}Agent Canvas + Automation Stack${c.reset} ${c.green}${c.bold}║${c.reset}`
|
|
);
|
|
console.log(
|
|
`${c.green}${c.bold}╠══════════════════════════════════════════════════════════════╣${c.reset}`
|
|
);
|
|
console.log(
|
|
`${c.green}${c.bold}║${c.reset} ${c.green}${c.bold}║${c.reset}`
|
|
);
|
|
console.log(
|
|
`${c.green}${c.bold}║${c.reset} Main UI: ${c.cyan}http://localhost:${config.ingressPort}/${c.reset}`.padEnd(75) + `${c.green}${c.bold}║${c.reset}`
|
|
);
|
|
console.log(
|
|
`${c.green}${c.bold}║${c.reset} API Docs: ${c.cyan}http://localhost:${config.ingressPort}/api/automation/docs${c.reset}`.padEnd(75) + `${c.green}${c.bold}║${c.reset}`
|
|
);
|
|
console.log(
|
|
`${c.green}${c.bold}║${c.reset} ${c.green}${c.bold}║${c.reset}`
|
|
);
|
|
console.log(
|
|
`${c.green}${c.bold}╚══════════════════════════════════════════════════════════════╝${c.reset}`
|
|
);
|
|
console.log("");
|
|
console.log(`${c.dim}State directory: ${config.stateDir}${c.reset}`);
|
|
console.log(`${c.dim}Press Ctrl+C to stop${c.reset}`);
|
|
console.log("");
|
|
}
|
|
|
|
async function main(options = {}) {
|
|
const {
|
|
bannerTitle = "Agent Canvas + Automation Development Stack",
|
|
startAgentServer: startAgentServerOverride,
|
|
extraPrereqs,
|
|
viteWorkingDir,
|
|
staticMode: staticModeOverride,
|
|
staticDir: staticDirOverride,
|
|
} = options;
|
|
|
|
const args = parseArgs();
|
|
|
|
// Allow options to override CLI args (for bin/agent-canvas.mjs)
|
|
const useStaticMode = staticModeOverride ?? args.static;
|
|
const staticDir = staticDirOverride ?? args.staticDir ?? join(projectRoot, "build", "client");
|
|
|
|
const modeLabel = useStaticMode ? "(Static)" : "";
|
|
const titleWithMode = modeLabel ? `${bannerTitle} ${modeLabel}` : bannerTitle;
|
|
|
|
console.log("");
|
|
console.log(`${c.cyan}${c.bold}${titleWithMode}${c.reset}`);
|
|
console.log("");
|
|
|
|
// Setup phase
|
|
// (uvx is still required even in docker mode because the automation
|
|
// backend runs via uvx; only the agent-server is dockerized.)
|
|
checkPrerequisites();
|
|
|
|
// In static mode, verify build exists
|
|
if (useStaticMode && !existsSync(staticDir)) {
|
|
logError(`Static directory not found: ${staticDir}`);
|
|
logError(`Run 'npm run build' first to create the static files.`);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Build config with dynamic port allocation
|
|
const config = await buildConfig(args);
|
|
if (viteWorkingDir) config.viteWorkingDir = viteWorkingDir;
|
|
ensureDirectories(config);
|
|
if (typeof extraPrereqs === "function") {
|
|
extraPrereqs(config);
|
|
}
|
|
|
|
// Start services phase
|
|
logStep("2/2", "Starting services...");
|
|
|
|
// 1. Start agent-server first (other services depend on it)
|
|
const agentServerStarter = startAgentServerOverride ?? startAgentServer;
|
|
agentServerStarter(config);
|
|
|
|
// Wait for agent-server to be ready (60s timeout for slow systems)
|
|
const agentServerReady = await waitForService(
|
|
"agent-server",
|
|
`http://localhost:${config.agentServerPort}/server_info`,
|
|
60000 // 60 second timeout for initial startup
|
|
);
|
|
|
|
// 2. Seed automation API key into agent-server secrets
|
|
// This makes the key available to agents during conversations
|
|
// Note: seedAutomationSecret has its own retry logic if server is still warming up
|
|
if (agentServerReady) {
|
|
await seedAutomationSecret(config);
|
|
} else {
|
|
logService("secrets", "Skipping secret seeding - agent-server not ready", c.yellow);
|
|
}
|
|
|
|
// 3. Start automation backend
|
|
startAutomationBackend(config);
|
|
|
|
// 4. Start frontend server (Vite dev server OR static server)
|
|
if (useStaticMode) {
|
|
startStaticFrontend(config, staticDir);
|
|
} else {
|
|
startVite(config);
|
|
}
|
|
|
|
// 5. Wait for services to be ready
|
|
await delay(2000);
|
|
|
|
// 6. Start ingress proxy (routes traffic to all backends)
|
|
startIngress(config);
|
|
|
|
// Wait for ingress to start
|
|
await delay(1000);
|
|
|
|
printBanner(config);
|
|
}
|
|
|
|
function startStaticFrontend(config, staticDir) {
|
|
logService("static", `Starting on port ${config.vitePort}...`, c.magenta);
|
|
logService("static", `Serving from: ${staticDir}`, c.dim);
|
|
|
|
const staticServerScript = join(projectRoot, "scripts", "static-server.mjs");
|
|
spawnService(
|
|
"static",
|
|
"node",
|
|
[
|
|
staticServerScript,
|
|
"--dir", staticDir,
|
|
"--host", "0.0.0.0",
|
|
"--port", String(config.vitePort),
|
|
// Proxy routes to backends (same as ingress but for direct access to vitePort)
|
|
"--route", `/api/automation=http://localhost:${config.autoBackendPort}`,
|
|
"--route", `/api=http://localhost:${config.agentServerPort}`,
|
|
"--route", `/sockets=http://localhost:${config.agentServerPort}`,
|
|
"--route", `/server_info=http://localhost:${config.agentServerPort}`,
|
|
"--route", `/health=http://localhost:${config.agentServerPort}`,
|
|
"--route", `/ready=http://localhost:${config.agentServerPort}`,
|
|
"--route", `/alive=http://localhost:${config.agentServerPort}`,
|
|
],
|
|
{
|
|
cwd: config.canvasPath,
|
|
color: c.magenta,
|
|
}
|
|
);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Exports for testing
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
export {
|
|
buildAutomationCommand,
|
|
buildConfig,
|
|
generateRandomApiKey,
|
|
main,
|
|
spawnService,
|
|
commandExists,
|
|
logService,
|
|
logStep,
|
|
logSuccess,
|
|
logError,
|
|
c,
|
|
DEFAULT_AUTOMATION_REPO,
|
|
DEFAULT_AUTOMATION_PACKAGE,
|
|
DEFAULT_AUTOMATION_VERSION,
|
|
DEFAULT_BACKEND_PORT,
|
|
DEFAULT_AUTOMATION_PORT,
|
|
};
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Main entry point (only when run directly, not when imported)
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
// Check if this module is the main entry point
|
|
const isMainModule =
|
|
process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
|
|
if (isMainModule) {
|
|
main().catch((err) => {
|
|
logError(`Fatal error: ${err.message}`);
|
|
if (err.stack) {
|
|
console.error(c.dim + err.stack + c.reset);
|
|
}
|
|
process.exit(1);
|
|
});
|
|
}
|