import { createHash } from "node:crypto"; import fs from "node:fs/promises"; import type { IncomingMessage } from "node:http"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; import type { ResolvedGatewayAuth } from "./auth.js"; import { CONTROL_UI_BOOTSTRAP_CONFIG_PATH } from "./control-ui-contract.js"; import { handleControlUiAssistantMediaRequest, handleControlUiAvatarRequest, handleControlUiHttpRequest, } from "./control-ui.js"; import { makeMockHttpResponse } from "./test-http-response.js"; describe("handleControlUiHttpRequest", () => { async function withControlUiRoot(params: { indexHtml?: string; fn: (tmp: string) => Promise; }) { const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-")); try { await fs.writeFile(path.join(tmp, "index.html"), params.indexHtml ?? "\n"); return await params.fn(tmp); } finally { await fs.rm(tmp, { recursive: true, force: true }); } } function parseBootstrapPayload(end: ReturnType["end"]) { return JSON.parse(String(end.mock.calls[0]?.[0] ?? "")) as { basePath: string; assistantName: string; assistantAvatar: string; assistantAgentId: string; localMediaPreviewRoots?: string[]; }; } function expectNotFoundResponse(params: { handled: boolean; res: ReturnType["res"]; end: ReturnType["end"]; }) { expect(params.handled).toBe(true); expect(params.res.statusCode).toBe(404); expect(params.end).toHaveBeenCalledWith("Not Found"); } function runControlUiRequest(params: { url: string; method: "GET" | "HEAD" | "POST"; rootPath: string; basePath?: string; rootKind?: "resolved" | "bundled"; }) { const { res, end } = makeMockHttpResponse(); const handled = handleControlUiHttpRequest( { url: params.url, method: params.method } as IncomingMessage, res, { ...(params.basePath ? { basePath: params.basePath } : {}), root: { kind: params.rootKind ?? "resolved", path: params.rootPath }, }, ); return { res, end, handled }; } function runAvatarRequest(params: { url: string; method: "GET" | "HEAD"; resolveAvatar: Parameters[2]["resolveAvatar"]; basePath?: string; }) { const { res, end } = makeMockHttpResponse(); const handled = handleControlUiAvatarRequest( { url: params.url, method: params.method } as IncomingMessage, res, { ...(params.basePath ? { basePath: params.basePath } : {}), resolveAvatar: params.resolveAvatar, }, ); return { res, end, handled }; } async function runAssistantMediaRequest(params: { url: string; method: "GET" | "HEAD"; basePath?: string; auth?: ResolvedGatewayAuth; headers?: IncomingMessage["headers"]; trustedProxies?: string[]; remoteAddress?: string; }) { const { res, end } = makeMockHttpResponse(); const handled = await handleControlUiAssistantMediaRequest( { url: params.url, method: params.method, headers: params.headers ?? {}, socket: { remoteAddress: params.remoteAddress ?? "127.0.0.1" }, } as IncomingMessage, res, { ...(params.basePath ? { basePath: params.basePath } : {}), ...(params.auth ? { auth: params.auth } : {}), ...(params.trustedProxies ? { trustedProxies: params.trustedProxies } : {}), }, ); return { res, end, handled }; } async function writeAssetFile(rootPath: string, filename: string, contents: string) { const assetsDir = path.join(rootPath, "assets"); await fs.mkdir(assetsDir, { recursive: true }); const filePath = path.join(assetsDir, filename); await fs.writeFile(filePath, contents); return { assetsDir, filePath }; } async function createHardlinkedAssetFile(rootPath: string) { const { filePath } = await writeAssetFile(rootPath, "app.js", "console.log('hi');"); const hardlinkPath = path.join(path.dirname(filePath), "app.hl.js"); await fs.link(filePath, hardlinkPath); return hardlinkPath; } async function withAllowedAssistantMediaRoot(params: { prefix: string; fn: (tmpRoot: string) => Promise; }) { const tmpRoot = await fs.mkdtemp(path.join(resolvePreferredOpenClawTmpDir(), params.prefix)); try { return await params.fn(tmpRoot); } finally { await fs.rm(tmpRoot, { recursive: true, force: true }); } } async function withBasePathRootFixture(params: { siblingDir: string; fn: (paths: { root: string; sibling: string }) => Promise; }) { const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-root-")); try { const root = path.join(tmp, "ui"); const sibling = path.join(tmp, params.siblingDir); await fs.mkdir(root, { recursive: true }); await fs.mkdir(sibling, { recursive: true }); await fs.writeFile(path.join(root, "index.html"), "ok\n"); return await params.fn({ root, sibling }); } finally { await fs.rm(tmp, { recursive: true, force: true }); } } it("sets security headers for Control UI responses", async () => { await withControlUiRoot({ fn: async (tmp) => { const { res, setHeader } = makeMockHttpResponse(); const handled = handleControlUiHttpRequest( { url: "/", method: "GET" } as IncomingMessage, res, { root: { kind: "resolved", path: tmp }, }, ); expect(handled).toBe(true); expect(setHeader).toHaveBeenCalledWith("X-Frame-Options", "DENY"); const csp = setHeader.mock.calls.find((call) => call[0] === "Content-Security-Policy")?.[1]; expect(typeof csp).toBe("string"); expect(String(csp)).toContain("frame-ancestors 'none'"); expect(String(csp)).toContain("script-src 'self'"); expect(String(csp)).not.toContain("script-src 'self' 'unsafe-inline'"); }, }); }); it("serves assistant local media through the control ui media route", async () => { await withAllowedAssistantMediaRoot({ prefix: "ui-media-", fn: async (tmpRoot) => { const filePath = path.join(tmpRoot, "photo.png"); await fs.writeFile(filePath, Buffer.from("not-a-real-png")); const { res, handled } = await runAssistantMediaRequest({ url: `/__openclaw__/assistant-media?source=${encodeURIComponent(filePath)}&token=test-token`, method: "GET", auth: { mode: "token", token: "test-token", allowTailscale: false }, }); expect(handled).toBe(true); expect(res.statusCode).toBe(200); }, }); }); it("rejects assistant local media outside allowed preview roots", async () => { const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-media-blocked-")); try { const filePath = path.join(tmp, "photo.png"); await fs.writeFile(filePath, Buffer.from("not-a-real-png")); const { res, handled, end } = await runAssistantMediaRequest({ url: `/__openclaw__/assistant-media?source=${encodeURIComponent(filePath)}&token=test-token`, method: "GET", auth: { mode: "token", token: "test-token", allowTailscale: false }, }); expectNotFoundResponse({ handled, res, end }); } finally { await fs.rm(tmp, { recursive: true, force: true }); } }); it("reports assistant local media availability metadata", async () => { await withAllowedAssistantMediaRoot({ prefix: "ui-media-meta-", fn: async (tmpRoot) => { const filePath = path.join(tmpRoot, "photo.png"); await fs.writeFile(filePath, Buffer.from("not-a-real-png")); const { res, handled, end } = await runAssistantMediaRequest({ url: `/__openclaw__/assistant-media?meta=1&source=${encodeURIComponent(filePath)}&token=test-token`, method: "GET", auth: { mode: "token", token: "test-token", allowTailscale: false }, }); expect(handled).toBe(true); expect(res.statusCode).toBe(200); expect(JSON.parse(String(end.mock.calls[0]?.[0] ?? ""))).toEqual({ available: true }); }, }); }); it("reports assistant local media availability failures with a reason", async () => { const { res, handled, end } = await runAssistantMediaRequest({ url: `/__openclaw__/assistant-media?meta=1&source=${encodeURIComponent("/Users/test/Documents/private.pdf")}&token=test-token`, method: "GET", auth: { mode: "token", token: "test-token", allowTailscale: false }, }); expect(handled).toBe(true); expect(res.statusCode).toBe(200); expect(JSON.parse(String(end.mock.calls[0]?.[0] ?? ""))).toEqual({ available: false, code: "outside-allowed-folders", reason: "Outside allowed folders", }); }); it("rejects assistant local media without a valid auth token when auth is enabled", async () => { await withAllowedAssistantMediaRoot({ prefix: "ui-media-auth-", fn: async (tmpRoot) => { const filePath = path.join(tmpRoot, "photo.png"); await fs.writeFile(filePath, Buffer.from("not-a-real-png")); const { res, handled, end } = await runAssistantMediaRequest({ url: `/__openclaw__/assistant-media?source=${encodeURIComponent(filePath)}`, method: "GET", auth: { mode: "token", token: "test-token", allowTailscale: false }, }); expect(handled).toBe(true); expect(res.statusCode).toBe(401); expect(String(end.mock.calls[0]?.[0] ?? "")).toContain("Unauthorized"); }, }); }); it("rejects trusted-proxy assistant media requests from disallowed browser origins", async () => { await withAllowedAssistantMediaRoot({ prefix: "ui-media-proxy-", fn: async (tmpRoot) => { const filePath = path.join(tmpRoot, "photo.png"); await fs.writeFile(filePath, Buffer.from("not-a-real-png")); const { res, handled, end } = await runAssistantMediaRequest({ url: `/__openclaw__/assistant-media?source=${encodeURIComponent(filePath)}`, method: "GET", auth: { mode: "trusted-proxy", allowTailscale: false, trustedProxy: { userHeader: "x-forwarded-user", }, }, trustedProxies: ["10.0.0.1"], remoteAddress: "10.0.0.1", headers: { host: "gateway.example.com", origin: "https://evil.example", "x-forwarded-user": "nick@example.com", "x-forwarded-proto": "https", }, }); expect(handled).toBe(true); expect(res.statusCode).toBe(401); expect(String(end.mock.calls[0]?.[0] ?? "")).toContain("Unauthorized"); }, }); }); it("includes CSP hash for inline scripts in index.html", async () => { const scriptContent = "(function(){ var x = 1; })();"; const html = `\n`; const expectedHash = createHash("sha256").update(scriptContent, "utf8").digest("base64"); await withControlUiRoot({ indexHtml: html, fn: async (tmp) => { const { res, setHeader } = makeMockHttpResponse(); handleControlUiHttpRequest({ url: "/", method: "GET" } as IncomingMessage, res, { root: { kind: "resolved", path: tmp }, }); const cspCalls = setHeader.mock.calls.filter( (call) => call[0] === "Content-Security-Policy", ); const lastCsp = String(cspCalls[cspCalls.length - 1]?.[1] ?? ""); expect(lastCsp).toContain(`'sha256-${expectedHash}'`); expect(lastCsp).not.toMatch(/script-src[^;]*'unsafe-inline'/); }, }); }); it("does not inject inline scripts into index.html", async () => { const html = "Hello\n"; await withControlUiRoot({ indexHtml: html, fn: async (tmp) => { const { res, end } = makeMockHttpResponse(); const handled = handleControlUiHttpRequest( { url: "/", method: "GET" } as IncomingMessage, res, { root: { kind: "resolved", path: tmp }, config: { agents: { defaults: { workspace: tmp } }, ui: { assistant: { name: ".png" } }, }, }, ); expect(handled).toBe(true); const parsed = parseBootstrapPayload(end); expect(parsed.basePath).toBe(""); expect(parsed.assistantName).toBe("