fix: route runtime services through server_info (#16090)

Co-authored-by: neubig <398875+neubig@users.noreply.github.com>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: neubig <neubig@users.noreply.github.com>
This commit is contained in:
Graham Neubig
2026-08-02 22:39:43 -04:00
committed by GitHub
co-authored by neubig openhands neubig
parent 1708efc446
commit e0b115757b
18 changed files with 847 additions and 237 deletions
+7 -6
View File
@@ -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 `<RUNTIME_SERVICES>` 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 `<RUNTIME_SERVICES>` 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:<port>` from inside the sandbox — but that is _you_, not the automation backend.
- Host-side services (ingress, Vite, automation) are reachable as `http://localhost:<port>`.
- Agents should treat the `<RUNTIME_SERVICES>` 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 `<RUNTIME_SERVICES>` 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 `<RUNTIME_SERVICES>` 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 `<RUNTIME_SERVICES>` 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
{
+126 -119
View File
@@ -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<string, string>;
};
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<string, unknown>)
.__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 <RUNTIME_SERVICES> 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("<RUNTIME_SERVICES>");
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<string, unknown>
).__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("<RUNTIME_SERVICES>");
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<string, unknown>
).__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<string, unknown> };
};
@@ -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 });
+180 -1
View File
@@ -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", () => {
+167 -8
View File
@@ -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<void>((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
// <RUNTIME_SERVICES> 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"),
"<html><head></head><body>app</body></html>",
);
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"),
"<html><head></head><body>app</body></html>",
);
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"), "<main>app</main>");
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);
});
});
+4 -6
View File
@@ -243,12 +243,10 @@ log "Starting frontend + proxy on port $PORT..."
# Describe the local runtime services so the frontend can populate the agent's
# <RUNTIME_SERVICES> 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 \
+1 -1
View File
@@ -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
+15 -10
View File
@@ -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 <RUNTIME_SERVICES> block.
// Stacks with automation/ingress/frontend services should use
// `npm run dev` / `dev:static`, which pass runtime-services info through
// ingress/static-server instead.
},
});
+17
View File
@@ -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",
+11 -14
View File
@@ -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
* `<RUNTIME_SERVICES>` 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 <RUNTIME_SERVICES> 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;
+22
View File
@@ -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> Port to listen on (default: 8000)
-r, --route <path=url> Add a route (can be repeated)
-d, --default <url> 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);
});
+157 -2
View File
@@ -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) {
+6 -6
View File
@@ -2,19 +2,19 @@
* Single source of truth for the `<RUNTIME_SERVICES>` 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
* `<RUNTIME_SERVICES>` 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
-7
View File
@@ -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 <RUNTIME_SERVICES> 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.
+10
View File
@@ -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;
}
+85 -57
View File
@@ -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 <json>`. 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 `<RUNTIME_SERVICES>` 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<string, unknown>)
.__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<RuntimeServicesInfo | null> {
let clientOptions: ReturnType<typeof getAgentServerClientOptions>;
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<Record<string, unknown>> {
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,
});
}
+13
View File
@@ -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;
}
+6
View File
@@ -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";