diff --git a/AGENTS.md b/AGENTS.md index 328e97b847..9986fed33d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,20 +58,21 @@ One Canvas-owned PostHog client owns telemetry and app analytics. ## Runtime Services in Dev Stacks -- When the agent-canvas dev launchers (`npm run dev` / `dev:minimal` / the published `agent-canvas` binary) start a stack, they set a `VITE_RUNTIME_SERVICES_INFO` env var on the frontend describing which services are running and how the agent should reach them. The frontend forwards this verbatim as `AgentContext.system_message_suffix` on every `POST /api/conversations`, so conversations land with a `` block appended to the system prompt. +- When the agent-canvas dev launchers (`npm run dev` / `dev:static` / the published `agent-canvas` binary) start a stack with ingress/static-server, the backend-facing server appends runtime service metadata to `/server_info` as the optional `runtime_services` field. The frontend reads that backend-provided value when creating conversations and forwards it as `AgentContext.system_message_suffix` on `POST /api/conversations`, so conversations land with a `` block appended to the system prompt. - The block lists URLs **from the agent's point of view**: - The Agent Server is always reachable as `http://localhost:` from inside the sandbox — but that is _you_, not the automation backend. - Host-side services (ingress, Vite, automation) are reachable as `http://localhost:`. - Agents should treat the `` block as authoritative: don't hardcode `localhost:8000` for "the automation server", and don't probe random ports trying to discover services. If the block says automation is not running, skip `/api/automation` calls; otherwise use the listed `url_from_agent` + `api_prefix` (default `/api/automation`) and the `X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY` header. -- The launcher → frontend → suffix plumbing is: +- The launcher → backend → frontend → suffix plumbing is: - `scripts/runtime-services-info.mjs::buildRuntimeServicesInfo()` — dependency-free module that constructs the info object; also runs as a CLI for the Docker entrypoint. Re-exported by `scripts/dev-safe.mjs` for backward compat. - - `scripts/dev-with-automation.mjs::buildAutomationRuntimeServicesInfo()` — wraps it with automation details; called from Vite spawn (`startVite`), static frontend spawn (`startStaticFrontend` → `--runtime-services-info` flag), and the static build (`static-build.mjs`). - - `src/api/agent-server-adapter.ts::buildRuntimeServicesSystemSuffix()` reads `VITE_RUNTIME_SERVICES_INFO` (Vite dev) or `window.__AGENT_CANVAS_RUNTIME_SERVICES_INFO__` (static builds, injected by `static-server.mjs`) and renders the `` markdown block; `buildAgentContext()` attaches it to `agent_context.system_message_suffix` when present. + - `scripts/dev-with-automation.mjs::buildAutomationRuntimeServicesInfo()` — wraps it with automation details. `dev-with-automation`, `dev-static`, and the published binary pass the JSON to `scripts/ingress.mjs` or `scripts/static-server.mjs` via `--runtime-services-info`. + - `scripts/ingress.mjs` and `scripts/static-server.mjs` proxy the real agent-server `/server_info` response and append `runtime_services` when configured. This keeps version/tool compatibility fields authoritative from the SDK while letting the Agent Canvas stack advertise automation/frontend/ingress topology. + - `src/api/agent-server-adapter.ts::fetchBackendRuntimeServicesInfo()` reads `runtime_services` from cached or freshly fetched `/server_info`; `buildRuntimeServicesSystemSuffix()` renders the `` markdown block; `buildAgentContext()` attaches it to `agent_context.system_message_suffix` when present. - E2E coverage: the mock-LLM automation test (`tests/e2e/mock-llm/automations/mock-llm-automation.spec.ts`) verifies the `` block reaches the LLM via `getMockLLMRequests()` and checks for Agent Server, Automation backend, and `/api/automation` entries. -### `VITE_RUNTIME_SERVICES_INFO` shape +### `/server_info.runtime_services` shape -The env var is a JSON string of: +The `runtime_services` value is a JSON object of: ```json { diff --git a/__tests__/api/agent-server-adapter.test.ts b/__tests__/api/agent-server-adapter.test.ts index b6853837e8..017f589dd9 100644 --- a/__tests__/api/agent-server-adapter.test.ts +++ b/__tests__/api/agent-server-adapter.test.ts @@ -5,7 +5,9 @@ import { ACP_SERVER_TAG_KEY, buildRuntimeServicesSystemSuffix, buildStartConversationRequest, + fetchBackendRuntimeServicesInfo, getDefaultConversationTitle, + parseRuntimeServicesInfo, toAppConversation, type DirectConversationInfo, } from "#/api/agent-server-adapter"; @@ -24,6 +26,8 @@ const { mockGetAgentServerWorkingDir, mockIsAgentServerToolAvailable, mockGetEffectiveLocalBackend, + mockGetCachedAgentServerInfo, + mockGetServerInfo, } = vi.hoisted(() => ({ mockGetAgentServerWorkingDir: vi.fn(() => "/workspace/project/agent-canvas"), mockIsAgentServerToolAvailable: vi.fn((_toolName: string) => true), @@ -34,6 +38,16 @@ const { apiKey: "session-key", kind: "local" as const, })), + mockGetCachedAgentServerInfo: vi.fn<() => unknown>(() => null), + mockGetServerInfo: vi.fn(), +})); + +vi.mock("@openhands/typescript-client/clients", () => ({ + ServerClient: vi.fn(function ServerClientMock() { + return { + getServerInfo: mockGetServerInfo, + }; + }), })); vi.mock("#/api/agent-server-config", () => ({ @@ -46,6 +60,7 @@ vi.mock("#/api/agent-server-config", () => ({ vi.mock("#/api/agent-server-compatibility", () => ({ isAgentServerToolAvailable: mockIsAgentServerToolAvailable, + getCachedAgentServerInfo: mockGetCachedAgentServerInfo, })); vi.mock("#/api/backend-registry/active-store", () => ({ @@ -54,6 +69,8 @@ vi.mock("#/api/backend-registry/active-store", () => ({ beforeEach(() => { mockIsAgentServerToolAvailable.mockReturnValue(true); + mockGetCachedAgentServerInfo.mockReturnValue(null); + mockGetServerInfo.mockReset(); mockGetEffectiveLocalBackend.mockReturnValue({ id: "default-local", name: "Local backend", @@ -691,6 +708,24 @@ describe("buildStartConversationRequest", () => { expect(payload.tool_module_qualnames).toBeUndefined(); }); + it("omits canvas_ui and its module qualname when the backend does not advertise canvas_ui", () => { + mockIsAgentServerToolAvailable.mockImplementation( + (toolName: string) => toolName !== "canvas_ui", + ); + + const payload = buildStartConversationRequest({ + settings: DEFAULT_SETTINGS, + }) as { + agent_settings: { tools: Array<{ name: string }> }; + tool_module_qualnames?: Record; + }; + + expect( + payload.agent_settings.tools.map((tool) => tool.name), + ).not.toContain("canvas_ui"); + expect(payload.tool_module_qualnames).toBeUndefined(); + }); + it("omits the client tool for an inline ACP agent", () => { const payload = buildStartConversationRequest({ settings: { @@ -1086,49 +1121,50 @@ describe("toAppConversation", () => { }); describe("buildRuntimeServicesSystemSuffix", () => { - afterEach(() => { - vi.unstubAllEnvs(); - delete (window as unknown as Record) - .__AGENT_CANVAS_RUNTIME_SERVICES_INFO__; - }); - - it("returns undefined when VITE_RUNTIME_SERVICES_INFO is unset", () => { + it("returns undefined when runtime services info is absent", () => { expect(buildRuntimeServicesSystemSuffix()).toBeUndefined(); }); - it("returns undefined when the env var is malformed JSON", () => { - vi.stubEnv("VITE_RUNTIME_SERVICES_INFO", "{not valid json"); - expect(buildRuntimeServicesSystemSuffix()).toBeUndefined(); + it("parses runtime services JSON strings", () => { + expect( + parseRuntimeServicesInfo( + JSON.stringify({ + mode: "dev:automation", + services: { + agent_server: { url_from_agent: "http://localhost:18000" }, + }, + }), + )?.mode, + ).toBe("dev:automation"); + }); + + it("returns null when runtime services JSON is malformed", () => { + expect(parseRuntimeServicesInfo("{not valid json")).toBeNull(); }); it("returns undefined when the JSON has no services", () => { - vi.stubEnv("VITE_RUNTIME_SERVICES_INFO", JSON.stringify({ mode: "x" })); - expect(buildRuntimeServicesSystemSuffix()).toBeUndefined(); + expect(buildRuntimeServicesSystemSuffix({ mode: "x" })).toBeUndefined(); }); it("renders a block when an automation entry is present", () => { - vi.stubEnv( - "VITE_RUNTIME_SERVICES_INFO", - JSON.stringify({ - mode: "dev:automation", - agent_host_alias: "localhost", - services: { - agent_server: { - description: "self", - url_from_agent: "http://localhost:18000", - }, - automation: { - description: "automations", - url_from_agent: "http://localhost:18001", - api_prefix: "/api/automation", - docs_url: "http://localhost:18001/api/automation/docs", - openapi_url: "http://localhost:18001/api/automation/openapi.json", - auth_env_var: "OPENHANDS_AUTOMATION_API_KEY", - }, + const suffix = buildRuntimeServicesSystemSuffix({ + mode: "dev:automation", + agent_host_alias: "localhost", + services: { + agent_server: { + description: "self", + url_from_agent: "http://localhost:18000", }, - }), - ); - const suffix = buildRuntimeServicesSystemSuffix(); + automation: { + description: "automations", + url_from_agent: "http://localhost:18001", + api_prefix: "/api/automation", + docs_url: "http://localhost:18001/api/automation/docs", + openapi_url: "http://localhost:18001/api/automation/openapi.json", + auth_env_var: "OPENHANDS_AUTOMATION_API_KEY", + }, + }, + }); expect(suffix).toBeDefined(); expect(suffix).toContain(""); expect(suffix).toContain("dev:automation"); @@ -1151,16 +1187,12 @@ describe("buildRuntimeServicesSystemSuffix", () => { it("uses the configured agent-server URL in the don't-guess line (not a hardcoded :8000)", () => { // dev:safe runs the agent-server on :18000, not :8000. Make sure the // rendered block doesn't lie to the agent about its own URL. - vi.stubEnv( - "VITE_RUNTIME_SERVICES_INFO", - JSON.stringify({ - mode: "dev:safe", - services: { - agent_server: { url_from_agent: "http://localhost:18000" }, - }, - }), - ); - const suffix = buildRuntimeServicesSystemSuffix(); + const suffix = buildRuntimeServicesSystemSuffix({ + mode: "dev:safe", + services: { + agent_server: { url_from_agent: "http://localhost:18000" }, + }, + }); expect(suffix).toBeDefined(); expect(suffix).toContain( "In particular, http://localhost:18000 inside your sandbox is the Agent Server", @@ -1171,21 +1203,17 @@ describe("buildRuntimeServicesSystemSuffix", () => { }); it("renders the frontend entry with the new key", () => { - vi.stubEnv( - "VITE_RUNTIME_SERVICES_INFO", - JSON.stringify({ - mode: "dev:static", - services: { - agent_server: { url_from_agent: "http://localhost:18000" }, - frontend: { - kind: "static", - description: "Static-file server hosting the agent-canvas build.", - url_from_agent: "http://localhost:3001", - }, + const suffix = buildRuntimeServicesSystemSuffix({ + mode: "dev:static", + services: { + agent_server: { url_from_agent: "http://localhost:18000" }, + frontend: { + kind: "static", + description: "Static-file server hosting the agent-canvas build.", + url_from_agent: "http://localhost:3001", }, - }), - ); - const suffix = buildRuntimeServicesSystemSuffix(); + }, + }); expect(suffix).toContain("* Frontend: http://localhost:3001"); expect(suffix).toContain("Static-file server"); // Should NOT mislabel a static-build frontend as "Vite frontend". @@ -1193,78 +1221,60 @@ describe("buildRuntimeServicesSystemSuffix", () => { }); it("explicitly mentions when automation is absent", () => { - vi.stubEnv( - "VITE_RUNTIME_SERVICES_INFO", - JSON.stringify({ - mode: "dev:safe", - services: { - agent_server: { url_from_agent: "http://localhost:18000" }, - }, - }), - ); - const suffix = buildRuntimeServicesSystemSuffix(); + const suffix = buildRuntimeServicesSystemSuffix({ + mode: "dev:safe", + services: { + agent_server: { url_from_agent: "http://localhost:18000" }, + }, + }); expect(suffix).toBeDefined(); expect(suffix).toContain("Automation backend: not running"); }); - it("falls back to window.__AGENT_CANVAS_RUNTIME_SERVICES_INFO__ when the env var is unset (static builds)", () => { - // Static builds (Docker image / published binary) have no - // VITE_RUNTIME_SERVICES_INFO baked in; scripts/static-server.mjs injects - // the JSON onto window at serve time instead. - ( - window as unknown as Record - ).__AGENT_CANVAS_RUNTIME_SERVICES_INFO__ = JSON.stringify({ - mode: "docker", - services: { - agent_server: { url_from_agent: "http://127.0.0.1:18000" }, - automation: { - url_from_agent: "http://127.0.0.1:8000", - api_prefix: "/api/automation", - auth_env_var: "OPENHANDS_AUTOMATION_API_KEY", + it("fetches runtime services from cached server_info when available", async () => { + mockGetCachedAgentServerInfo.mockReturnValue({ + version: "1.28.0", + runtime_services: { + mode: "docker", + services: { + agent_server: { url_from_agent: "http://127.0.0.1:18000" }, + automation: { + url_from_agent: "http://127.0.0.1:8000", + api_prefix: "/api/automation", + auth_env_var: "OPENHANDS_AUTOMATION_API_KEY", + }, }, }, }); - const suffix = buildRuntimeServicesSystemSuffix(); - expect(suffix).toBeDefined(); - expect(suffix).toContain(""); - expect(suffix).toContain("docker"); - expect(suffix).toContain("http://127.0.0.1:18000"); - expect(suffix).toContain("http://127.0.0.1:8000"); - expect(suffix).toContain( - "X-Session-API-Key: $OPENHANDS_AUTOMATION_API_KEY", + + const info = await fetchBackendRuntimeServicesInfo(); + + expect(info?.mode).toBe("docker"); + expect(info?.services?.automation?.url_from_agent).toBe( + "http://127.0.0.1:8000", ); + expect(mockGetServerInfo).not.toHaveBeenCalled(); }); - it("prefers VITE_RUNTIME_SERVICES_INFO over the window fallback", () => { - vi.stubEnv( - "VITE_RUNTIME_SERVICES_INFO", - JSON.stringify({ - mode: "dev:env", + it("fetches runtime services from /server_info when there is no cached probe", async () => { + mockGetServerInfo.mockResolvedValue({ + version: "1.28.0", + runtime_services: { + mode: "dev:automation", services: { agent_server: { url_from_agent: "http://localhost:18000" }, }, - }), - ); - ( - window as unknown as Record - ).__AGENT_CANVAS_RUNTIME_SERVICES_INFO__ = JSON.stringify({ - mode: "docker:window", - services: { - agent_server: { url_from_agent: "http://127.0.0.1:99999" }, }, }); - const suffix = buildRuntimeServicesSystemSuffix(); - expect(suffix).toContain("dev:env"); - expect(suffix).not.toContain("docker:window"); - expect(suffix).not.toContain("99999"); + + const info = await fetchBackendRuntimeServicesInfo(); + + expect(info?.mode).toBe("dev:automation"); + expect(mockGetServerInfo).toHaveBeenCalledOnce(); }); }); describe("agent_settings runtime services suffix", () => { - afterEach(() => { - vi.unstubAllEnvs(); - }); - it("does not set system_message_suffix when no runtime info is provided", () => { const payload = buildStartConversationRequest({ settings: DEFAULT_SETTINGS, @@ -1282,10 +1292,11 @@ describe("agent_settings runtime services suffix", () => { ); }); - it("sets system_message_suffix when runtime info is provided", () => { - vi.stubEnv( - "VITE_RUNTIME_SERVICES_INFO", - JSON.stringify({ + it("sets system_message_suffix when backend runtime info is provided", () => { + const payload = buildStartConversationRequest({ + settings: DEFAULT_SETTINGS, + query: "hello", + runtimeServicesInfo: { mode: "dev:automation", services: { agent_server: { url_from_agent: "http://localhost:18000" }, @@ -1293,11 +1304,7 @@ describe("agent_settings runtime services suffix", () => { url_from_agent: "http://localhost:18001", }, }, - }), - ); - const payload = buildStartConversationRequest({ - settings: DEFAULT_SETTINGS, - query: "hello", + }, }) as { agent_settings: { agent_context: Record }; }; diff --git a/__tests__/api/agent-server-compatibility-bundled-pin.test.ts b/__tests__/api/agent-server-compatibility-bundled-pin.test.ts index 9043ccce9e..5be11fb4d0 100644 --- a/__tests__/api/agent-server-compatibility-bundled-pin.test.ts +++ b/__tests__/api/agent-server-compatibility-bundled-pin.test.ts @@ -10,6 +10,8 @@ import { AgentServerUnavailableError, AgentServerUnknownVersionError, AgentServerUnsupportedVersionError, + clearCachedAgentServerInfo, + getCachedAgentServerInfo, loadAgentServerInfo, MINIMUM_COMPATIBLE_AGENT_SERVER_VERSION, } from "#/api/agent-server-compatibility"; @@ -49,6 +51,7 @@ const localBackend: Backend = { beforeEach(() => { window.localStorage.clear(); + clearCachedAgentServerInfo(); __resetActiveStoreForTests(); getServerInfoMock.mockReset(); vi.mocked(ServerClient).mockClear(); @@ -59,6 +62,7 @@ beforeEach(() => { afterEach(() => { window.localStorage.clear(); + clearCachedAgentServerInfo(); __resetActiveStoreForTests(); }); @@ -75,6 +79,22 @@ describe("loadAgentServerInfo", () => { expect(ServerClient).toHaveBeenCalled(); }); + it("returns cached server info only for the probed backend host", async () => { + setRegisteredBackends([localBackend]); + setActiveSelection({ backendId: localBackend.id }); + + await loadAgentServerInfo(); + + expect(getCachedAgentServerInfo({ host: localBackend.host })).toMatchObject( + { + version: MINIMUM_COMPATIBLE_AGENT_SERVER_VERSION, + }, + ); + expect( + getCachedAgentServerInfo({ host: "http://localhost:9001" }), + ).toBeNull(); + }); + it("throws AgentServerUnsupportedVersionError when the local backend is too old", async () => { setRegisteredBackends([localBackend]); setActiveSelection({ backendId: localBackend.id }); diff --git a/__tests__/scripts/ingress.test.ts b/__tests__/scripts/ingress.test.ts index ee972cb19a..fe769bfa08 100644 --- a/__tests__/scripts/ingress.test.ts +++ b/__tests__/scripts/ingress.test.ts @@ -1,4 +1,4 @@ -import { createServer, type Server } from "node:http"; +import { createServer, request, type Server } from "node:http"; import { connect as netConnect, type AddressInfo, type Socket } from "node:net"; import type { Duplex } from "node:stream"; import { spawn, type ChildProcess } from "node:child_process"; @@ -102,6 +102,50 @@ async function waitForPort(port: number, child?: ChildProcess) { throw new Error(`Timed out waiting for port ${port}`); } +async function getJson(url: string) { + return new Promise<{ status: number; body: unknown }>((resolve, reject) => { + const req = request(url, { method: "GET" }, (res) => { + let body = ""; + res.setEncoding("utf8"); + res.on("data", (chunk) => { + body += chunk; + }); + res.on("end", () => { + try { + resolve({ + status: res.statusCode ?? 0, + body: JSON.parse(body), + }); + } catch (error) { + reject(error); + } + }); + }); + req.on("error", reject); + req.end(); + }); +} + +async function getText(url: string) { + return new Promise<{ status: number; body: string }>((resolve, reject) => { + const req = request(url, { method: "GET" }, (res) => { + let body = ""; + res.setEncoding("utf8"); + res.on("data", (chunk) => { + body += chunk; + }); + res.on("end", () => { + resolve({ + status: res.statusCode ?? 0, + body, + }); + }); + }); + req.on("error", reject); + req.end(); + }); +} + async function stopChild(child?: ChildProcess) { if (!child || child.exitCode !== null) { return; @@ -338,6 +382,141 @@ describe("ingress proxy functionality", () => { await stopChild(badIngress); } }); + + it("returns 502 when backend target URL is invalid", async () => { + const badIngressPort = await getFreePort(); + const badIngress = spawn( + process.execPath, + [ + ingressScript, + "--port", + badIngressPort.toString(), + "--default", + "not-a-url", + ], + { + cwd: repoRoot, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + + let stderr = ""; + badIngress.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + + await waitForPort(badIngressPort, badIngress); + + try { + const response = await getText(`${originForPort(badIngressPort)}/test`); + await delay(100); + + expect(response.status).toBe(502); + expect(response.body).toContain("Bad Gateway"); + expect(response.body).toContain("Invalid URL"); + expect(badIngress.exitCode).toBeNull(); + expect(stderr).toContain("Invalid URL"); + } finally { + await stopChild(badIngress); + } + }); + + it("adds runtime_services to proxied /server_info", async () => { + const serverInfoBackend = createServer((_req, res) => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ version: "1.28.0" })); + }); + const serverInfoBackendPort = await listenOnLoopback(serverInfoBackend); + const runtimeIngressPort = await getFreePort(); + const runtimeServicesInfo = JSON.stringify({ + mode: "dev:automation", + services: { + agent_server: { url_from_agent: "http://localhost:18000" }, + automation: { url_from_agent: "http://localhost:18001" }, + }, + }); + const runtimeIngress = spawn( + process.execPath, + [ + ingressScript, + "--port", + runtimeIngressPort.toString(), + "--runtime-services-info", + runtimeServicesInfo, + "--route", + `/server_info=${originForPort(serverInfoBackendPort)}`, + ], + { + cwd: repoRoot, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + + await waitForPort(runtimeIngressPort, runtimeIngress); + + try { + const response = await getJson( + `${originForPort(runtimeIngressPort)}/server_info`, + ); + const body = response.body as { + version?: string; + runtime_services?: unknown; + }; + + expect(response.status).toBe(200); + expect(body.version).toBe("1.28.0"); + expect(body.runtime_services).toEqual(JSON.parse(runtimeServicesInfo)); + } finally { + await stopChild(runtimeIngress); + await closeServer(serverInfoBackend); + } + }); + + it("returns 502 when intercepted /server_info target URL is invalid", async () => { + const runtimeIngressPort = await getFreePort(); + const runtimeServicesInfo = JSON.stringify({ + mode: "dev:automation", + services: {}, + }); + const runtimeIngress = spawn( + process.execPath, + [ + ingressScript, + "--port", + runtimeIngressPort.toString(), + "--runtime-services-info", + runtimeServicesInfo, + "--route", + "/server_info=not-a-url", + ], + { + cwd: repoRoot, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + + let stderr = ""; + runtimeIngress.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + + await waitForPort(runtimeIngressPort, runtimeIngress); + + try { + const response = await getText( + `${originForPort(runtimeIngressPort)}/server_info`, + ); + await delay(100); + + expect(response.status).toBe(502); + expect(response.body).toContain("Bad Gateway"); + expect(response.body).toContain("Invalid backend URL"); + expect(runtimeIngress.exitCode).toBeNull(); + expect(stderr).toContain("Invalid backend URL"); + } finally { + await stopChild(runtimeIngress); + } + }); }); describe("ingress route matching", () => { diff --git a/__tests__/scripts/static-server.test.ts b/__tests__/scripts/static-server.test.ts index 93d7555835..c93d7b9eb3 100644 --- a/__tests__/scripts/static-server.test.ts +++ b/__tests__/scripts/static-server.test.ts @@ -1,4 +1,4 @@ -import type { Server } from "node:http"; +import { createServer, request, type Server } from "node:http"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -46,6 +46,66 @@ describe("static-server.mjs", () => { return `http://127.0.0.1:${address.port}`; } + async function startHttpServer(server: Server) { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + resolve(); + }); + }); + servers.push(server); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Server did not bind to a TCP port"); + } + return `http://127.0.0.1:${address.port}`; + } + + async function getJson(url: string) { + return new Promise<{ status: number; body: unknown }>((resolve, reject) => { + const req = request(url, { method: "GET" }, (res) => { + let body = ""; + res.setEncoding("utf8"); + res.on("data", (chunk) => { + body += chunk; + }); + res.on("end", () => { + try { + resolve({ + status: res.statusCode ?? 0, + body: JSON.parse(body), + }); + } catch (error) { + reject(error); + } + }); + }); + req.on("error", reject); + req.end(); + }); + } + + async function getText(url: string) { + return new Promise<{ status: number; body: string }>((resolve, reject) => { + const req = request(url, { method: "GET" }, (res) => { + let body = ""; + res.setEncoding("utf8"); + res.on("data", (chunk) => { + body += chunk; + }); + res.on("end", () => { + resolve({ + status: res.statusCode ?? 0, + body, + }); + }); + }); + req.on("error", reject); + req.end(); + }); + } + describe("parseArgs", () => { it("defaults sessionApiKey to null", () => { const config = parseArgs([]); @@ -112,7 +172,7 @@ describe("static-server.mjs", () => { }); }); - describe("runtime services info injection", () => { + describe("runtime services info exposure", () => { async function startServerWithRuntimeInfo( dir: string, runtimeServicesInfo: string, @@ -132,10 +192,9 @@ describe("static-server.mjs", () => { return `http://127.0.0.1:${address.port}`; } - // Regression test for the Docker / published-binary path: static builds - // have no VITE_RUNTIME_SERVICES_INFO baked in, so the agent's - // block is populated from this injected window global - // (see `parseRuntimeServicesInfo()` in src/api/agent-server-adapter.ts). + // Legacy compatibility for older Docker / published-binary frontend + // bundles, which read runtime services from this injected window global. + // New bundles read /server_info.runtime_services instead. it("exposes the JSON on window.__AGENT_CANVAS_RUNTIME_SERVICES_INFO__", async () => { const buildDir = mkdtempSync(path.join(tmpdir(), "agent-canvas-build-")); tempDirs.push(buildDir); @@ -154,8 +213,8 @@ describe("static-server.mjs", () => { const body = await (await fetch(`${origin}/`)).text(); expect(body).toContain("window.__AGENT_CANVAS_RUNTIME_SERVICES_INFO__"); - // Stored as a JSON *string* (note the escaped quotes) so the browser can - // JSON.parse it, exactly like the VITE_RUNTIME_SERVICES_INFO env var. + // Stored as a JSON *string* (note the escaped quotes) so older browser + // code can JSON.parse it. expect(body).toContain('\\"mode\\"'); expect(body).toContain("docker"); }); @@ -183,6 +242,82 @@ describe("static-server.mjs", () => { const body = await (await fetch(`${origin}/`)).text(); expect(body).not.toContain("__AGENT_CANVAS_RUNTIME_SERVICES_INFO__"); }); + + it("adds runtime_services to proxied /server_info", async () => { + const buildDir = mkdtempSync(path.join(tmpdir(), "agent-canvas-build-")); + tempDirs.push(buildDir); + writeFileSync( + path.join(buildDir, "index.html"), + "app", + ); + + const upstreamOrigin = await startHttpServer( + createServer((_req, res) => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ version: "1.28.0" })); + }), + ); + const runtimeServicesInfo = JSON.stringify({ + mode: "docker", + services: { + agent_server: { url_from_agent: "http://127.0.0.1:18000" }, + }, + }); + + const server = await startStaticServer({ + port: 0, + host: "127.0.0.1", + dir: buildDir, + routes: { "/server_info": upstreamOrigin }, + runtimeServicesInfo, + }); + servers.push(server); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("No port"); + const origin = `http://127.0.0.1:${address.port}`; + + const response = await getJson(`${origin}/server_info`); + const body = response.body as { + version?: string; + runtime_services?: unknown; + }; + + expect(response.status).toBe(200); + expect(body.version).toBe("1.28.0"); + expect(body.runtime_services).toEqual(JSON.parse(runtimeServicesInfo)); + }); + + it("returns 502 when proxied /server_info target URL is invalid", async () => { + const buildDir = mkdtempSync(path.join(tmpdir(), "agent-canvas-build-")); + tempDirs.push(buildDir); + writeFileSync( + path.join(buildDir, "index.html"), + "app", + ); + + const runtimeServicesInfo = JSON.stringify({ + mode: "docker", + services: {}, + }); + const server = await startStaticServer({ + port: 0, + host: "127.0.0.1", + dir: buildDir, + routes: { "/server_info": "not-a-url" }, + runtimeServicesInfo, + }); + servers.push(server); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("No port"); + const origin = `http://127.0.0.1:${address.port}`; + + const response = await getText(`${origin}/server_info`); + + expect(response.status).toBe(502); + expect(response.body).toContain("Bad Gateway"); + expect(response.body).toContain("Invalid backend URL"); + expect(server.listening).toBe(true); + }); }); describe("lock-to-cloud injection", () => { @@ -507,4 +642,28 @@ describe("static-server.mjs", () => { expect(response.status).not.toBe(200); await expect(response.text()).resolves.not.toContain("secret"); }); + + it("returns 502 when backend target URL is invalid", async () => { + const buildDir = mkdtempSync(path.join(tmpdir(), "agent-canvas-build-")); + tempDirs.push(buildDir); + writeFileSync(path.join(buildDir, "index.html"), "
app
"); + + const server = await startStaticServer({ + port: 0, + host: "127.0.0.1", + dir: buildDir, + routes: { "/api/invalid": "not-a-url" }, + }); + servers.push(server); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("No port"); + const origin = `http://127.0.0.1:${address.port}`; + + const response = await getText(`${origin}/api/invalid/test`); + + expect(response.status).toBe(502); + expect(response.body).toContain("Bad Gateway"); + expect(response.body).toContain("Invalid URL"); + expect(server.listening).toBe(true); + }); }); diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 3c74317e17..5218f80559 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -243,12 +243,10 @@ log "Starting frontend + proxy on port $PORT..." # Describe the local runtime services so the frontend can populate the agent's # system-prompt block (without it the agent does not know how # to reach the local automation backend and falls back to the cloud API). These -# URLs are runtime config (overridable at `docker run`), so unlike the dev -# launchers we cannot bake VITE_RUNTIME_SERVICES_INFO into the image at build -# time — we build the JSON here from the sandbox-facing URLs the entrypoint -# already exports and inject it at serve time via -# static-server.mjs --runtime-services-info. The shape comes from the same -# builder the dev stack uses (scripts/runtime-services-info.mjs). +# URLs are runtime config (overridable at `docker run`), so build the JSON here +# from the sandbox-facing URLs the entrypoint already exports. static-server.mjs +# appends it to /server_info as runtime_services and also injects the legacy +# window global for older frontend bundles. RUNTIME_SERVICES_INFO="$(node /opt/agent-canvas/runtime-services-info.mjs \ --mode docker \ --agent-host-alias 127.0.0.1 \ diff --git a/docs/architecture.md b/docs/architecture.md index f952553bf2..f7c391fdc2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -28,7 +28,7 @@ Optional runtime services include: - An Automation Server for scheduled or event-triggered agent runs. - OpenHands Cloud APIs for hosted sandbox and organization workflows. -The development launchers expose runtime service information through `VITE_RUNTIME_SERVICES_INFO`. The frontend forwards that information into new conversations as an agent context suffix so agents can use the correct URLs instead of guessing ports. +Agent Canvas stack launchers expose runtime service information through the backend `/server_info.runtime_services` field. The frontend forwards that backend-provided information into new conversations as an agent context suffix so agents can use the correct URLs instead of guessing ports. ## Frontend modules diff --git a/scripts/dev-safe.mjs b/scripts/dev-safe.mjs index bd4d004f1c..48c406d864 100644 --- a/scripts/dev-safe.mjs +++ b/scripts/dev-safe.mjs @@ -264,7 +264,9 @@ export async function assertPortsFree(portConfigs, host = "127.0.0.1") { const busy = results.filter(({ free }) => !free); if (busy.length === 0) return; - const lines = busy.map(({ name, port }) => ` • ${name}: port ${port}`).join("\n"); + const lines = busy + .map(({ name, port }) => ` • ${name}: port ${port}`) + .join("\n"); throw new Error( `Cannot start: the following ports are already in use:\n\n${lines}\n\n` + `Another agent-canvas instance may already be running.\n` + @@ -619,8 +621,7 @@ function buildConfigFromPorts(ports, cwd, env) { // ~/.openhands/agent-canvas/secret-key.txt. Persisting ensures dev mode // and Docker mode share the same encryption key when they mount the same // ~/.openhands directory (docker/entrypoint.sh reads/writes the same file). - const secretKeyPath = - env.OH_SECRET_KEY_PATH || DEFAULT_SECRET_KEY_PATH; + const secretKeyPath = env.OH_SECRET_KEY_PATH || DEFAULT_SECRET_KEY_PATH; const secretKey = env.OH_SECRET_KEY || getOrCreatePersistedApiKey(secretKeyPath, "secret"); // Use the user-provided LOCAL_BACKEND_API_KEY or fall back to a key @@ -958,10 +959,6 @@ async function main() { } const frontendCommand = buildNpmScriptCommand("dev:frontend"); - const runtimeServicesInfo = buildRuntimeServicesInfo({ - mode: "dev:safe", - agentServerPort: config.backendPort, - }); frontend = spawnProcess(frontendCommand.command, frontendCommand.args, { cwd: config.cwd, env: { @@ -971,9 +968,17 @@ async function main() { 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), + // dev:minimal deliberately does NOT supply runtime-services info (the + // frontend here talks straight to the agent-server over + // VITE_BACKEND_BASE_URL — there is no ingress or static-server in front + // of it to append `runtime_services` to `/server_info`, and the + // frontend's own VITE_RUNTIME_SERVICES_INFO env var is no longer read). + // It is a bare agent-server + Vite stack with no companion services to + // advertise, so `fetchBackendRuntimeServicesInfo()` correctly returns + // null and conversations simply omit the block. + // Stacks with automation/ingress/frontend services should use + // `npm run dev` / `dev:static`, which pass runtime-services info through + // ingress/static-server instead. }, }); diff --git a/scripts/dev-static.mjs b/scripts/dev-static.mjs index 18f0be291b..3d10da46aa 100644 --- a/scripts/dev-static.mjs +++ b/scripts/dev-static.mjs @@ -61,6 +61,7 @@ import { buildAgentServerAutomationEnv, buildAutomationCommand, buildAutomationTelemetryEnv, + buildAutomationRuntimeServicesInfo, buildConfig, } from "./dev-with-automation.mjs"; @@ -379,6 +380,12 @@ function startStaticServer(config) { // is forwarded to the agent-server instead of falling back to the SPA // shell). Without this, /server_info on :3001 returns index.html. const staticServerScript = join(projectRoot, "scripts", "static-server.mjs"); + const runtimeServicesInfo = JSON.stringify( + buildAutomationRuntimeServicesInfo({ + ...config, + frontendKind: "static", + }), + ); spawnService( "static", "node", @@ -396,6 +403,8 @@ function startStaticServer(config) { ...(config.sessionApiKey ? ["--session-api-key", config.sessionApiKey] : []), + "--runtime-services-info", + runtimeServicesInfo, "--route", `/api/automation=http://localhost:${config.autoBackendPort}`, "--route", @@ -428,6 +437,12 @@ function startIngress(config) { logService("ingress", `Starting on port ${config.ingressPort}...`, c.yellow); const ingressScript = join(projectRoot, "scripts", "ingress.mjs"); + const runtimeServicesInfo = JSON.stringify( + buildAutomationRuntimeServicesInfo({ + ...config, + frontendKind: "static", + }), + ); spawnService( "ingress", @@ -436,6 +451,8 @@ function startIngress(config) { ingressScript, "--port", config.ingressPort.toString(), + "--runtime-services-info", + runtimeServicesInfo, "--route", `/api/automation=http://localhost:${config.autoBackendPort}`, "--route", diff --git a/scripts/dev-with-automation.mjs b/scripts/dev-with-automation.mjs index cc52a35320..a2f048a785 100644 --- a/scripts/dev-with-automation.mjs +++ b/scripts/dev-with-automation.mjs @@ -964,6 +964,9 @@ function startIngress(config) { const ingressScript = join(projectRoot, "scripts", "ingress.mjs"); const frontendBackend = getFrontendBackend(config); + const runtimeServicesInfo = config.launchAgentServer + ? JSON.stringify(buildAutomationRuntimeServicesInfo(config)) + : null; spawnService( "ingress", @@ -972,6 +975,9 @@ function startIngress(config) { ingressScript, "--port", config.ingressPort.toString(), + ...(runtimeServicesInfo + ? ["--runtime-services-info", runtimeServicesInfo] + : []), ...buildRouteArgs(getLocalServiceRoutes(config)), ...(frontendBackend ? ["--default", frontendBackend] : []), ], @@ -984,8 +990,8 @@ function startIngress(config) { /** * Build the JSON-serializable runtime services info for an automation - * stack. Used by both the Vite dev server (dev mode) and static-build.mjs - * (static mode) so the frontend can populate the agent's + * stack. Backend-serving processes append this to `/server_info` so any + * frontend connected to the backend can populate the agent's * `` system-prompt block. */ export function buildAutomationRuntimeServicesInfo(config) { @@ -1009,9 +1015,6 @@ function startVite(config) { logService("vite", `Starting on port ${config.vitePort}...`, c.magenta); const frontendCommand = buildNpmScriptCommand("dev:frontend"); - const runtimeServicesInfo = config.launchAgentServer - ? buildAutomationRuntimeServicesInfo(config) - : null; const viteEnv = { // Full-stack mode points Vite at this launcher's ingress. Frontend-only @@ -1023,12 +1026,6 @@ function startVite(config) { viteEnv.VITE_WORKING_DIR = config.viteWorkingDir; } - if (runtimeServicesInfo) { - // Inform the frontend (and downstream, the agent's system prompt) about - // which services are available in this dev stack. - 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 @@ -1442,9 +1439,9 @@ function startStaticFrontend(config, staticDir) { logService("static", `Starting on port ${config.vitePort}...`, c.magenta); logService("static", `Serving from: ${staticDir}`, c.dim); - // Build the runtime-services info JSON so the pre-built frontend can - // populate the agent's system-prompt block without - // VITE_RUNTIME_SERVICES_INFO baked in at build time. + // Build the runtime-services info JSON so static-server can append it to + // /server_info. The static-server also injects the old window global for + // compatibility with previously built frontend bundles. const runtimeServicesInfo = config.launchAgentServer ? JSON.stringify(buildAutomationRuntimeServicesInfo(config)) : null; diff --git a/scripts/ingress.mjs b/scripts/ingress.mjs index 1c8da36985..72eb75082e 100644 --- a/scripts/ingress.mjs +++ b/scripts/ingress.mjs @@ -13,6 +13,8 @@ * INGRESS_PORT - Port to listen on (default: 8000) * INGRESS_ROUTES - JSON object of path prefix -> backend URL * INGRESS_DEFAULT - Default backend for unmatched routes + * INGRESS_RUNTIME_SERVICES_INFO - Runtime services JSON appended to + * /server_info * * Route matching: * - Routes are matched by longest prefix first @@ -27,6 +29,8 @@ import { createProxyHandlers, createRouter, isBenignSocketError, + isServerInfoRequest, + proxyServerInfoRequest, } from "./proxy-utils.mjs"; // ═══════════════════════════════════════════════════════════════════════════ @@ -39,6 +43,7 @@ function parseArgs() { port: 8000, routes: {}, defaultBackend: null, + runtimeServicesInfo: null, }; for (let i = 0; i < args.length; i++) { @@ -57,6 +62,9 @@ function parseArgs() { case "--default": config.defaultBackend = args[++i]; break; + case "--runtime-services-info": + config.runtimeServicesInfo = args[++i] || null; + break; case "-h": case "--help": showHelp(); @@ -80,12 +88,15 @@ OPTIONS: -p, --port Port to listen on (default: 8000) -r, --route Add a route (can be repeated) -d, --default Default backend for unmatched routes + --runtime-services-info Runtime services JSON for /server_info -h, --help Show this help ENVIRONMENT VARIABLES: INGRESS_PORT Port to listen on INGRESS_ROUTES JSON object: {"path": "url", ...} INGRESS_DEFAULT Default backend URL + INGRESS_RUNTIME_SERVICES_INFO + Runtime services JSON for /server_info EXAMPLES: # Basic setup with agent server and automation @@ -125,6 +136,8 @@ function buildConfig(args, env = process.env) { port: args.port || parseInt(env.INGRESS_PORT, 10) || 8000, routes, defaultBackend: args.defaultBackend || env.INGRESS_DEFAULT || null, + runtimeServicesInfo: + args.runtimeServicesInfo || env.INGRESS_RUNTIME_SERVICES_INFO || null, }; } @@ -146,6 +159,15 @@ export function startIngress(config) { return; } + if ( + config.runtimeServicesInfo && + isServerInfoRequest(req) && + (req.method === "GET" || req.method === "HEAD") + ) { + proxyServerInfoRequest(req, res, backend, config.runtimeServicesInfo); + return; + } + proxy.proxyHttp(req, res, backend); }); diff --git a/scripts/proxy-utils.mjs b/scripts/proxy-utils.mjs index 04d308fd59..4d81dd870f 100644 --- a/scripts/proxy-utils.mjs +++ b/scripts/proxy-utils.mjs @@ -1,6 +1,9 @@ +import { request as httpRequest } from "node:http"; +import { request as httpsRequest } from "node:https"; import { createProxyServer } from "httpxy"; const DEFAULT_PROXY_TIMEOUT_MS = 120_000; +const SERVER_INFO_PATH = "/server_info"; const BENIGN_SOCKET_ERRORS = new Set([ "ECONNRESET", "EPIPE", @@ -35,6 +38,152 @@ export function isBenignSocketError(err) { return Boolean(err && BENIGN_SOCKET_ERRORS.has(err.code)); } +function parseBackendUrl(backendUrl) { + const url = new URL(backendUrl); + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("Invalid backend URL"); + } + return { + hostname: url.hostname, + port: Number.parseInt(url.port, 10) || (url.protocol === "https:" ? 443 : 80), + protocol: url.protocol, + }; +} + +function writeInvalidBackendUrlResponse(req, res) { + const message = "Invalid backend URL"; + console.error(`Proxy error for ${req.url}: ${message}`); + if (!res.headersSent) { + res.writeHead(502, { "Content-Type": "text/plain; charset=utf-8" }); + res.end(`Bad Gateway: ${message}`); + } else { + res.destroy(); + } +} + +export function isServerInfoRequest(req) { + const pathname = new URL(req.url ?? "/", "http://localhost").pathname; + return pathname === SERVER_INFO_PATH; +} + +export function proxyServerInfoRequest( + req, + res, + backendUrl, + runtimeServicesInfo, +) { + let backend; + try { + backend = parseBackendUrl(backendUrl); + } catch { + writeInvalidBackendUrlResponse(req, res); + return; + } + + const request = backend.protocol === "https:" ? httpsRequest : httpRequest; + const proxyReq = request( + { + hostname: backend.hostname, + port: backend.port, + path: req.url, + method: req.method, + headers: { + ...req.headers, + host: `${backend.hostname}:${backend.port}`, + }, + }, + (proxyRes) => { + const chunks = []; + + proxyRes.on("data", (chunk) => { + chunks.push(Buffer.from(chunk)); + }); + + proxyRes.on("error", (err) => { + if (!isBenignSocketError(err)) { + console.error(`Upstream response error for ${req.url}:`, err.message); + } + if (!res.headersSent) { + res.writeHead(502); + res.end(`Bad Gateway: ${err.message}`); + } else { + res.destroy(); + } + }); + + proxyRes.on("end", () => { + const statusCode = proxyRes.statusCode ?? 502; + const headers = { ...proxyRes.headers }; + const originalBody = Buffer.concat(chunks); + + if (statusCode < 200 || statusCode >= 300 || req.method === "HEAD") { + res.writeHead(statusCode, headers); + res.end(req.method === "HEAD" ? "" : originalBody); + return; + } + + try { + const serverInfo = JSON.parse(originalBody.toString("utf8")); + const runtimeServices = + typeof runtimeServicesInfo === "string" + ? JSON.parse(runtimeServicesInfo) + : runtimeServicesInfo; + const body = Buffer.from( + JSON.stringify({ + ...serverInfo, + runtime_services: runtimeServices, + }), + "utf8", + ); + + delete headers["content-length"]; + delete headers["transfer-encoding"]; + headers["content-type"] = "application/json; charset=utf-8"; + headers["cache-control"] = "no-store"; + res.writeHead(statusCode, headers); + res.end(body); + } catch (err) { + console.warn( + `Could not append runtime_services to ${SERVER_INFO_PATH}: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + res.writeHead(statusCode, headers); + res.end(originalBody); + } + }); + }, + ); + + proxyReq.on("error", (err) => { + if (!isBenignSocketError(err)) { + console.error(`Proxy error for ${req.url}:`, err.message); + } + if (!res.headersSent) { + res.writeHead(502); + res.end(`Bad Gateway: ${err.message}`); + } else { + res.destroy(); + } + }); + + req.on("error", (err) => { + if (!isBenignSocketError(err)) { + console.error(`Client request error for ${req.url}:`, err.message); + } + proxyReq.destroy(); + }); + + res.on("error", (err) => { + if (!isBenignSocketError(err)) { + console.error(`Client response error for ${req.url}:`, err.message); + } + proxyReq.destroy(); + }); + + req.pipe(proxyReq, { end: true }); +} + function once(fn) { let called = false; return (...args) => { @@ -97,7 +246,7 @@ export function createProxyHandlers({ res.on("finish", finish); res.on("error", finish); - proxy.web(req, res, { target }).catch((err) => { + const handleProxyError = (err) => { metrics.totalErrors += 1; if (!isBenignSocketError(err)) { console.error( @@ -107,7 +256,13 @@ export function createProxyHandlers({ } writeProxyError(res, err instanceof Error ? err.message : String(err)); finish(); - }); + }; + + try { + proxy.web(req, res, { target }).catch(handleProxyError); + } catch (err) { + handleProxyError(err); + } } function proxyWebSocket(req, socket, head, target) { diff --git a/scripts/runtime-services-info.mjs b/scripts/runtime-services-info.mjs index b3b80b62d2..0519076637 100644 --- a/scripts/runtime-services-info.mjs +++ b/scripts/runtime-services-info.mjs @@ -2,19 +2,19 @@ * Single source of truth for the `` block. * * Builds a structured description of the services that are reachable from - * inside the agent's sandbox. The frontend forwards it (verbatim, as a JSON - * string) and renders it into the system prompt via - * `AgentContext.system_message_suffix`, so the agent sees a + * inside the agent's sandbox. Agent Canvas backend-serving processes attach it + * to `/server_info.runtime_services`; the frontend renders that backend value + * into `AgentContext.system_message_suffix`, so the agent sees a * `` block listing what's available without having to probe. * * Two callers share this one definition: * - the dev launchers (scripts/dev-*.mjs), which know the stack as a set of - * ports and bake the result into `VITE_RUNTIME_SERVICES_INFO` at build time; + * ports and pass the result to ingress/static-server for `/server_info`; * - docker/entrypoint.sh, which runs this file as a CLI (see the bottom of * this module) because in a container the URLs are *runtime* config — the * ports and base URLs are overridable at `docker run` and therefore cannot - * be baked into the image at build time. The JSON it prints is injected - * into index.html at serve time by scripts/static-server.mjs. + * be baked into the image at build time. The JSON it prints is passed to + * scripts/static-server.mjs and exposed through `/server_info`. * * URLs are written from the *agent's* point of view (i.e. as the agent should * curl/fetch them from inside its sandbox), which is deliberately not the diff --git a/scripts/static-build.mjs b/scripts/static-build.mjs index 0679575eb6..23bb529a85 100644 --- a/scripts/static-build.mjs +++ b/scripts/static-build.mjs @@ -3,7 +3,6 @@ import { existsSync } from "node:fs"; import { join } from "node:path"; import { - buildAutomationRuntimeServicesInfo, c, logError, logService, @@ -45,12 +44,6 @@ export function buildFrontend(config, args = {}) { // Bake the session API key — used by the frontend for both agent-server // and automation auth via the `X-Session-API-Key` header. VITE_SESSION_API_KEY: config.sessionApiKey, - // Bake a description of the runtime services in this dev stack so the - // frontend can populate the agent's system-prompt - // block when creating a conversation. - VITE_RUNTIME_SERVICES_INFO: JSON.stringify( - buildAutomationRuntimeServicesInfo(config), - ), // Intentionally do NOT set VITE_BACKEND_BASE_URL: leaving it unset makes // the runtime fall back to window.location.origin, which keeps the build // portable across localhost, LAN hosts, and tunnels such as ngrok. diff --git a/scripts/static-server.mjs b/scripts/static-server.mjs index 4feb41c06e..8e399259bf 100644 --- a/scripts/static-server.mjs +++ b/scripts/static-server.mjs @@ -37,7 +37,9 @@ import sirv from "sirv"; import { createProxyHandlers, createRouter, + isServerInfoRequest, matchesPathPrefix, + proxyServerInfoRequest, } from "./proxy-utils.mjs"; // ───────────────────────────────────────────────────────────────────────────── @@ -558,6 +560,14 @@ export function startStaticServer(config) { const server = createServer((req, res) => { const backend = route(req.url ?? "/"); if (backend) { + if ( + config.runtimeServicesInfo && + isServerInfoRequest(req) && + (req.method === "GET" || req.method === "HEAD") + ) { + proxyServerInfoRequest(req, res, backend, config.runtimeServicesInfo); + return; + } proxy.proxyHttp(req, res, backend); return; } diff --git a/src/api/agent-server-adapter.ts b/src/api/agent-server-adapter.ts index e655501836..cdc6b97044 100644 --- a/src/api/agent-server-adapter.ts +++ b/src/api/agent-server-adapter.ts @@ -1,4 +1,5 @@ import { ACP_SETTINGS_KEYS } from "@openhands/typescript-client"; +import { ServerClient } from "@openhands/typescript-client/clients"; import { SKILLS_CATALOG } from "@openhands/extensions/skills"; import { DEFAULT_SETTINGS } from "#/services/settings"; import { ExecutionStatus } from "#/types/agent-server/core"; @@ -9,7 +10,10 @@ import { resolveEffectiveAcpModel, } from "#/constants/acp-providers"; import { getAgentServerClientOptions } from "./agent-server-client-options"; -import { isAgentServerToolAvailable } from "./agent-server-compatibility"; +import { + getCachedAgentServerInfo, + isAgentServerToolAvailable, +} from "./agent-server-compatibility"; import { getAgentServerWorkingDir } from "./agent-server-config"; import { getEffectiveLocalBackend } from "./backend-registry/active-store"; import { buildAuthHeaders } from "./backend-registry/auth"; @@ -103,15 +107,13 @@ function browserToolsEnabled() { } /** - * Shape of the runtime services info (set by the dev launchers in - * scripts/dev-*.mjs as `VITE_RUNTIME_SERVICES_INFO`, or injected at serve time - * by `scripts/static-server.mjs` for static builds — see - * `getRawRuntimeServicesInfo`). All URLs are written from the agent's point of + * Shape of the runtime services info served by Agent Canvas backends in + * `/server_info.runtime_services`. All URLs are written from the agent's point of * view, not the browser's. The block is rendered into the agent's system prompt - * via `AgentContext.system_message_suffix` so the agent knows what's - * reachable from inside its sandbox without having to probe. + * via `AgentContext.system_message_suffix` so the agent knows what's reachable + * from inside its sandbox without having to probe. */ -interface RuntimeServicesInfo { +export interface RuntimeServicesInfo { mode?: string; agent_host_alias?: string; services?: { @@ -136,66 +138,72 @@ interface RuntimeServicesInfo { }; } -/** - * Return the raw runtime-services JSON string, consulting two sources in order - * (mirrors `getBakedSessionApiKey` in agent-server-config.ts): - * 1. `VITE_RUNTIME_SERVICES_INFO` — baked into the bundle at build time by - * the dev launchers (`npm run dev`, dev:static). - * 2. `window.__AGENT_CANVAS_RUNTIME_SERVICES_INFO__` — injected into - * index.html at serve time by `scripts/static-server.mjs - * --runtime-services-info `. This is the path used by static builds - * (the Docker image and the published binary), where the env var is empty - * in the prebuilt bundle. Without it the `` block is - * missing and the agent cannot reach the local automation backend. - */ -function getRawRuntimeServicesInfo(): string | null { - const envRaw = import.meta.env.VITE_RUNTIME_SERVICES_INFO?.trim(); - if (envRaw) return envRaw; - - if (typeof window !== "undefined") { - const injected = (window as unknown as Record) - .__AGENT_CANVAS_RUNTIME_SERVICES_INFO__; - if (typeof injected === "string") { - return injected.trim() || null; +export function parseRuntimeServicesInfo( + value: unknown, +): RuntimeServicesInfo | null { + if (typeof value === "string") { + const raw = value.trim(); + if (!raw) return null; + try { + return parseRuntimeServicesInfo(JSON.parse(raw)); + } catch { + return null; } } - return null; + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + + const parsed = value as RuntimeServicesInfo; + if (!parsed.services || typeof parsed.services !== "object") return null; + return parsed; } -function parseRuntimeServicesInfo(): RuntimeServicesInfo | null { - const raw = getRawRuntimeServicesInfo(); - if (!raw) return null; +export async function fetchBackendRuntimeServicesInfo(): Promise { + let clientOptions: ReturnType; try { - const parsed = JSON.parse(raw) as RuntimeServicesInfo; - if (!parsed || typeof parsed !== "object") return null; - return parsed; + clientOptions = getAgentServerClientOptions({ timeout: 3000 }); + } catch { + return null; + } + + const cached = parseRuntimeServicesInfo( + getCachedAgentServerInfo({ host: clientOptions.host })?.runtime_services, + ); + if (cached) return cached; + + try { + const serverInfo = await new ServerClient(clientOptions).getServerInfo(); + return parseRuntimeServicesInfo( + (serverInfo as { runtime_services?: unknown }).runtime_services, + ); } catch { - // Malformed JSON: ignore and fall back to no runtime info, rather than - // tearing down conversation creation over a misconfigured env var or - // injected value. return null; } } /** * Return the deployment mode from the runtime services info, e.g. "docker", - * "dev:automation", etc. Returns `null` when no runtime info is configured. + * "dev:automation", etc. Returns `null` when no runtime info is supplied. */ -export function getDeploymentMode(): string | null { - return parseRuntimeServicesInfo()?.mode ?? null; +export function getDeploymentMode( + runtimeServicesInfo?: RuntimeServicesInfo | null, +): string | null { + return runtimeServicesInfo?.mode ?? null; } /** * Render the runtime services info into a markdown block suitable for * appending to the system prompt via `AgentContext.system_message_suffix`. * - * Returns `undefined` when no runtime info is configured, so callers can - * safely omit the field on production builds (where the launcher doesn't - * set `VITE_RUNTIME_SERVICES_INFO`). + * Returns `undefined` when no runtime info is available, so callers can safely + * omit the field when the selected backend does not advertise runtime services. */ -export function buildRuntimeServicesSystemSuffix(): string | undefined { - const info = parseRuntimeServicesInfo(); +export function buildRuntimeServicesSystemSuffix( + runtimeServicesInfo?: RuntimeServicesInfo | null, +): string | undefined { + const info = parseRuntimeServicesInfo(runtimeServicesInfo); if (!info?.services) return undefined; const lines: string[] = []; @@ -658,9 +666,11 @@ function buildBundledSkills(): BundledSkill[] { function buildAgentContext( agentSettings: SettingsRecord, + runtimeServicesInfo?: RuntimeServicesInfo | null, disabledSkills: string[] = [], ): SettingsRecord { - const runtimeServicesSuffix = buildRuntimeServicesSystemSuffix(); + const runtimeServicesSuffix = + buildRuntimeServicesSystemSuffix(runtimeServicesInfo); const existingContext = toRecord(agentSettings.agent_context); // Merge bundled public skills with any skills already present in the @@ -724,11 +734,16 @@ function resolveAcpCommand(agentSettings: SettingsRecord): unknown { function buildConfiguredAcpAgentSettings( settings: Settings, + runtimeServicesInfo?: RuntimeServicesInfo | null, ): AgentSettingsPayload { const agentSettings = toRecord(settings.agent_settings); const payload: AgentSettingsPayload = { agent_kind: "acp", - agent_context: buildAgentContext(agentSettings, settings.disabled_skills), + agent_context: buildAgentContext( + agentSettings, + runtimeServicesInfo, + settings.disabled_skills, + ), }; // TODO(#1019): set ``acp_isolate_data_dir: true`` here for a containerized @@ -785,6 +800,7 @@ function buildConfiguredAcpAgentSettings( function buildConfiguredOpenHandsAgentSettings( settings: Settings, + runtimeServicesInfo?: RuntimeServicesInfo | null, ): AgentSettingsPayload { const agentSettings = toRecord(settings.agent_settings); const llm = toRecord(agentSettings.llm); @@ -839,17 +855,22 @@ function buildConfiguredOpenHandsAgentSettings( return { ...agentSettings, llm, - agent_context: buildAgentContext(agentSettings, settings.disabled_skills), + agent_context: buildAgentContext( + agentSettings, + runtimeServicesInfo, + settings.disabled_skills, + ), tools: getAgentTools(agentSettings), }; } function buildConfiguredAgentSettings( settings: Settings, + runtimeServicesInfo?: RuntimeServicesInfo | null, ): AgentSettingsPayload { return isAcpAgent(settings) - ? buildConfiguredAcpAgentSettings(settings) - : buildConfiguredOpenHandsAgentSettings(settings); + ? buildConfiguredAcpAgentSettings(settings, runtimeServicesInfo) + : buildConfiguredOpenHandsAgentSettings(settings, runtimeServicesInfo); } function buildConfiguredConversationSettings(options: { @@ -935,6 +956,7 @@ export interface StartConversationOptions { agentProfileId?: string; agentProfileKind?: AgentKind; titleLlmProfile?: string; + runtimeServicesInfo?: RuntimeServicesInfo | null; } export function buildStartConversationRequest( @@ -950,7 +972,10 @@ export function buildStartConversationRequest( : acpMode ? "acp" : "openhands"; - const agentSettings = buildConfiguredAgentSettings(sourceAgentSettings); + const agentSettings = buildConfiguredAgentSettings( + sourceAgentSettings, + options.runtimeServicesInfo, + ); const acpServerTag = acpMode ? getAcpServerTag(sourceAgentSettings) : undefined; @@ -1133,10 +1158,12 @@ export async function buildStartConversationRequestWithEncryptedSettings(options }): Promise> { const { SecretsService } = await import("./secrets-service"); - const [settingsResult, customSecrets] = await Promise.all([ - SettingsService.getSettingsForConversation(), - SecretsService.getSecrets(), - ]); + const [settingsResult, customSecrets, runtimeServicesInfo] = + await Promise.all([ + SettingsService.getSettingsForConversation(), + SecretsService.getSecrets(), + fetchBackendRuntimeServicesInfo(), + ]); const { agentSettings, conversationSettings, secretsEncrypted } = settingsResult; @@ -1153,6 +1180,7 @@ export async function buildStartConversationRequestWithEncryptedSettings(options encryptedConversationSettings: conversationSettings, secretsEncrypted, customSecrets, + runtimeServicesInfo, }); } diff --git a/src/api/agent-server-compatibility.ts b/src/api/agent-server-compatibility.ts index 7aea8a8357..674d8101e7 100644 --- a/src/api/agent-server-compatibility.ts +++ b/src/api/agent-server-compatibility.ts @@ -25,9 +25,11 @@ export const AGENT_SERVER_UNKNOWN_VERSION_ERROR_CODE = export interface AgentServerInfo extends BaseServerInfo { sdk_version?: string; usable_tools?: string[] | null; + runtime_services?: unknown; } let cachedAgentServerInfo: AgentServerInfo | null = null; +let cachedAgentServerInfoHost: string | null = null; const getAdvertisedTools = (serverInfo: AgentServerInfo | null) => { if (Array.isArray(serverInfo?.usable_tools)) { @@ -130,6 +132,16 @@ export const isAgentServerAuthError = (error: unknown): boolean => export function clearCachedAgentServerInfo() { cachedAgentServerInfo = null; + cachedAgentServerInfoHost = null; +} + +export function getCachedAgentServerInfo(options?: { + host?: string | null; +}): AgentServerInfo | null { + if (options?.host && options.host !== cachedAgentServerInfoHost) { + return null; + } + return cachedAgentServerInfo; } export function isAgentServerToolAvailable(toolName: string) { @@ -381,5 +393,6 @@ export async function loadAgentServerInfo() { } cachedAgentServerInfo = serverInfo; + cachedAgentServerInfoHost = clientOptions.host; return serverInfo; } diff --git a/vite.config.ts b/vite.config.ts index 81cfee1488..57582d2733 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -95,6 +95,12 @@ export default defineConfig(({ mode }) => { VITE_FRONTEND_PORT = "3001", VITE_INSECURE_SKIP_VERIFY = "false", VITE_BASE_PATH, + // Runtime-services metadata for the dev server, passed by launchers that + // run the Vite dev server directly (e.g. dev:minimal). Unlike + // ingress/static-server, the Vite proxy cannot post-process the upstream + // /server_info response, so the launcher serializes the info here and the + // middleware below merges it into the proxied response. + VITE_RUNTIME_SERVICES_INFO, } = loadEnv(mode, process.cwd()); const isLibraryBuild = process.env.BUILD_LIB === "true";