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"; import { afterEach, describe, expect, it, vi } from "vitest"; import { parseArgs, serializeForInlineScript, startStaticServer, } from "../../scripts/static-server.mjs"; describe("static-server.mjs", () => { const servers: Server[] = []; const tempDirs: string[] = []; afterEach(async () => { await Promise.all( servers.splice(0).map( (server) => new Promise((resolve) => { server.close(() => resolve()); }), ), ); for (const dir of tempDirs.splice(0)) { rmSync(dir, { recursive: true, force: true }); } }); async function startServer( dir: string, overrides: Partial[0]> = {}, ) { const server = await startStaticServer({ port: 0, host: "127.0.0.1", dir, routes: {}, ...overrides, }); servers.push(server); const address = server.address(); if (!address || typeof address === "string") { throw new Error("Static server did not bind to a TCP port"); } 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([]); expect(config.sessionApiKey).toBeNull(); }); it("parses --session-api-key", () => { const config = parseArgs(["--session-api-key", "my-test-key"]); expect(config.sessionApiKey).toBe("my-test-key"); }); it("treats empty string as null for session key", () => { const config = parseArgs(["--session-api-key", ""]); expect(config.sessionApiKey).toBeNull(); }); it("defaults runtimeServicesInfo to null", () => { const config = parseArgs([]); expect(config.runtimeServicesInfo).toBeNull(); }); it("defaults lockToCloud to null", () => { const config = parseArgs([]); expect(config.lockToCloud).toBeNull(); }); it("parses --lock-to-cloud", () => { const config = parseArgs([ "--lock-to-cloud", "https://cloud.example.com", ]); expect(config.lockToCloud).toBe("https://cloud.example.com"); }); it("treats empty string as null for lockToCloud", () => { const config = parseArgs(["--lock-to-cloud", ""]); expect(config.lockToCloud).toBeNull(); }); it("defaults disableTelemetry to false", () => { const config = parseArgs([], {}); expect(config.disableTelemetry).toBe(false); }); it("parses --disable-telemetry", () => { const config = parseArgs(["--disable-telemetry"], {}); expect(config.disableTelemetry).toBe(true); }); it("enables disableTelemetry from AGENT_CANVAS_DISABLE_TELEMETRY=1", () => { const config = parseArgs([], { AGENT_CANVAS_DISABLE_TELEMETRY: "1" }); expect(config.disableTelemetry).toBe(true); }); it("enables disableTelemetry from AGENT_CANVAS_DISABLE_TELEMETRY=true", () => { const config = parseArgs([], { AGENT_CANVAS_DISABLE_TELEMETRY: "true" }); expect(config.disableTelemetry).toBe(true); }); it("ignores a falsy AGENT_CANVAS_DISABLE_TELEMETRY value", () => { const config = parseArgs([], { AGENT_CANVAS_DISABLE_TELEMETRY: "0" }); expect(config.disableTelemetry).toBe(false); }); it("defaults basePath to root", () => { const config = parseArgs([]); expect(config.basePath).toBe("/"); }); it("parses and normalizes --base-path", () => { const config = parseArgs(["--base-path", "canvas/"]); expect(config.basePath).toBe("/canvas"); }); it("treats empty string as root for basePath", () => { const config = parseArgs(["--base-path", ""]); expect(config.basePath).toBe("/"); }); it("parses --runtime-services-info", () => { const json = '{"mode":"docker"}'; const config = parseArgs(["--runtime-services-info", json]); expect(config.runtimeServicesInfo).toBe(json); }); it("treats empty string as null for runtime services info", () => { const config = parseArgs(["--runtime-services-info", ""]); expect(config.runtimeServicesInfo).toBeNull(); }); }); describe("serializeForInlineScript", () => { it("escapes '<' and '>' to prevent breaking out of script tags", () => { const input = ""; const result = serializeForInlineScript(input); expect(result).not.toContain("<"); expect(result).not.toContain(">"); expect(result).toBe('"\\u003c/script\\u003e\\u003cscript\\u003ealert(1)\\u003c/script\\u003e"'); }); it("escapes line and paragraph separators U+2028 and U+2029", () => { const input = "line1\u2028line2\u2029line3"; const result = serializeForInlineScript(input); expect(result).toContain("\\u2028"); expect(result).toContain("\\u2029"); expect(result).not.toContain("\u2028"); expect(result).not.toContain("\u2029"); }); it("serializes complex objects properly with escaping", () => { const input = { key: "", count: 42 }; const result = serializeForInlineScript(input); expect(result).toBe('{"key":"\\u003ctest\\u003e","count":42}'); }); }); describe("--vscode-base-path", () => { it("parses the prefix and normalizes a trailing slash", () => { const config = parseArgs([ "--route", "/vscode=http://127.0.0.1:8001", "--vscode-base-path", "/vscode/", ]); expect(config.vscodeBasePath).toBe("/vscode"); }); it("defaults to null so an origin advertises nothing unless asked", () => { expect(parseArgs([]).vscodeBasePath).toBeNull(); }); it("rejects a value that is not a path", () => { expect(() => parseArgs(["--vscode-base-path", "vscode"])).toThrow( /must start with '\//, ); }); // The guard that makes this flag trustworthy: advertising the editor and // routing it are the same decision, so a prefix with no route behind it // must not start. Without it, the frontend would render the control and // the navigation would fall through to the SPA — the exact bug the flag // exists to prevent. it("refuses to start when the advertised prefix has no matching route", () => { const exit = vi.spyOn(process, "exit").mockImplementation((() => { throw new Error("process.exit"); }) as never); const error = vi.spyOn(console, "error").mockImplementation(() => {}); try { expect(() => parseArgs([ "--route", "/api=http://127.0.0.1:8000", "--vscode-base-path", "/vscode", ]), ).toThrow("process.exit"); expect(exit).toHaveBeenCalledWith(1); expect(error.mock.calls[0][0]).toContain("has no matching --route"); } finally { exit.mockRestore(); error.mockRestore(); } }); }); describe("vscode base path injection", () => { async function startServerAdvertising( dir: string, vscodeBasePath: string | null, ) { const server = await startStaticServer({ port: 0, host: "127.0.0.1", dir, routes: {}, vscodeBasePath, }); servers.push(server); const address = server.address(); if (!address || typeof address === "string") { throw new Error("Static server did not bind to a TCP port"); } return `http://127.0.0.1:${address.port}`; } function makeBuildDir() { const buildDir = mkdtempSync(path.join(tmpdir(), "agent-canvas-build-")); tempDirs.push(buildDir); writeFileSync( path.join(buildDir, "index.html"), "app", ); return buildDir; } // Read by getOriginVSCodeBasePath() in src/utils/vscode-origin.ts, which // is what lets the frontend tell "this server has an editor" apart from // "this origin can reach it". it("exposes the prefix on window.__AGENT_CANVAS_VSCODE_BASE_PATH__", async () => { const origin = await startServerAdvertising(makeBuildDir(), "/vscode"); const body = await (await fetch(`${origin}/`)).text(); expect(body).toContain( 'window.__AGENT_CANVAS_VSCODE_BASE_PATH__="/vscode"', ); }); // Public mode: the same document, served without the advertisement, is // what hides the control on an origin that has no editor route. it("injects nothing when the origin serves no editor", async () => { const origin = await startServerAdvertising(makeBuildDir(), null); const body = await (await fetch(`${origin}/`)).text(); expect(body).not.toContain("__AGENT_CANVAS_VSCODE_BASE_PATH__"); }); }); describe("runtime services info exposure", () => { async function startServerWithRuntimeInfo( dir: string, runtimeServicesInfo: string, ) { const server = await startStaticServer({ port: 0, host: "127.0.0.1", dir, routes: {}, runtimeServicesInfo, }); servers.push(server); const address = server.address(); if (!address || typeof address === "string") { throw new Error("Static server did not bind to a TCP port"); } return `http://127.0.0.1:${address.port}`; } // 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); writeFileSync( path.join(buildDir, "index.html"), "app", ); const info = JSON.stringify({ mode: "docker", services: { agent_server: { url_from_agent: "http://127.0.0.1:18000" }, }, }); const origin = await startServerWithRuntimeInfo(buildDir, info); 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 older browser // code can JSON.parse it. expect(body).toContain('\\"mode\\"'); expect(body).toContain("docker"); }); it("does not inject when runtimeServicesInfo is null", 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: {}, runtimeServicesInfo: null, }); 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 as { port: number }).port}`; 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", () => { async function startServerLockedToCloud(dir: string, lockToCloud: string) { const server = await startStaticServer({ port: 0, host: "127.0.0.1", dir, routes: {}, lockToCloud, }); servers.push(server); const address = server.address(); if (!address || typeof address === "string") { throw new Error("Static server did not bind to a TCP port"); } return `http://127.0.0.1:${address.port}`; } it("exposes the locked Cloud URL on window.__AGENT_CANVAS_LOCK_TO_CLOUD__", async () => { const buildDir = mkdtempSync(path.join(tmpdir(), "agent-canvas-build-")); tempDirs.push(buildDir); writeFileSync( path.join(buildDir, "index.html"), "app", ); const origin = await startServerLockedToCloud( buildDir, "https://cloud.example.com", ); const body = await (await fetch(`${origin}/`)).text(); expect(body).toContain("window.__AGENT_CANVAS_LOCK_TO_CLOUD__"); expect(body).toContain('"https://cloud.example.com"'); }); it("injects lock-to-cloud into SPA fallback index.html", async () => { const buildDir = mkdtempSync(path.join(tmpdir(), "agent-canvas-build-")); tempDirs.push(buildDir); writeFileSync( path.join(buildDir, "index.html"), "app", ); const origin = await startServerLockedToCloud( buildDir, "https://cloud.example.com", ); const response = await fetch(`${origin}/some/deep/route`); const body = await response.text(); expect(response.status).toBe(200); expect(body).toContain("__AGENT_CANVAS_LOCK_TO_CLOUD__"); }); }); describe("disable-telemetry injection", () => { async function startServerWithDisabledTelemetry(dir: string) { const origin = await startServer(dir, { disableTelemetry: true }); return origin; } it("exposes window.__AGENT_CANVAS_DO_NOT_TRACK__ when enabled", async () => { const buildDir = mkdtempSync(path.join(tmpdir(), "agent-canvas-build-")); tempDirs.push(buildDir); writeFileSync( path.join(buildDir, "index.html"), "app", ); const origin = await startServerWithDisabledTelemetry(buildDir); const body = await (await fetch(`${origin}/`)).text(); expect(body).toContain("window.__AGENT_CANVAS_DO_NOT_TRACK__=true"); }); it("injects the flag into the SPA fallback index.html", async () => { const buildDir = mkdtempSync(path.join(tmpdir(), "agent-canvas-build-")); tempDirs.push(buildDir); writeFileSync( path.join(buildDir, "index.html"), "app", ); const origin = await startServerWithDisabledTelemetry(buildDir); const response = await fetch(`${origin}/some/deep/route`); const body = await response.text(); expect(response.status).toBe(200); expect(body).toContain("window.__AGENT_CANVAS_DO_NOT_TRACK__=true"); }); it("does not inject the flag when telemetry is not disabled", async () => { const buildDir = mkdtempSync(path.join(tmpdir(), "agent-canvas-build-")); tempDirs.push(buildDir); writeFileSync( path.join(buildDir, "index.html"), "app", ); const origin = await startServer(buildDir); const body = await (await fetch(`${origin}/`)).text(); expect(body).not.toContain("__AGENT_CANVAS_DO_NOT_TRACK__"); }); }); describe("session key injection", () => { async function startServerWithKey(dir: string, sessionApiKey: string) { const server = await startStaticServer({ port: 0, host: "127.0.0.1", dir, routes: {}, sessionApiKey, }); servers.push(server); const address = server.address(); if (!address || typeof address === "string") { throw new Error("Static server did not bind to a TCP port"); } return `http://127.0.0.1:${address.port}`; } it("injects session key script into index.html", async () => { const buildDir = mkdtempSync(path.join(tmpdir(), "agent-canvas-build-")); tempDirs.push(buildDir); writeFileSync( path.join(buildDir, "index.html"), "app", ); const origin = await startServerWithKey(buildDir, "test-session-key"); const response = await fetch(`${origin}/`); expect(response.status).toBe(200); const body = await response.text(); expect(body).toContain("openhands-agent-server-config"); expect(body).toContain("test-session-key"); expect(body).toContain("sessionApiKey"); }); // Regression test: the published `agent-canvas` binary builds without // VITE_SESSION_API_KEY baked in, so the React app reads the key from // `window.__AGENT_CANVAS_SESSION_API_KEY__` (see // `getBakedSessionApiKey()` in `src/api/agent-server-config.ts`). // Without this assignment, `makeDefaultLocalBackend()` returns null // on a fresh install and the user gets the Manage Backends modal // instead of onboarding. it("exposes the session key on window.__AGENT_CANVAS_SESSION_API_KEY__", async () => { const buildDir = mkdtempSync(path.join(tmpdir(), "agent-canvas-build-")); tempDirs.push(buildDir); writeFileSync( path.join(buildDir, "index.html"), "app", ); const origin = await startServerWithKey(buildDir, "runtime-key"); const response = await fetch(`${origin}/`); const body = await response.text(); expect(body).toContain("window.__AGENT_CANVAS_SESSION_API_KEY__"); expect(body).toContain('"runtime-key"'); // The window assignment must precede the localStorage write so the // global is set even if storage access throws (private mode, etc.). const windowIdx = body.indexOf("__AGENT_CANVAS_SESSION_API_KEY__"); const localStorageIdx = body.indexOf("openhands-agent-server-config"); expect(windowIdx).toBeGreaterThan(-1); expect(localStorageIdx).toBeGreaterThan(-1); expect(windowIdx).toBeLessThan(localStorageIdx); }); it("injects session key into SPA fallback index.html", async () => { const buildDir = mkdtempSync(path.join(tmpdir(), "agent-canvas-build-")); tempDirs.push(buildDir); writeFileSync( path.join(buildDir, "index.html"), "app", ); const origin = await startServerWithKey(buildDir, "fallback-key"); const response = await fetch(`${origin}/some/deep/route`); expect(response.status).toBe(200); const body = await response.text(); expect(body).toContain("fallback-key"); }); it("does not inject into non-html asset responses", async () => { const buildDir = mkdtempSync(path.join(tmpdir(), "agent-canvas-build-")); tempDirs.push(buildDir); mkdirSync(path.join(buildDir, "assets")); writeFileSync( path.join(buildDir, "index.html"), "app", ); writeFileSync( path.join(buildDir, "assets", "app.js"), "console.log('app');", ); const origin = await startServerWithKey(buildDir, "should-not-inject"); const response = await fetch(`${origin}/assets/app.js`); expect(response.status).toBe(200); const body = await response.text(); expect(body).not.toContain("should-not-inject"); }); it("sets Cache-Control: no-store when session key is injected", async () => { const buildDir = mkdtempSync(path.join(tmpdir(), "agent-canvas-build-")); tempDirs.push(buildDir); writeFileSync( path.join(buildDir, "index.html"), "app", ); const origin = await startServerWithKey(buildDir, "cache-test-key"); const response = await fetch(`${origin}/`); expect(response.headers.get("cache-control")).toBe("no-store"); }); it("sets Cache-Control: no-cache when session key is not present but other config is injected", 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: {}, authRequired: true, }); 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 as { port: number }).port}`; const response = await fetch(`${origin}/`); expect(response.headers.get("cache-control")).toBe("no-cache"); }); it("escapes HTML special characters and script closing tags in injected values", async () => { const buildDir = mkdtempSync(path.join(tmpdir(), "agent-canvas-build-")); tempDirs.push(buildDir); writeFileSync( path.join(buildDir, "index.html"), "app", ); const payload = ''; const origin = await startServerWithKey(buildDir, payload); const response = await fetch(`${origin}/`); const body = await response.text(); expect(body).not.toContain("