// Gateway hosted web tests cover Control UI and public plugin routes on one real listener. import fs from "node:fs/promises"; import path from "node:path"; import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api"; import { afterEach, describe, expect, it, vi } from "vitest"; import adminHttpRpcPlugin from "../../../../extensions/admin-http-rpc/index.js"; import canvasPlugin from "../../../../extensions/canvas/index.js"; import { clearConfigCache, clearRuntimeConfigSnapshot } from "../../../../src/config/config.js"; import type { OpenClawConfig } from "../../../../src/config/types.openclaw.js"; import { startGatewayServer } from "../../../../src/gateway/server.js"; import { connectGatewayClient, disconnectGatewayClient, getGatewayE2ePortBlock, } from "../../../../src/gateway/test-helpers.e2e.js"; import { snapshotGatewayStartupEnv } from "../../../../src/gateway/test-helpers.env.js"; import { registerPluginHttpRoute, withPluginHttpRouteRegistry, } from "../../../../src/plugins/http-registry.js"; import { getActivePluginRegistry, resetPluginRuntimeStateForTest, } from "../../../../src/plugins/runtime.js"; import { withEnvAsync } from "../../../../src/test-utils/env.js"; import { useAutoCleanupTempDirTracker } from "../../../helpers/temp-dir.js"; const TOKEN = "qa-hosted-web-token"; const OPERATOR_SCOPES = ["operator.read", "operator.write", "operator.admin"]; const tempDirs = useAutoCleanupTempDirTracker(afterEach); afterEach(async () => { vi.useRealTimers(); clearConfigCache(); clearRuntimeConfigSnapshot(); resetPluginRuntimeStateForTest(); }); function replaceCapability(scopedUrl: string, capability: string): string { const url = new URL(scopedUrl); const segments = url.pathname.split("/"); const capabilityIndex = segments.findIndex((segment) => segment === "cap") + 1; if (capabilityIndex <= 0 || capabilityIndex >= segments.length) { throw new Error(`expected capability-scoped URL, received ${url.pathname}`); } segments[capabilityIndex] = encodeURIComponent(capability); url.pathname = segments.join("/"); return url.toString().replace(/\/$/, ""); } describe("Gateway hosted web surfaces", () => { it( "serves the Control UI, admin RPC, and capability-scoped A2UI assets", { timeout: 90_000 }, async () => { const root = tempDirs.make("openclaw-qa-hosted-web-"); const stateDir = path.join(root, "state"); const controlUiRoot = path.join(root, "control-ui"); const configPath = path.join(root, "openclaw.json"); await Promise.all([ fs.mkdir(stateDir, { recursive: true }), fs.mkdir(controlUiRoot, { recursive: true }), ]); await fs.writeFile( path.join(controlUiRoot, "index.html"), "Gateway hosted Control UI", "utf8", ); const config: OpenClawConfig = { gateway: { mode: "local", bind: "loopback", auth: { mode: "token", token: TOKEN }, controlUi: { enabled: true, root: controlUiRoot }, }, }; await fs.writeFile(configPath, `${JSON.stringify(config)}\n`, "utf8"); await withEnvAsync( { ...snapshotGatewayStartupEnv(), HOME: root, OPENCLAW_CONFIG_PATH: configPath, OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1", OPENCLAW_HOME: root, OPENCLAW_SKIP_BROWSER_CONTROL_SERVER: "1", OPENCLAW_SKIP_CHANNELS: "1", OPENCLAW_SKIP_CRON: "1", OPENCLAW_SKIP_GMAIL_WATCHER: "1", OPENCLAW_SKIP_PROVIDERS: "1", OPENCLAW_STATE_DIR: stateDir, }, async () => { clearConfigCache(); clearRuntimeConfigSnapshot(); const port = await getGatewayE2ePortBlock(); const server = await startGatewayServer(port, { auth: { mode: "token", token: TOKEN }, bind: "loopback", controlUiEnabled: true, sidecarStartup: "defer", }); // Deferred startup replaces the bootstrap registry; register routes only after the // server publishes the settled runtime so requests do not target a retired registry. await server.startupSettled; const registry = getActivePluginRegistry(); if (!registry) { throw new Error("gateway did not publish an active plugin registry"); } const routeCleanups: Array<() => void> = []; const services: Array<{ stop?: (context: never) => void | Promise }> = []; let operator: Awaited> | undefined; const registerEntry = ( pluginId: string, entry: typeof adminHttpRpcPlugin, pluginConfig: Record = {}, ) => { entry.register( createTestPluginApi({ id: pluginId, name: pluginId, config, pluginConfig, registerHttpRoute: (route) => { const routeCount = registry.httpRoutes.length; routeCleanups.push( registerPluginHttpRoute({ ...route, pluginId, registry, source: `extensions/${pluginId}/index.ts`, }), ); if (pluginId === "admin-http-rpc") { const registeredRoute = registry.httpRoutes[routeCount]; if (registeredRoute) { registeredRoute.gatewayMethodDispatchAllowed = true; } } const registeredRoute = registry.httpRoutes[routeCount]; if (registeredRoute && route.handleUpgrade) { registeredRoute.handleUpgrade = route.handleUpgrade; } if (registeredRoute && route.nodeCapability) { registeredRoute.nodeCapability = { ...route.nodeCapability }; } }, registerService: (service) => services.push(service), }), ); }; try { registerEntry("admin-http-rpc", adminHttpRpcPlugin); registerEntry("canvas", canvasPlugin, { host: { enabled: true } }); expect(registry.httpRoutes.map((route) => route.path)).toEqual( expect.arrayContaining(["/api/v1/admin/rpc", "/__openclaw__/a2ui"]), ); const origin = `http://127.0.0.1:${port}`; const controlUi = await fetch(`${origin}/`, { headers: { authorization: `Bearer ${TOKEN}` }, }); expect(controlUi.status).toBe(200); expect(controlUi.headers.get("content-type")).toContain("text/html"); expect(controlUi.headers.get("x-content-type-options")).toBe("nosniff"); expect(controlUi.headers.get("x-frame-options")).toBe("DENY"); expect(controlUi.headers.get("referrer-policy")).toBe("no-referrer"); expect(controlUi.headers.get("content-security-policy")).toContain( "frame-ancestors 'none'", ); expect(await controlUi.text()).toContain("Gateway hosted Control UI"); const unauthorizedAdmin = await fetch(`${origin}/api/v1/admin/rpc`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ id: "unauthorized", method: "health", params: {} }), }); expect(unauthorizedAdmin.status).toBe(401); const admin = await fetch(`${origin}/api/v1/admin/rpc`, { method: "POST", headers: { accept: "text/html", authorization: `Bearer ${TOKEN}`, "content-type": "application/json", }, body: JSON.stringify({ id: "health", method: "health", params: {} }), }); expect(admin.status).toBe(200); expect(admin.headers.get("content-type")).toContain("application/json"); expect(await admin.json()).toMatchObject({ id: "health", ok: true, payload: { ok: true }, }); const capabilityIssuedAtMs = Date.now(); vi.useFakeTimers({ toFake: ["Date"] }); vi.setSystemTime(capabilityIssuedAtMs); let helloCanvasUrl: string | undefined; operator = await connectGatewayClient({ url: `ws://127.0.0.1:${port}`, token: TOKEN, role: "operator", scopes: OPERATOR_SCOPES, onHelloOk: (hello) => { helloCanvasUrl = hello.pluginSurfaceUrls?.canvas; }, }); expect(helloCanvasUrl).toMatch( /^http:\/\/127\.0\.0\.1:\d+\/__openclaw__\/cap\/[^/]+$/u, ); vi.useRealTimers(); const scopedCanvasUrl = helloCanvasUrl ?? ""; const wrongCapabilityUrl = replaceCapability(scopedCanvasUrl, "wrong-capability"); await withEnvAsync({ NODE_ENV: undefined, VITEST: undefined }, async () => { for (const fileName of ["a2ui.bundle.js", "a2ui-v0.9.bundle.js"]) { const asset = await fetch(`${scopedCanvasUrl}/__openclaw__/a2ui/${fileName}`); expect(asset.status).toBe(200); expect(asset.headers.get("content-type")).toContain("javascript"); expect((await asset.text()).length).toBeGreaterThan(100); } const wrongCapability = await fetch( `${wrongCapabilityUrl}/__openclaw__/a2ui/a2ui.bundle.js`, ); expect(wrongCapability.status).toBe(401); vi.useFakeTimers({ toFake: ["Date"] }); vi.setSystemTime(capabilityIssuedAtMs + 24 * 60 * 60_000); const expiredCapability = await fetch( `${scopedCanvasUrl}/__openclaw__/a2ui/a2ui.bundle.js`, ); expect(expiredCapability.status).toBe(401); }); } finally { if (operator) { await disconnectGatewayClient(operator).catch(() => undefined); } for (const service of services.toReversed()) { await withPluginHttpRouteRegistry(registry, async () => { await service.stop?.({} as never); }); } for (const cleanup of routeCleanups.toReversed()) { cleanup(); } await server.close(); } }, ); }, ); });