| 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<void>((resolve) => { |
| server.close(() => resolve()); |
| }), |
| ), |
| ); |
|
|
| for (const dir of tempDirs.splice(0)) { |
| rmSync(dir, { recursive: true, force: true }); |
| } |
| }); |
|
|
| async function startServer( |
| dir: string, |
| overrides: Partial<Parameters<typeof startStaticServer>[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<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([]); |
| 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 = "</script><script>alert(1)</script>"; |
| 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: "<test>", 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 '\//, |
| ); |
| }); |
|
|
| |
| |
| |
| |
| |
| 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"), |
| "<html><head></head><body>app</body></html>", |
| ); |
| return buildDir; |
| } |
|
|
| |
| |
| |
| 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"', |
| ); |
| }); |
|
|
| |
| |
| 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}`; |
| } |
|
|
| |
| |
| |
| 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"), |
| "<html><head></head><body>app</body></html>", |
| ); |
|
|
| 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__"); |
| |
| |
| 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"), |
| "<html><head></head><body>app</body></html>", |
| ); |
|
|
| 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"), |
| "<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", () => { |
| 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"), |
| "<html><head></head><body>app</body></html>", |
| ); |
|
|
| 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"), |
| "<html><head></head><body>app</body></html>", |
| ); |
|
|
| 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"), |
| "<html><head></head><body>app</body></html>", |
| ); |
|
|
| 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"), |
| "<html><head></head><body>app</body></html>", |
| ); |
|
|
| 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"), |
| "<html><head></head><body>app</body></html>", |
| ); |
|
|
| 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"), |
| "<html><head></head><body>app</body></html>", |
| ); |
|
|
| 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"); |
| }); |
|
|
| |
| |
| |
| |
| |
| |
| |
| 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"), |
| "<html><head></head><body>app</body></html>", |
| ); |
|
|
| 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"'); |
| |
| |
| 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"), |
| "<html><head></head><body>app</body></html>", |
| ); |
|
|
| 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"), |
| "<html><head></head><body>app</body></html>", |
| ); |
| 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"), |
| "<html><head></head><body>app</body></html>", |
| ); |
|
|
| 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"), |
| "<html><head></head><body>app</body></html>", |
| ); |
|
|
| 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"), |
| "<html><head></head><body>app</body></html>", |
| ); |
|
|
| const payload = '</script><script>alert("xss")</script>'; |
| const origin = await startServerWithKey(buildDir, payload); |
| const response = await fetch(`${origin}/`); |
| const body = await response.text(); |
|
|
| expect(body).not.toContain("</script><script>"); |
| expect(body).toContain("\\u003c/script\\u003e\\u003cscript\\u003e"); |
| }); |
|
|
| it("does not inject when sessionApiKey is null", 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 server = await startStaticServer({ |
| port: 0, |
| host: "127.0.0.1", |
| dir: buildDir, |
| routes: {}, |
| sessionApiKey: 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 response = await fetch(`${origin}/`); |
| const body = await response.text(); |
| expect(body).not.toContain("openhands-agent-server-config"); |
| expect(body).not.toContain("__AGENT_CANVAS_SESSION_API_KEY__"); |
| }); |
|
|
| it("injects session key into HTML without </head> tag (falls back to </body>)", async () => { |
| const buildDir = mkdtempSync(path.join(tmpdir(), "agent-canvas-build-")); |
| tempDirs.push(buildDir); |
| writeFileSync( |
| path.join(buildDir, "index.html"), |
| "<html><body>no-head</body></html>", |
| ); |
|
|
| const origin = await startServerWithKey(buildDir, "no-head-key"); |
| const response = await fetch(`${origin}/`); |
|
|
| expect(response.status).toBe(200); |
| const body = await response.text(); |
| expect(body).toContain("no-head-key"); |
| expect(body).toContain("openhands-agent-server-config"); |
| |
| expect(body.indexOf("no-head-key")).toBeLessThan(body.indexOf("</body>")); |
| expect(body.indexOf("no-head-key")).toBeGreaterThan(0); |
| }); |
| }); |
|
|
| it("serves nested build assets on all platforms", async () => { |
| const buildDir = mkdtempSync(path.join(tmpdir(), "agent-canvas-build-")); |
| tempDirs.push(buildDir); |
| mkdirSync(path.join(buildDir, "assets")); |
| writeFileSync(path.join(buildDir, "index.html"), "<main>app</main>"); |
| writeFileSync( |
| path.join(buildDir, "assets", "entry.client-test.js"), |
| "export const loaded = true;\n", |
| ); |
|
|
| const origin = await startServer(buildDir); |
| const response = await fetch(`${origin}/assets/entry.client-test.js`); |
|
|
| expect(response.status).toBe(200); |
| expect(response.headers.get("content-type")).toContain( |
| "application/javascript", |
| ); |
| await expect(response.text()).resolves.toContain("loaded = true"); |
| }); |
|
|
| describe("base path mounting", () => { |
| it("serves index.html and injects the base path under the mount", 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 origin = await startServer(buildDir, { basePath: "/canvas" }); |
| const response = await fetch(`${origin}/canvas/conversations/abc`); |
| const body = await response.text(); |
|
|
| expect(response.status).toBe(200); |
| expect(body).toContain("window.__AGENT_CANVAS_BASE_PATH__"); |
| expect(body).toContain('"/canvas"'); |
| }); |
|
|
| it("serves static assets from underneath the mount", async () => { |
| const buildDir = mkdtempSync(path.join(tmpdir(), "agent-canvas-build-")); |
| tempDirs.push(buildDir); |
| mkdirSync(path.join(buildDir, "assets")); |
| writeFileSync(path.join(buildDir, "index.html"), "<main>app</main>"); |
| writeFileSync( |
| path.join(buildDir, "assets", "entry.client-test.js"), |
| "export const loaded = true;\n", |
| ); |
|
|
| const origin = await startServer(buildDir, { basePath: "/canvas" }); |
| const response = await fetch( |
| `${origin}/canvas/assets/entry.client-test.js`, |
| ); |
|
|
| expect(response.status).toBe(200); |
| expect(response.headers.get("content-type")).toContain( |
| "application/javascript", |
| ); |
| await expect(response.text()).resolves.toContain("loaded = true"); |
| }); |
|
|
| it("redirects app routes outside the mount to the configured base path", async () => { |
| const buildDir = mkdtempSync(path.join(tmpdir(), "agent-canvas-build-")); |
| tempDirs.push(buildDir); |
| writeFileSync(path.join(buildDir, "index.html"), "<main>app</main>"); |
|
|
| const origin = await startServer(buildDir, { basePath: "/canvas" }); |
| const response = await fetch(`${origin}/conversations/abc?tab=files`, { |
| redirect: "manual", |
| }); |
|
|
| expect(response.status).toBe(308); |
| expect(response.headers.get("location")).toBe( |
| "/canvas/conversations/abc?tab=files", |
| ); |
| }); |
|
|
| it("does not redirect asset-like requests outside the configured mount", async () => { |
| const buildDir = mkdtempSync(path.join(tmpdir(), "agent-canvas-build-")); |
| tempDirs.push(buildDir); |
| writeFileSync(path.join(buildDir, "index.html"), "<main>app</main>"); |
|
|
| const origin = await startServer(buildDir, { basePath: "/canvas" }); |
| const response = await fetch(`${origin}/assets/missing.js`, { |
| redirect: "manual", |
| }); |
|
|
| expect(response.status).toBe(404); |
| }); |
| }); |
|
|
| describe("--no-referrer-prefix", () => { |
| it("defaults to none", () => { |
| expect(parseArgs([]).noReferrerPrefixes).toEqual([]); |
| }); |
|
|
| it("collects repeated prefixes", () => { |
| const config = parseArgs([ |
| "--no-referrer-prefix", |
| "/vscode", |
| "--no-referrer-prefix", |
| "/editor", |
| ]); |
| expect(config.noReferrerPrefixes).toEqual(["/vscode", "/editor"]); |
| }); |
|
|
| it("rejects a prefix without a leading slash", () => { |
| expect(() => parseArgs(["--no-referrer-prefix", "vscode"])).toThrow( |
| /must start with/, |
| ); |
| }); |
|
|
| |
| |
| |
| }); |
|
|
| it("keeps paths confined to the static directory", async () => { |
| const parentDir = mkdtempSync(path.join(tmpdir(), "agent-canvas-parent-")); |
| tempDirs.push(parentDir); |
| const buildDir = path.join(parentDir, "build"); |
| mkdirSync(buildDir); |
| writeFileSync(path.join(buildDir, "index.html"), "<main>app</main>"); |
| writeFileSync(path.join(parentDir, "secret.txt"), "secret\n"); |
|
|
| const origin = await startServer(buildDir); |
| const response = await fetch(`${origin}/../secret.txt`); |
|
|
| 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); |
| }); |
| }); |
|
|