diff --git a/AGENTS.md b/AGENTS.md index aa5ba339b8..05462fd0ea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -160,6 +160,7 @@ you are running inside of — NOT the automation backend. - **Test specs**: - `mock-llm-conversation.spec.ts` — Creates LLM profile via UI, runs a conversation with a terminal tool call, verifies bash execution and agent reply. - `mock-llm-automation.spec.ts` — Full automation lifecycle: registers a trajectory (7 responses total — 4 for the main conversation + 3 for the automation run's spawned conversation) where the LLM creates a cron automation and dispatches a run via terminal `curl` commands to the real automation backend. Verifies: automation created with correct schedule, run reaches COMPLETED status with a conversation_id, automation appears on the `/automations` list page, detail page shows COMPLETED badge (`data-testid="run-status-icon-completed"`), and clicking the run's conversation link navigates to the correct `/conversations/{id}` page. + - `mock-llm-partial-stack.spec.ts` — Partial stack mode tests. Unlike other specs, these spawn their own `bin/agent-canvas.mjs` child processes instead of relying on the config's webServer entries. Three describe blocks: (1) `--frontend-only` verifies static frontend is served (200 on `/`), backend routes return 503 (`/server_info`, `/api/settings`, `/api/automation/v1`), and the browser shows the manage-backends modal; (2) `--backend-only` verifies `/server_info` returns 200, `/api/settings` is reachable, automation endpoint works, and root/asset requests return 503; (3) port conflict verifies the process exits non-zero with a clear error message when the ingress port is occupied, then starts successfully on a free port. Each test uses isolated state dirs and high port numbers (18310+ range) to avoid collisions with the main full-stack instance. - Tests run serially (`workers: 1`, `mode: "serial"` per describe block). Files are discovered alphabetically so automation tests run before conversation tests; each spec is self-contained (automation test configures its own LLM profile via the settings API). The `afterEach` hook resets the mock LLM to its default trajectory so subsequent specs start fresh even when a preceding test fails. - CI workflow: `.github/workflows/mock-llm-e2e.yml` runs on PRs with the `e2e-tests` label or on manual dispatch. It builds the frontend, starts the mock LLM server, runs the tests, and posts a PR comment with results. - The custom `DoneMarkerReporter` writes `.mock-llm-markers/.tests-done` after all tests complete (before webServer teardown) so the CI wrapper can detect completion and kill the lingering teardown process. diff --git a/README.md b/README.md index e9467b66b7..7050951a2e 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,13 @@ npm install -g @openhands/agent-canvas agent-canvas ``` +The `agent-canvas` command starts the full local stack by default. You can also split it when you want to run pieces separately: + +```sh +agent-canvas --frontend-only # static frontend + ingress only +agent-canvas --backend-only # agent server + automation backend + ingress only +``` + ### Option 2: With a Docker Sandbox **Prerequisites**: diff --git a/__tests__/bin/agent-canvas.test.ts b/__tests__/bin/agent-canvas.test.ts index 15d68bf1c4..68ef7e9cfd 100644 --- a/__tests__/bin/agent-canvas.test.ts +++ b/__tests__/bin/agent-canvas.test.ts @@ -31,6 +31,8 @@ describe("agent-canvas CLI", () => { expect(stderr).toBe(""); expect(stdout).toContain("@openhands/agent-canvas"); expect(stdout).toContain("USAGE:"); + expect(stdout).toContain("--frontend-only"); + expect(stdout).toContain("--backend-only"); expect(stdout).toContain("--help"); }); }); diff --git a/__tests__/scripts/dev-with-automation.test.ts b/__tests__/scripts/dev-with-automation.test.ts index 43a4c15e22..211f9a3e9e 100644 --- a/__tests__/scripts/dev-with-automation.test.ts +++ b/__tests__/scripts/dev-with-automation.test.ts @@ -18,6 +18,9 @@ import { buildAgentServerAutomationEnv, buildAutomationCommand, buildConfig, + buildRouteArgs, + getFrontendBackend, + getLocalServiceRoutes, DEFAULT_AUTOMATION_REPO, DEFAULT_AUTOMATION_PACKAGE, DEFAULT_AUTOMATION_VERSION, @@ -328,6 +331,89 @@ describe("buildConfig", () => { }); }); +describe("stack mode routing", () => { + const keyDirs: string[] = []; + + afterEach(() => { + while (keyDirs.length > 0) { + const dir = keyDirs.pop(); + if (dir) rmSync(dir, { recursive: true, force: true }); + } + resetPersistedSessionApiKeyCache(); + }); + + function envWithIsolatedKeyPath( + extra: Record = {}, + ): Record { + const dir = mkdtempSync(path.join(tmpdir(), "stack-mode-key-")); + keyDirs.push(dir); + return { + OH_SESSION_API_KEY_PATH: path.join(dir, "session-api-key.txt"), + PORT: "19802", + OH_CANVAS_SAFE_BACKEND_PORT: "19800", + OH_CANVAS_SAFE_AUTOMATION_PORT: "19801", + OH_CANVAS_SAFE_VITE_PORT: "19803", + ...extra, + }; + } + + it("uses only a frontend default route in frontend-only mode", async () => { + const config = await buildConfig( + { frontendOnly: true }, + envWithIsolatedKeyPath(), + ); + + expect(config.launchFrontend).toBe(true); + expect(config.launchAgentServer).toBe(false); + expect(config.launchAutomation).toBe(false); + expect(getLocalServiceRoutes(config)).toEqual([]); + expect(getFrontendBackend(config)).toBe( + `http://localhost:${config.vitePort}`, + ); + expect(buildRouteArgs(getLocalServiceRoutes(config))).toEqual([]); + }); + + it("routes only agent-server and automation in backend-only mode", async () => { + const config = await buildConfig( + { backendOnly: true }, + envWithIsolatedKeyPath(), + ); + + expect(config.launchFrontend).toBe(false); + expect(config.launchAgentServer).toBe(true); + expect(config.launchAutomation).toBe(true); + expect(getFrontendBackend(config)).toBeNull(); + + const routes = getLocalServiceRoutes(config); + expect(routes).toContainEqual([ + "/api/automation", + `http://localhost:${config.autoBackendPort}`, + ]); + expect(routes).toContainEqual([ + "/api", + `http://localhost:${config.agentServerPort}`, + ]); + + const routeArgs = buildRouteArgs(routes); + expect(routeArgs).toContain( + `/api/automation=http://localhost:${config.autoBackendPort}`, + ); + expect(routeArgs).toContain( + `/server_info=http://localhost:${config.agentServerPort}`, + ); + expect(routeArgs).not.toContain("--default"); + }); + + it("rejects mutually exclusive partial-stack modes", async () => { + await expect( + buildConfig( + { frontendOnly: true, backendOnly: true }, + envWithIsolatedKeyPath(), + ), + ).rejects.toThrow(/cannot be used together/); + }); +}); + describe("default constants", () => { it("has expected default automation repo", () => { expect(DEFAULT_AUTOMATION_REPO).toBe( @@ -377,6 +463,8 @@ describe("dev-with-automation CLI", () => { expect(output).toContain("--automation-repo"); expect(output).toContain("--static"); expect(output).toContain("--dynamic"); + expect(output).toContain("--frontend-only"); + expect(output).toContain("--backend-only"); expect(output).toContain("OH_AUTOMATION_GIT_REF"); expect(output).toContain("OH_AGENT_SERVER_LOCAL_PATH"); expect(output).toContain("OPENHANDS_AUTOMATION_API_KEY"); diff --git a/bin/agent-canvas.mjs b/bin/agent-canvas.mjs index b9aab7d264..35533afd5c 100755 --- a/bin/agent-canvas.mjs +++ b/bin/agent-canvas.mjs @@ -2,7 +2,7 @@ /** * CLI entry point for @openhands/agent-canvas * - * Runs the full Agent Canvas stack locally: + * Runs the full Agent Canvas stack locally by default: * - Agent-server via uvx * - Automation backend via uvx * - Pre-built static frontend @@ -50,6 +50,9 @@ Override versions via environment variables: process.exit(0); } const isPublic = args.includes("--public"); +const isFrontendOnly = args.includes("--frontend-only"); +const isBackendOnly = args.includes("--backend-only"); + if (args.includes("-h") || args.includes("--help")) { console.log(` @openhands/agent-canvas - Run the Agent Canvas UI with agent-server @@ -71,6 +74,8 @@ AUTH MODES: OPTIONS: -p, --port Ingress port (default: 8000) --public Enable public mode (see above) + --frontend-only Start only the static frontend behind ingress + --backend-only Start only agent-server + automation behind ingress -v, --version Show version number --info Show version and default stack configuration -h, --help Show this help message @@ -100,6 +105,12 @@ EXAMPLES: # Use a specific port npx @openhands/agent-canvas --port 3000 + # Start only the static frontend behind ingress + npx @openhands/agent-canvas --frontend-only + + # Start only the agent-server and automation backend behind ingress + npx @openhands/agent-canvas --backend-only + # Show default stack versions and ports npx @openhands/agent-canvas --info @@ -109,8 +120,20 @@ EXAMPLES: process.exit(0); } -// Check build exists before doing anything else -if (!existsSync(BUILD_DIR)) { +if (isFrontendOnly && isBackendOnly) { + console.error( + "Error: --frontend-only and --backend-only cannot be used together", + ); + process.exit(1); +} + +if (isFrontendOnly && isPublic) { + console.error("Error: --public cannot be used with --frontend-only"); + process.exit(1); +} + +// Check build exists before doing anything else unless no frontend will run. +if (!isBackendOnly && !existsSync(BUILD_DIR)) { console.error(` Error: No build found at ${BUILD_DIR} diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 554a9a6784..7f60c0c43b 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -14,6 +14,15 @@ For a static frontend build (better for slow networks, remote access, tunnels): npm run dev:static ``` +The published `agent-canvas` binary also supports partial-stack modes when you want to run the frontend and backend processes separately: + +```sh +agent-canvas --frontend-only +agent-canvas --backend-only +``` + +Both modes still start the ingress proxy; the proxy only routes to the services started by that mode. + The dev stack uses `uvx` to run a temporary `agent-server` installation on `127.0.0.1:18000` and points the frontend at it. It isolates conversation persistence by setting separate `OH_CONVERSATIONS_PATH`, diff --git a/scripts/dev-with-automation.mjs b/scripts/dev-with-automation.mjs index 99ad8b64eb..750e3953d5 100644 --- a/scripts/dev-with-automation.mjs +++ b/scripts/dev-with-automation.mjs @@ -135,6 +135,8 @@ function parseArgs() { staticDir: null, skipBuild: false, public: false, + frontendOnly: false, + backendOnly: false, }; for (let i = 0; i < args.length; i++) { @@ -168,6 +170,12 @@ function parseArgs() { case "--public": config.public = true; break; + case "--frontend-only": + config.frontendOnly = true; + break; + case "--backend-only": + config.backendOnly = true; + break; case "-h": case "--help": showHelp(); @@ -196,6 +204,8 @@ OPTIONS: --static-dir Static build directory (default: build/) --skip-build Reuse build/ when the launcher builds static assets --dynamic Force Vite dev server when a wrapper defaults static + --frontend-only Start only the frontend behind ingress + --backend-only Start only agent-server + automation behind ingress -v, --verbose Show detailed output -h, --help Show this help @@ -285,8 +295,23 @@ async function buildConfig(args, env = process.env) { env.OH_AUTOMATION_REPO = args.automationRepo; } + const frontendOnly = Boolean(args.frontendOnly); + const backendOnly = Boolean(args.backendOnly); + if (frontendOnly && backendOnly) { + throw new Error( + "--frontend-only and --backend-only cannot be used together", + ); + } + + const launchFrontend = !backendOnly; + const launchAgentServer = !frontendOnly; + const launchAutomation = !frontendOnly; const isPublic = args.public; + if (isPublic && frontendOnly) { + throw new Error("--public cannot be used with --frontend-only"); + } + // In public mode, LOCAL_BACKEND_API_KEY is required — without it the // auth screen has nothing to validate against. if (isPublic && !env.LOCAL_BACKEND_API_KEY) { @@ -308,14 +333,20 @@ async function buildConfig(args, env = process.env) { parseInt(env.OH_CANVAS_SAFE_AUTOMATION_PORT, 10) || DEFAULT_AUTOMATION_PORT; const preferredVitePort = parseInt(env.OH_CANVAS_SAFE_VITE_PORT, 10) || 3001; - // Fail fast if any preferred port is already in use. + // Fail fast if any preferred port for a service in this mode is already in use. + const requiredPorts = [{ name: "ingress", port: preferredIngressPort }]; + if (launchAgentServer) { + requiredPorts.push({ name: "agent-server", port: preferredBackendPort }); + } + if (launchAutomation) { + requiredPorts.push({ name: "automation", port: preferredAutomationPort }); + } + if (launchFrontend) { + requiredPorts.push({ name: "frontend", port: preferredVitePort }); + } + logStep("ports", "Checking ports..."); - await assertPortsFree([ - { name: "ingress", port: preferredIngressPort }, - { name: "agent-server", port: preferredBackendPort }, - { name: "automation", port: preferredAutomationPort }, - { name: "vite", port: preferredVitePort }, - ]); + await assertPortsFree(requiredPorts); const vscodePort = preferredBackendPort + 1000; @@ -370,6 +401,12 @@ async function buildConfig(args, env = process.env) { // Public mode — the session key should NOT be baked into the frontend isPublic, + frontendOnly, + backendOnly, + launchFrontend, + launchAgentServer, + launchAutomation, + verbose: args.verbose, }; } @@ -387,20 +424,28 @@ function commandExists(cmd) { return result.status === 0; } -function checkPrerequisites({ checkFrontendDependencies = true } = {}) { +function checkPrerequisites({ + checkUvx = true, + checkNpm = true, + checkFrontendDependencies = true, +} = {}) { logStep("1/2", "Checking prerequisites..."); - if (!commandExists("uvx")) { - console.error(formatMissingUvxGuidance(projectRoot)); - process.exit(1); + if (checkUvx) { + if (!commandExists("uvx")) { + console.error(formatMissingUvxGuidance(projectRoot)); + process.exit(1); + } + logSuccess("uvx found"); } - logSuccess("uvx found"); - if (!commandExists("npm")) { - logError("npm is required but not found"); - process.exit(1); + if (checkNpm) { + if (!commandExists("npm")) { + logError("npm is required but not found"); + process.exit(1); + } + logSuccess("npm found"); } - logSuccess("npm found"); if (checkFrontendDependencies) { try { @@ -416,14 +461,28 @@ function checkPrerequisites({ checkFrontendDependencies = true } = {}) { function ensureDirectories(config) { const dirs = [ config.stateDir, - join(config.stateDir, "dev_conversations"), - join(config.stateDir, "workspaces"), - join(config.stateDir, "bash_events"), - join(config.stateDir, "storage"), - // Automation DB directory — matches docker/entrypoint.sh mkdir -p behaviour. - dirname(join(dirname(config.stateDir), SHARED_DEFAULTS.paths.automationDb)), + // Both agent-server and automation use storage; create it unconditionally + // whenever either backend service runs (i.e. not frontend-only). + ...(!config.frontendOnly ? [join(config.stateDir, "storage")] : []), ]; + if (config.launchAgentServer) { + dirs.push( + join(config.stateDir, "dev_conversations"), + join(config.stateDir, "workspaces"), + join(config.stateDir, "bash_events"), + ); + } + + if (config.launchAutomation) { + dirs.push( + // Automation DB directory — matches docker/entrypoint.sh mkdir -p behaviour. + dirname( + join(dirname(config.stateDir), SHARED_DEFAULTS.paths.automationDb), + ), + ); + } + for (const dir of dirs) { mkdirSync(dir, { recursive: true }); } @@ -521,6 +580,68 @@ async function waitForService(name, url, timeoutMs = 30000) { // Service Starters // ═══════════════════════════════════════════════════════════════════════════ +const AUTOMATION_ROUTE_PREFIX = "/api/automation"; +const AGENT_SERVER_ROUTE_PREFIXES = [ + "/api", + "/sockets", + "/server_info", + "/health", + "/ready", + "/alive", + "/docs", + "/redoc", + "/openapi.json", +]; + +function getLocalServiceRoutes(config) { + const routes = []; + + if (config.launchAutomation) { + routes.push([ + AUTOMATION_ROUTE_PREFIX, + `http://localhost:${config.autoBackendPort}`, + ]); + } + + if (config.launchAgentServer) { + for (const prefix of AGENT_SERVER_ROUTE_PREFIXES) { + routes.push([prefix, `http://localhost:${config.agentServerPort}`]); + } + } + + return routes; +} + +function buildRouteArgs(routes) { + return routes.flatMap(([prefix, url]) => ["--route", `${prefix}=${url}`]); +} + +/** + * Build --reject-prefix args for the static server. + * In frontend-only mode, API paths that have no backend should return 503 + * instead of being SPA-fallbacked to index.html. + */ +function getRejectPrefixes(config) { + const prefixes = []; + if (!config.launchAutomation) { + prefixes.push(AUTOMATION_ROUTE_PREFIX); + } + if (!config.launchAgentServer) { + for (const prefix of AGENT_SERVER_ROUTE_PREFIXES) { + prefixes.push(prefix); + } + } + return prefixes; +} + +function buildRejectPrefixArgs(prefixes) { + return prefixes.flatMap((prefix) => ["--reject-prefix", prefix]); +} + +function getFrontendBackend(config) { + return config.launchFrontend ? `http://localhost:${config.vitePort}` : null; +} + function buildAgentServerAutomationEnv(config) { return { // Make the session API key available to terminal commands spawned by the @@ -702,6 +823,7 @@ function startIngress(config) { logService("ingress", `Starting on port ${config.ingressPort}...`, c.yellow); const ingressScript = join(projectRoot, "scripts", "ingress.mjs"); + const frontendBackend = getFrontendBackend(config); spawnService( "ingress", @@ -710,28 +832,8 @@ function startIngress(config) { 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}`, - "--route", - `/docs=http://localhost:${config.agentServerPort}`, - "--route", - `/redoc=http://localhost:${config.agentServerPort}`, - "--route", - `/openapi.json=http://localhost:${config.agentServerPort}`, - "--default", - `http://localhost:${config.vitePort}`, + ...buildRouteArgs(getLocalServiceRoutes(config)), + ...(frontendBackend ? ["--default", frontendBackend] : []), ], { cwd: projectRoot, @@ -752,12 +854,14 @@ export function buildAutomationRuntimeServicesInfo(config) { agentHostAlias: config.agentHostAlias ?? "localhost", agentServerPort: config.agentServerPort, ingressPort: config.ingressPort, - frontendPort: config.vitePort, + frontendPort: config.launchFrontend ? config.vitePort : undefined, // The same port hosts Vite in dynamic mode and a static-file server // in static mode. The launcher records this on the config so the // description shown to the agent matches reality. frontendKind: config.frontendKind ?? "vite", - automation: { port: config.autoBackendPort }, + automation: config.launchAutomation + ? { port: config.autoBackendPort } + : undefined, }); } @@ -765,7 +869,9 @@ function startVite(config) { logService("vite", `Starting on port ${config.vitePort}...`, c.magenta); const frontendCommand = buildNpmScriptCommand("dev:frontend"); - const runtimeServicesInfo = buildAutomationRuntimeServicesInfo(config); + const runtimeServicesInfo = config.launchAgentServer + ? buildAutomationRuntimeServicesInfo(config) + : null; const viteEnv = { // Point Vite at the ingress (so client-side fetches work) @@ -774,18 +880,21 @@ function startVite(config) { VITE_WORKING_DIR: config.viteWorkingDir ?? join(config.stateDir, "workspaces"), VITE_FRONTEND_PORT: config.vitePort.toString(), + }; + + if (runtimeServicesInfo) { // 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), - }; + viteEnv.VITE_RUNTIME_SERVICES_INFO = JSON.stringify(runtimeServicesInfo); + } // In local mode, bake the session key into the frontend so the user // never has to paste it. In public mode, omit the key and set // VITE_AUTH_REQUIRED so the frontend shows the API key entry screen // immediately (no network round-trip needed). - if (config.isPublic) { + if (config.launchAgentServer && config.isPublic) { viteEnv.VITE_AUTH_REQUIRED = "true"; - } else { + } else if (config.launchAgentServer) { viteEnv.VITE_SESSION_API_KEY = config.sessionApiKey; } @@ -894,12 +1003,32 @@ async function seedAutomationSecret(config, options = {}) { } function printBanner(config) { + const stackName = config.frontendOnly + ? "Agent Canvas Frontend Stack" + : config.backendOnly + ? "Agent Canvas Backend Stack" + : "Agent Canvas + Automation Stack"; + + // padEnd counts invisible ANSI escape bytes as visible characters, so we + // compute the visible length separately and pad with spaces accordingly. + const ansiRe = /\x1b\[[0-9;]*m/g; + const ansiPadEnd = (str, targetVisible) => { + const visible = str.replace(ansiRe, "").length; + return str + " ".repeat(Math.max(0, targetVisible - visible)); + }; + // The box has 62-char inner width; each content line needs 63 visible chars + // before the trailing border (1 leading ║ + 62 inner). + const BOX_INNER = 63; + 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}`, + ansiPadEnd( + `${c.green}${c.bold}║${c.reset} ${c.bold}${stackName}${c.reset}`, + BOX_INNER, + ) + `${c.green}${c.bold}║${c.reset}`, ); console.log( `${c.green}${c.bold}╠══════════════════════════════════════════════════════════════╣${c.reset}`, @@ -908,15 +1037,27 @@ function printBanner(config) { `${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, + ansiPadEnd( + `${c.green}${c.bold}║${c.reset} Ingress: ${c.cyan}http://localhost:${config.ingressPort}/${c.reset}`, + BOX_INNER, ) + `${c.green}${c.bold}║${c.reset}`, ); + if (config.launchFrontend) { + console.log( + ansiPadEnd( + `${c.green}${c.bold}║${c.reset} Main UI: ${c.cyan}http://localhost:${config.ingressPort}/${c.reset}`, + BOX_INNER, + ) + `${c.green}${c.bold}║${c.reset}`, + ); + } + if (config.launchAutomation) { + console.log( + ansiPadEnd( + `${c.green}${c.bold}║${c.reset} API Docs: ${c.cyan}http://localhost:${config.ingressPort}/api/automation/docs${c.reset}`, + BOX_INNER, + ) + `${c.green}${c.bold}║${c.reset}`, + ); + } console.log( `${c.green}${c.bold}║${c.reset} ${c.green}${c.bold}║${c.reset}`, ); @@ -974,7 +1115,7 @@ async function main(options = {}) { const staticDir = staticDirOverride ?? args.staticDir ?? join(projectRoot, "build"); - const modeLabel = useStaticMode ? "(Static)" : ""; + const modeLabel = useStaticMode && !args.backendOnly ? "(Static)" : ""; const titleWithMode = modeLabel ? `${bannerTitle} ${modeLabel}` : bannerTitle; console.log(""); @@ -983,15 +1124,22 @@ async function main(options = {}) { // Setup phase checkPrerequisites({ + checkUvx: !args.frontendOnly, + // Static-mode + backend-only has no frontend to build, so npm is not + // required — unless the caller provides a custom buildStaticFrontend hook. + checkNpm: + (!useStaticMode && !args.backendOnly) || + typeof buildStaticFrontend === "function", checkFrontendDependencies: - !useStaticMode || typeof buildStaticFrontend === "function", + (!useStaticMode && !args.backendOnly) || + typeof buildStaticFrontend === "function", }); // Fail fast on an obviously bad OH_AGENT_SERVER_LOCAL_PATH so we don't waste // time allocating ports / generating keys / launching uvx with a path that // would only produce a cryptic build error. Mirrors dev-safe.mjs and // dev-extra-backend.mjs. - if (process.env.OH_AGENT_SERVER_LOCAL_PATH) { + if (!args.frontendOnly && process.env.OH_AGENT_SERVER_LOCAL_PATH) { try { validateLocalAgentServerPath(process.env.OH_AGENT_SERVER_LOCAL_PATH); } catch (error) { @@ -1023,12 +1171,16 @@ async function main(options = {}) { extraPrereqs(config); } - if (useStaticMode && typeof buildStaticFrontend === "function") { + if ( + config.launchFrontend && + useStaticMode && + typeof buildStaticFrontend === "function" + ) { buildStaticFrontend(config, args); } // In static mode, verify build exists after any launcher-managed build. - if (useStaticMode && !existsSync(staticDir)) { + if (config.launchFrontend && useStaticMode && !existsSync(staticDir)) { logError(`Static directory not found: ${staticDir}`); logError(`Run 'npm run build' first to create the static files.`); process.exit(1); @@ -1037,23 +1189,27 @@ async function main(options = {}) { // Start services phase logStep("2/2", "Starting services..."); - // 1. Start agent-server first (other services depend on it) - const agentServerStarter = startAgentServerOverride ?? startAgentServer; - agentServerStarter(config); + let agentServerReady = false; - // 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 - ); + // 1. Start agent-server first (automation depends on it) + if (config.launchAgentServer) { + const agentServerStarter = startAgentServerOverride ?? startAgentServer; + agentServerStarter(config); + + // Wait for agent-server to be ready (60s timeout for slow systems) + 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) { + if (config.launchAutomation && agentServerReady) { await seedAutomationSecret(config); - } else { + } else if (config.launchAutomation) { logService( "secrets", "Skipping secret seeding - agent-server not ready", @@ -1062,19 +1218,23 @@ async function main(options = {}) { } // 3. Start automation backend - startAutomationBackend(config); + if (config.launchAutomation) { + startAutomationBackend(config); + } // 4. Start frontend server (Vite dev server OR static server) - if (useStaticMode) { - startStaticFrontend(config, staticDir); - } else { - startVite(config); + if (config.launchFrontend) { + 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) + // 6. Start ingress proxy (routes traffic only to running services) startIngress(config); // Wait for ingress to start @@ -1102,31 +1262,17 @@ function startStaticFrontend(config, staticDir) { // In local mode, inject the API key so the pre-built frontend can // authenticate transparently. In public mode, pass --auth-required // so the frontend shows the API key entry screen instead. - ...(!config.isPublic && config.sessionApiKey + ...(config.launchAgentServer && !config.isPublic && config.sessionApiKey ? ["--session-api-key", config.sessionApiKey] : []), - ...(config.isPublic ? ["--auth-required"] : []), - // 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}`, - "--route", - `/docs=http://localhost:${config.agentServerPort}`, - "--route", - `/redoc=http://localhost:${config.agentServerPort}`, - "--route", - `/openapi.json=http://localhost:${config.agentServerPort}`, + ...(config.launchAgentServer && config.isPublic + ? ["--auth-required"] + : []), + // Proxy routes only to services that this launch mode started. + ...buildRouteArgs(getLocalServiceRoutes(config)), + // Reject known API prefixes that have no backend — returns 503 + // instead of SPA-fallbacking to index.html. + ...buildRejectPrefixArgs(getRejectPrefixes(config)), ], { cwd: config.canvasPath, @@ -1143,6 +1289,9 @@ export { buildAgentServerAutomationEnv, buildAutomationCommand, buildConfig, + buildRouteArgs, + getFrontendBackend, + getLocalServiceRoutes, main, registerShutdownHook, spawnService, diff --git a/scripts/static-server.mjs b/scripts/static-server.mjs index da96d0c4a1..556326f808 100644 --- a/scripts/static-server.mjs +++ b/scripts/static-server.mjs @@ -72,6 +72,7 @@ export function parseArgs(argv = process.argv.slice(2)) { host: "0.0.0.0", dir: "build", routes: {}, + rejectPrefixes: [], sessionApiKey: null, authRequired: false, }; @@ -112,6 +113,16 @@ export function parseArgs(argv = process.argv.slice(2)) { case "--auth-required": config.authRequired = true; break; + case "--reject-prefix": { + const prefix = argv[++i]; + if (!prefix || !prefix.startsWith("/")) { + throw new Error( + `--reject-prefix value must start with '/': ${prefix ?? "(empty)"}`, + ); + } + config.rejectPrefixes.push(prefix); + break; + } case "-h": case "--help": showHelp(); @@ -156,11 +167,18 @@ OPTIONS: --auth-required Inject authRequired flag into index.html so the pre-built frontend shows the API key entry screen (public mode) without VITE_AUTH_REQUIRED baked in. + --reject-prefix Return 503 for requests matching + instead of SPA-fallbacking to index.html; + may be repeated. Useful in --frontend-only + mode to cleanly reject API paths. -h, --help Show this help ROUTING: • Routes are matched by longest prefix first (most-specific wins). - • Anything that does not match a route is served from --dir. + • Reject prefixes are checked before SPA fallback — matching requests + get 503 immediately. + • Anything that does not match a route or reject prefix is served + from --dir. • Unknown paths fall back to index.html (SPA mode), unless they look like an asset request (have a known file extension), in which case a 404 is returned. @@ -429,7 +447,13 @@ async function serveFile(req, res, filePath, urlPath) { return true; } -async function handleStatic(req, res, dirAbs, injectionOpts = {}) { +async function handleStatic( + req, + res, + dirAbs, + injectionOpts = {}, + rejectPrefixes = [], +) { const rawPath = req.url.split("?")[0]; let urlPath; try { @@ -464,6 +488,23 @@ async function handleStatic(req, res, dirAbs, injectionOpts = {}) { if (await serveFile(req, res, filePath, urlPath)) return; + // Reject prefixes: return 503 for known API paths that have no backend + // configured (e.g. in --frontend-only mode). Checked before SPA fallback + // so these paths never silently serve index.html. + if (rejectPrefixes.length > 0) { + for (const prefix of rejectPrefixes) { + if ( + urlPath === prefix || + urlPath.startsWith(prefix + "/") || + urlPath.startsWith(prefix + "?") + ) { + res.writeHead(503, { "Content-Type": "text/plain; charset=utf-8" }); + res.end("Service Unavailable (no backend configured for this route)"); + return; + } + } + } + // SPA fallback: only for non-asset requests, and not for non-GET/HEAD. if ( (req.method === "GET" || req.method === "HEAD") && @@ -491,6 +532,7 @@ export function startStaticServer(config) { sessionApiKey: config.sessionApiKey || null, authRequired: config.authRequired || false, }; + const rejectPrefixes = config.rejectPrefixes ?? []; const server = createServer((req, res) => { const backend = route(req.url); @@ -498,7 +540,7 @@ export function startStaticServer(config) { proxyRequest(req, res, backend); return; } - handleStatic(req, res, dirAbs, injectionOpts).catch((err) => { + handleStatic(req, res, dirAbs, injectionOpts, rejectPrefixes).catch((err) => { console.error(`Static handler error for ${req.url}:`, err); if (!res.headersSent) { res.writeHead(500); @@ -529,6 +571,11 @@ export function startStaticServer(config) { for (const [prefix, backend] of sortedRoutes) { console.log(` ${prefix} -> ${backend}`); } + if (rejectPrefixes.length > 0) { + for (const prefix of rejectPrefixes) { + console.log(` ${prefix} -> 503 (rejected)`); + } + } console.log(" * (default) -> static files + SPA fallback"); console.log(""); resolveListen(server); diff --git a/src/api/agent-server-compatibility.ts b/src/api/agent-server-compatibility.ts index d3710df381..3aa7855cce 100644 --- a/src/api/agent-server-compatibility.ts +++ b/src/api/agent-server-compatibility.ts @@ -105,7 +105,10 @@ export async function loadAgentServerInfo() { ).getServerInfo()) as AgentServerInfo; } catch (error) { clearCachedAgentServerInfo(); - if (isSdkHttpError(error)) { + // Preserve 401 so root.tsx can show the auth screen (public mode). + // All other HTTP errors (502, 503, etc.) mean the server is unreachable + // or misconfigured — treat them as unavailable. + if (isSdkHttpStatusError(error, 401)) { throw error; } diff --git a/tests/e2e/mock-llm/mock-llm-partial-stack.spec.ts b/tests/e2e/mock-llm/mock-llm-partial-stack.spec.ts new file mode 100644 index 0000000000..313da47761 --- /dev/null +++ b/tests/e2e/mock-llm/mock-llm-partial-stack.spec.ts @@ -0,0 +1,416 @@ +/** + * Mock-LLM E2E tests for partial stack modes (--frontend-only, --backend-only) + * and port conflict handling. + * + * These tests spawn `bin/agent-canvas.mjs` with partial-stack flags and verify: + * 1. --frontend-only: static frontend is served, backend APIs return 503 + * 2. --backend-only: backend APIs work, frontend root returns 503 + * 3. Port conflict: binary fails with a clear error when the port is busy, + * then succeeds when a free port is used + * + * Unlike the other mock-llm specs these tests do NOT rely on the webServer + * entries in playwright.mock-llm.config.ts — they manage their own child + * processes so each test controls exactly which flags are passed. + */ + +import { test, expect } from "@playwright/test"; +import { spawn, type ChildProcess } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { createServer, type Server } from "node:net"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + FRONTEND_ONLY_INGRESS_PORT, + FRONTEND_ONLY_URL, + BACKEND_ONLY_INGRESS_PORT, + BACKEND_ONLY_URL, + seedLocalStorage, +} from "./utils/mock-llm-helpers"; + +// ── Paths ────────────────────────────────────────────────────────────── +const PROJECT_ROOT = resolve( + dirname(fileURLToPath(import.meta.url)), + "../../..", +); +const BIN = join(PROJECT_ROOT, "bin/agent-canvas.mjs"); + +// ── Helpers ──────────────────────────────────────────────────────────── + +/** Spawn `bin/agent-canvas.mjs` with the given CLI flags and env overrides. */ +function spawnAgentCanvas( + flags: string[], + env: Record = {}, +): ChildProcess { + const child = spawn("node", [BIN, ...flags], { + cwd: PROJECT_ROOT, + env: { + ...process.env, + // Suppress analytics in child processes + VITE_DO_NOT_TRACK: "1", + VITE_ENABLE_BROWSER_TOOLS: "false", + ...env, + }, + stdio: ["ignore", "pipe", "pipe"], + }); + return child; +} + +/** Collect stdout + stderr from a child process into a buffer. */ +function collectOutput(child: ChildProcess): { get(): string } { + let buf = ""; + child.stdout?.on("data", (chunk: Buffer) => { + buf += chunk.toString(); + }); + child.stderr?.on("data", (chunk: Buffer) => { + buf += chunk.toString(); + }); + return { get: () => buf }; +} + +/** Wait for a child process to exit within a timeout. */ +async function waitForExit( + child: ChildProcess, + timeoutMs = 15_000, +): Promise<{ code: number | null; signal: string | null; timedOut: boolean }> { + return new Promise((resolve) => { + const timer = setTimeout(() => { + resolve({ code: null, signal: null, timedOut: true }); + }, timeoutMs); + + child.on("exit", (code, signal) => { + clearTimeout(timer); + resolve({ code, signal, timedOut: false }); + }); + }); +} + +/** + * Poll a URL until it returns a non-5xx response or timeout. + * Returns the HTTP status or null if the service never became ready. + */ +async function pollUrl( + url: string, + timeoutMs = 120_000, + intervalMs = 1_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const resp = await fetch(url, { + signal: AbortSignal.timeout(2_000), + }); + // Treat 502/503/504 as "not ready yet" — the ingress can proxy but + // the upstream service hasn't started. Only return on a real response. + if (resp.status < 500) return resp.status; + } catch { + // Connection refused — service not ready yet + } + await new Promise((r) => setTimeout(r, intervalMs)); + } + return null; +} + +/** Create an isolated state dir + session key for a child stack instance. */ +function createIsolatedEnv( + ingressPort: string, + extra: Record = {}, +): { env: Record; stateDir: string } { + const stateDir = mkdtempSync(join(tmpdir(), "partial-stack-")); + const sessionKey = randomBytes(32).toString("hex"); + return { + stateDir, + env: { + OH_CANVAS_SAFE_STATE_DIR: stateDir, + PORT: ingressPort, + LOCAL_BACKEND_API_KEY: sessionKey, + // Use isolated ports for backend services (high ports unlikely to collide) + OH_CANVAS_SAFE_BACKEND_PORT: String(parseInt(ingressPort) + 1), + OH_CANVAS_SAFE_AUTOMATION_PORT: String(parseInt(ingressPort) + 2), + OH_CANVAS_SAFE_VITE_PORT: String(parseInt(ingressPort) + 3), + // Isolated key file so we don't touch ~/.openhands + OH_SESSION_API_KEY_PATH: join(stateDir, "session-key.txt"), + ...extra, + }, + }; +} + +/** Gracefully kill a child process and wait for it to exit. */ +async function killChild(child: ChildProcess): Promise { + if (child.exitCode !== null) return; // already exited + child.kill("SIGTERM"); + await new Promise((resolve) => { + const timer = setTimeout(() => { + child.kill("SIGKILL"); + resolve(); + }, 5_000); + child.on("exit", () => { + clearTimeout(timer); + resolve(); + }); + }); +} + +/** Block a TCP port and return the server handle. */ +function blockPort(port: number): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.listen(port, "127.0.0.1", () => resolve(server)); + server.on("error", reject); + }); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════════════════════════════ + +test.describe.configure({ mode: "serial" }); + +// ─────────────────────────────────────────────────────────────────────── +// 1. Frontend-only mode +// ─────────────────────────────────────────────────────────────────────── + +test.describe("partial stack: --frontend-only", () => { + let child: ChildProcess; + let stateDir: string; + + test.afterEach(async () => { + if (child) await killChild(child); + if (stateDir) rmSync(stateDir, { recursive: true, force: true }); + }); + + test("serves the frontend but returns 503 for backend routes", async ({ + page, + request, + }) => { + test.setTimeout(60_000); + + // Ensure build directory exists (required for frontend-only mode) + expect( + existsSync(join(PROJECT_ROOT, "build/index.html")), + "build/index.html must exist — run `npm run build:app` first", + ).toBe(true); + + const isolated = createIsolatedEnv(FRONTEND_ONLY_INGRESS_PORT); + stateDir = isolated.stateDir; + const output = collectOutput( + (child = spawnAgentCanvas(["--frontend-only"], isolated.env)), + ); + + // Wait for the ingress to start serving + const rootStatus = await pollUrl(`${FRONTEND_ONLY_URL}/`, 60_000); + expect( + rootStatus, + `Frontend-only ingress never became ready.\nOutput: ${output.get().slice(-500)}`, + ).toBe(200); + + // Verify: root returns HTML (the static frontend) + const rootResp = await request.get(`${FRONTEND_ONLY_URL}/`); + expect(rootResp.status()).toBe(200); + const html = await rootResp.text(); + expect(html).toContain(" { + let child: ChildProcess; + let stateDir: string; + + test.afterEach(async () => { + if (child) await killChild(child); + if (stateDir) rmSync(stateDir, { recursive: true, force: true }); + }); + + test("serves backend APIs but returns 503 for the frontend root", async ({ + request, + }) => { + // Backend-only needs uvx → agent-server + automation startup: allow 3 min + test.setTimeout(180_000); + + const isolated = createIsolatedEnv(BACKEND_ONLY_INGRESS_PORT); + stateDir = isolated.stateDir; + const sessionKey = isolated.env.LOCAL_BACKEND_API_KEY; + const output = collectOutput( + (child = spawnAgentCanvas(["--backend-only"], isolated.env)), + ); + + // Wait for agent-server to be ready through the ingress + const serverInfoStatus = await pollUrl( + `${BACKEND_ONLY_URL}/server_info`, + 150_000, + ); + expect( + serverInfoStatus, + `Backend-only agent-server never became ready.\nOutput: ${output.get().slice(-800)}`, + ).not.toBeNull(); + + // Wait for automation backend to also be ready (starts independently, + // may lag behind the agent-server). + const automationStatus = await pollUrl( + `${BACKEND_ONLY_URL}/api/automation/v1`, + 60_000, + ); + expect( + automationStatus, + `Backend-only automation never became ready.\nOutput: ${output.get().slice(-800)}`, + ).not.toBeNull(); + + // Verify: /server_info returns 200 (agent-server running) + const serverInfoResp = await request.get( + `${BACKEND_ONLY_URL}/server_info`, + ); + expect(serverInfoResp.status()).toBe(200); + const serverInfo = await serverInfoResp.json(); + expect(serverInfo).toHaveProperty("version"); + + // Verify: /api/settings is reachable (may return 401 without key, but not 503) + const settingsResp = await request.get( + `${BACKEND_ONLY_URL}/api/settings`, + { + headers: { "X-Session-API-Key": sessionKey }, + failOnStatusCode: false, + }, + ); + expect(settingsResp.status()).not.toBe(503); + + // Verify: automation backend is also running and reachable + const automationResp = await request.get( + `${BACKEND_ONLY_URL}/api/automation/v1`, + { failOnStatusCode: false }, + ); + expect([200, 401]).toContain(automationResp.status()); + + // Verify: frontend root returns 503 (no default backend in ingress) + const rootResp = await request.get(`${BACKEND_ONLY_URL}/`, { + failOnStatusCode: false, + }); + expect(rootResp.status()).toBe(503); + + // Verify: a random static-asset-like path also returns 503 + const assetResp = await request.get( + `${BACKEND_ONLY_URL}/assets/index.js`, + { failOnStatusCode: false }, + ); + expect(assetResp.status()).toBe(503); + }); +}); + +// ─────────────────────────────────────────────────────────────────────── +// 3. Port conflict handling +// ─────────────────────────────────────────────────────────────────────── + +test.describe("partial stack: port conflict", () => { + let child: ChildProcess; + let blocker: Server | null = null; + let stateDir: string; + + test.afterEach(async () => { + if (child) await killChild(child); + if (blocker) { + await new Promise((r) => blocker!.close(() => r())); + blocker = null; + } + if (stateDir) rmSync(stateDir, { recursive: true, force: true }); + }); + + test("fails with a clear error when the ingress port is occupied", async () => { + test.setTimeout(30_000); + + const conflictPort = 18330; + + // Block the port with a dummy TCP server + blocker = await blockPort(conflictPort); + + const isolated = createIsolatedEnv(String(conflictPort)); + stateDir = isolated.stateDir; + const output = collectOutput( + (child = spawnAgentCanvas(["--frontend-only"], isolated.env)), + ); + + // The process should exit non-zero because the port is busy + const result = await waitForExit(child, 20_000); + + expect(result.timedOut, "Process should exit promptly on port conflict").toBe( + false, + ); + expect(result.code, "Process should exit non-zero on port conflict").not.toBe( + 0, + ); + + // The error message should mention the blocked port + const text = output.get(); + expect(text).toMatch( + new RegExp(`(port\\s+${conflictPort}|${conflictPort}.*in use|EADDRINUSE)`, "i"), + ); + }); + + test("starts successfully on a free port after a conflict", async ({ + request, + }) => { + test.setTimeout(60_000); + + // Use a port that is NOT blocked — frontend-only starts fast (no uvx) + const freePort = 18331; + + expect( + existsSync(join(PROJECT_ROOT, "build/index.html")), + "build/index.html must exist — run `npm run build:app` first", + ).toBe(true); + + const isolated = createIsolatedEnv(String(freePort)); + stateDir = isolated.stateDir; + const output = collectOutput( + (child = spawnAgentCanvas(["--frontend-only"], isolated.env)), + ); + + const freeUrl = `http://localhost:${freePort}`; + const rootStatus = await pollUrl(`${freeUrl}/`, 30_000); + expect( + rootStatus, + `Frontend-only on free port never became ready.\nOutput: ${output.get().slice(-500)}`, + ).toBe(200); + + // Verify it actually serves content + const rootResp = await request.get(`${freeUrl}/`); + expect(rootResp.status()).toBe(200); + const html = await rootResp.text(); + expect(html).toContain("