From 0dc99706243ec4e1b340a0a13d78dc1f862b5bce Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 4 Aug 2026 08:16:22 +0800 Subject: [PATCH] test(ui): cover Control UI auth transports (#118866) * test(ui): cover control ui auth transports * test(qa): register control ui auth transport scenarios * test(ui): allow loaded control ui startup * test(ui): allow loaded screenshot capture * test(ui): scrub loopback proof URLs * test(ui): keep transport proof assertion-focused * test(ui): capture auth transport proof directly * test(ui): gate auth transport proof media * test(ui): scope auth transport evidence * test(ui): fix auth transport frame typing * test(ui): stabilize auth transport QA * test(ui): separate auth UI navigation readiness --- ...control-ui-allowed-origins-gatewayurl.yaml | 36 + .../ui/control-ui-trusted-proxy-auth.yaml | 33 + .../control-ui-auth-transports.e2e.test.ts | 687 ++++++++++++++++++ 3 files changed, 756 insertions(+) create mode 100644 qa/scenarios/ui/control-ui-allowed-origins-gatewayurl.yaml create mode 100644 qa/scenarios/ui/control-ui-trusted-proxy-auth.yaml create mode 100644 ui/src/e2e/control-ui-auth-transports.e2e.test.ts diff --git a/qa/scenarios/ui/control-ui-allowed-origins-gatewayurl.yaml b/qa/scenarios/ui/control-ui-allowed-origins-gatewayurl.yaml new file mode 100644 index 000000000000..4911a5218588 --- /dev/null +++ b/qa/scenarios/ui/control-ui-allowed-origins-gatewayurl.yaml @@ -0,0 +1,36 @@ +title: Control UI allowed origins and explicit gateway URL + +scenario: + id: control-ui-allowed-origins-gatewayurl + surface: control-ui + category: control-ui.browser-access-and-trust + coverage: + primary: + - control-ui.allowed-origins-gatewayurl + objective: >- + Prove real Chromium explicitly confirms a gatewayUrl, connects from the + configured browser origin, and receives deterministic visible recovery when + the real Gateway rejects an unlisted origin. + successCriteria: + - The Control UI visibly presents and confirms the explicit gatewayUrl before connecting. + - A browser served from the configured allowed origin reaches the connected shell through the real proxy and Gateway. + - A browser served from a distinct unlisted origin is rejected by Gateway origin enforcement and renders origin-specific recovery without mounting the shell. + - Captured browser Origin headers and Gateway responses or handshake status distinguish UI behavior from server-side enforcement. + - The run always writes redacted transport and process-cleanup evidence; when OPENCLAW_CAPTURE_UI_PROOF=1, it also writes screenshots and video under the configured artifact directory. + docsRefs: + - docs/web/control-ui.md + - docs/web/urls.md + - docs/gateway/trusted-proxy-auth.md + codeRefs: + - ui/src/e2e/control-ui-auth-transports.e2e.test.ts + - ui/src/components/gateway-url-confirmation.ts + - ui/src/components/login-gate.ts + - src/gateway/origin-check.ts + - src/gateway/server/ws-connection/connect-admission.ts + execution: + kind: playwright + path: ui/src/e2e/control-ui-auth-transports.e2e.test.ts + testNamePattern: confirms gatewayUrl, accepts the allowed origin, and rejects an unlisted origin + summary: >- + Real Chromium and Gateway proof for explicit target confirmation, + allowed-origin success, and deterministic rejected-origin recovery. diff --git a/qa/scenarios/ui/control-ui-trusted-proxy-auth.yaml b/qa/scenarios/ui/control-ui-trusted-proxy-auth.yaml new file mode 100644 index 000000000000..7abcaca30fb2 --- /dev/null +++ b/qa/scenarios/ui/control-ui-trusted-proxy-auth.yaml @@ -0,0 +1,33 @@ +title: Control UI trusted-proxy authentication + +scenario: + id: control-ui-trusted-proxy-auth + surface: control-ui + category: control-ui.browser-access-and-trust + coverage: + primary: + - control-ui.trusted-proxy-auth + objective: >- + Prove real Chromium connects through a task-owned reverse proxy whose + trusted path supplies the configured identity, while the same proxy's + untrusted path is rejected by the real Gateway with visible recovery. + successCriteria: + - A task-owned real Gateway runs with temporary home, state, and trusted-proxy configuration. + - Real Chromium reaches the connected Control UI shell through the proxy path that injects the configured identity and required header. + - The proxy path that omits proxy-owned auth headers receives a Gateway unauthorized rejection with the exact required-header reason captured from the protocol frame. + - Captured browser connect frames and Gateway responses distinguish UI behavior from server-side enforcement. + - The run always writes redacted transport and process-cleanup evidence; when OPENCLAW_CAPTURE_UI_PROOF=1, it also writes screenshots and video under the configured artifact directory. + docsRefs: + - docs/gateway/trusted-proxy-auth.md + - docs/web/control-ui.md + codeRefs: + - ui/src/e2e/control-ui-auth-transports.e2e.test.ts + - src/gateway/auth.ts + - src/gateway/server/ws-connection/connect-admission.ts + execution: + kind: playwright + path: ui/src/e2e/control-ui-auth-transports.e2e.test.ts + testNamePattern: connects through the trusted path and rejects the untrusted proxy path + summary: >- + Real Chromium, reverse-proxy, and Gateway proof for successful + trusted-proxy identity admission and required-header rejection. diff --git a/ui/src/e2e/control-ui-auth-transports.e2e.test.ts b/ui/src/e2e/control-ui-auth-transports.e2e.test.ts new file mode 100644 index 000000000000..29ecee86da63 --- /dev/null +++ b/ui/src/e2e/control-ui-auth-transports.e2e.test.ts @@ -0,0 +1,687 @@ +// Control UI tests prove trusted-proxy and browser-origin auth through real transports. +import { mkdir, writeFile } from "node:fs/promises"; +import { createServer, type IncomingMessage } from "node:http"; +import net from "node:net"; +import path from "node:path"; +import { chromium, type Browser, type BrowserContext, type Page } from "playwright"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { WebSocket, WebSocketServer, type RawData } from "ws"; +import { ConnectErrorDetailCodes } from "../../../packages/gateway-protocol/src/connect-error-details.js"; +import type { GatewayServer } from "../../../src/gateway/server.js"; +import { + createOpenClawTestState, + type OpenClawTestState, +} from "../../../src/test-utils/openclaw-test-state.js"; +import { + canRunPlaywrightChromium, + resolvePlaywrightChromiumExecutablePath, + startControlUiE2eServer, + type ControlUiE2eServer, +} from "../test-helpers/control-ui-e2e.ts"; + +const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath()); +const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath); +const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1"; +const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip; +const captureUiProofEnabled = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1"; +const artifactDir = path.resolve( + process.cwd(), + process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim() || + ".artifacts/control-ui-e2e/control-ui-auth-transports", +); +const viewport = { height: 900, width: 1280 }; +const trustedProxyUser = "qa-operator"; +const controlUiSettleTimeoutMs = 60_000; + +type ProxyRoute = "trusted" | "untrusted"; + +type BrowserConnectEvidence = { + authFields: string[]; + clientId: string | null; + clientMode: string | null; + hasDevice: boolean; + scopes: string[]; +}; + +type GatewayResultEvidence = { + errorCode: string | null; + errorReason: string | null; + helloType: string | null; + message: string | null; + ok: boolean; +}; + +type ProxyConnectionEvidence = { + browserConnect?: BrowserConnectEvidence; + browserOrigin: string | null; + gatewayResult?: GatewayResultEvidence; + identityInjected: boolean; + requiredHeaderInjected: boolean; + route: ProxyRoute; + upstreamHandshakeStatus?: number; +}; + +type RealTransportProxy = { + close: () => Promise; + evidence: ProxyConnectionEvidence[]; + port: number; + trustedUrl: string; + untrustedUrl: string; +}; + +type RealGateway = { + cleanup: () => Promise; + port: number; + server: GatewayServer; + state: OpenClawTestState; + url: string; +}; + +let browser: Browser; +let allowedUi: ControlUiE2eServer; +let rejectedUi: ControlUiE2eServer; +let gateway: RealGateway; +let proxy: RealTransportProxy; +const openContexts = new Set(); + +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function stringValue(value: unknown): string | null { + return typeof value === "string" ? value : null; +} + +function stringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((entry): entry is string => typeof entry === "string") + : []; +} + +function parseJsonFrame(data: RawData): Record | null { + try { + const text = Array.isArray(data) + ? Buffer.concat(data).toString("utf8") + : data instanceof ArrayBuffer + ? Buffer.from(data).toString("utf8") + : data.toString("utf8"); + return asRecord(JSON.parse(text)); + } catch { + return null; + } +} + +function captureBrowserConnect( + evidence: ProxyConnectionEvidence, + frame: Record, +): string | null { + if (frame.type !== "req" || frame.method !== "connect") { + return null; + } + const params = asRecord(frame.params); + const client = asRecord(params?.client); + const auth = asRecord(params?.auth); + evidence.browserConnect = { + authFields: auth ? Object.keys(auth).toSorted() : [], + clientId: stringValue(client?.id), + clientMode: stringValue(client?.mode), + hasDevice: asRecord(params?.device) !== null, + scopes: stringArray(params?.scopes).toSorted(), + }; + return stringValue(frame.id); +} + +function captureGatewayResult( + evidence: ProxyConnectionEvidence, + frame: Record, + connectRequestId: string | null, +): void { + if (frame.type !== "res" || stringValue(frame.id) !== connectRequestId) { + return; + } + const error = asRecord(frame.error); + const details = asRecord(error?.details); + const payload = asRecord(frame.payload); + evidence.gatewayResult = { + errorCode: stringValue(details?.code) ?? stringValue(error?.code), + errorReason: stringValue(details?.authReason) ?? stringValue(details?.reason), + helloType: stringValue(payload?.type), + message: stringValue(error?.message), + ok: frame.ok === true, + }; +} + +function sanitizeProxyEvidence(evidence: ProxyConnectionEvidence) { + return { + browserConnect: evidence.browserConnect, + browserOriginPresent: Boolean(evidence.browserOrigin), + gatewayResult: evidence.gatewayResult, + identityInjected: evidence.identityInjected, + requiredHeaderInjected: evidence.requiredHeaderInjected, + route: evidence.route, + upstreamHandshakeStatus: evidence.upstreamHandshakeStatus, + }; +} + +function startProxyConnection( + request: IncomingMessage, + browserSocket: WebSocket, + gatewayUrl: string, + evidence: ProxyConnectionEvidence, + activeSockets: Set, +): void { + const headers = + evidence.route === "trusted" + ? { + "x-forwarded-for": "192.0.2.10", + "x-forwarded-proto": "http", + "x-forwarded-user": trustedProxyUser, + } + : {}; + const upstream = new WebSocket(gatewayUrl, { + headers, + origin: evidence.browserOrigin ?? undefined, + }); + activeSockets.add(browserSocket); + activeSockets.add(upstream); + const pendingBrowserFrames: Array<{ data: RawData; isBinary: boolean }> = []; + let connectRequestId: string | null = null; + + browserSocket.on("message", (data, isBinary) => { + const frame = parseJsonFrame(data); + if (frame) { + connectRequestId = captureBrowserConnect(evidence, frame) ?? connectRequestId; + } + if (upstream.readyState === WebSocket.OPEN) { + upstream.send(data, { binary: isBinary }); + return; + } + pendingBrowserFrames.push({ data, isBinary }); + }); + upstream.on("open", () => { + for (const frame of pendingBrowserFrames.splice(0)) { + upstream.send(frame.data, { binary: frame.isBinary }); + } + }); + upstream.on("message", (data, isBinary) => { + const frame = parseJsonFrame(data); + if (frame) { + captureGatewayResult(evidence, frame, connectRequestId); + } + if (browserSocket.readyState === WebSocket.OPEN) { + browserSocket.send(data, { binary: isBinary }); + } + }); + upstream.on("unexpected-response", (_upstreamRequest, response) => { + evidence.upstreamHandshakeStatus = response.statusCode; + const body: Buffer[] = []; + response.on("data", (chunk) => body.push(Buffer.from(chunk))); + response.on("end", () => { + const reason = Buffer.concat(body).toString("utf8").trim() || "gateway rejected websocket"; + if (browserSocket.readyState === WebSocket.OPEN) { + browserSocket.close(1008, reason.slice(0, 120)); + } + }); + }); + upstream.on("close", (code, reason) => { + activeSockets.delete(upstream); + if (browserSocket.readyState === WebSocket.OPEN) { + browserSocket.close(code, reason.toString().slice(0, 120)); + } + }); + upstream.on("error", () => { + if (browserSocket.readyState === WebSocket.OPEN) { + browserSocket.close(1011, "gateway transport error"); + } + }); + browserSocket.on("close", () => { + activeSockets.delete(browserSocket); + if (upstream.readyState === WebSocket.OPEN || upstream.readyState === WebSocket.CONNECTING) { + upstream.close(); + } + }); + browserSocket.on("error", () => { + if (upstream.readyState === WebSocket.OPEN) { + upstream.close(); + } + }); + + request.socket.once("error", () => { + browserSocket.terminate(); + upstream.terminate(); + }); +} + +async function startRealTransportProxy(gatewayUrl: string): Promise { + const evidence: ProxyConnectionEvidence[] = []; + const activeSockets = new Set(); + const websocketServer = new WebSocketServer({ noServer: true }); + const server = createServer((_request, response) => { + response.writeHead(404).end(); + }); + + server.on("upgrade", (request, socket, head) => { + const pathname = new URL(request.url ?? "/", "http://localhost").pathname; + const route = + pathname === "/trusted" ? "trusted" : pathname === "/untrusted" ? "untrusted" : null; + if (!route) { + socket.destroy(); + return; + } + websocketServer.handleUpgrade(request, socket, head, (browserSocket) => { + const connectionEvidence: ProxyConnectionEvidence = { + browserOrigin: stringValue(request.headers.origin), + identityInjected: route === "trusted", + requiredHeaderInjected: route === "trusted", + route, + }; + evidence.push(connectionEvidence); + startProxyConnection(request, browserSocket, gatewayUrl, connectionEvidence, activeSockets); + }); + }); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("real-transport proxy did not bind a TCP port"); + } + const baseUrl = `ws://localhost:${address.port}`; + return { + close: async () => { + for (const socket of activeSockets) { + socket.terminate(); + } + activeSockets.clear(); + await new Promise((resolve, reject) => { + websocketServer.close(() => { + server.close((error) => (error ? reject(error) : resolve())); + }); + }); + }, + evidence, + port: address.port, + trustedUrl: `${baseUrl}/trusted`, + untrustedUrl: `${baseUrl}/untrusted`, + }; +} + +async function getFreePort(): Promise { + const server = net.createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + throw new Error("failed to allocate a Gateway port"); + } + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + return address.port; +} + +async function startRealGateway(allowedOrigin: string): Promise { + const port = await getFreePort(); + const state = await createOpenClawTestState({ + label: "control-ui-auth-transports", + layout: "home", + env: { + OPENCLAW_GATEWAY_PASSWORD: undefined, + OPENCLAW_GATEWAY_TOKEN: undefined, + OPENCLAW_SKIP_BROWSER_CONTROL_SERVER: "1", + OPENCLAW_SKIP_CANVAS_HOST: "1", + OPENCLAW_SKIP_CHANNELS: "1", + OPENCLAW_SKIP_CRON: "1", + OPENCLAW_SKIP_GMAIL_WATCHER: "1", + OPENCLAW_SKIP_PROVIDERS: "1", + OPENCLAW_TEST_MINIMAL_GATEWAY: "1", + VITEST: "1", + }, + }); + const trustedProxy = { + allowLoopback: true, + allowUsers: [trustedProxyUser], + deviceAutoApprove: { + enabled: true, + scopes: ["operator.approvals", "operator.questions", "operator.read", "operator.write"], + }, + requiredHeaders: ["x-forwarded-proto"], + userHeader: "x-forwarded-user", + }; + await state.writeConfig({ + gateway: { + auth: { + mode: "trusted-proxy", + trustedProxy, + }, + controlUi: { + allowedOrigins: [allowedOrigin], + enabled: false, + }, + port, + trustedProxies: ["127.0.0.1", "::1"], + }, + }); + state.applyEnv(); + try { + const { startGatewayServer } = await import("../../../src/gateway/server.js"); + const server = await startGatewayServer(port, { + auth: { + mode: "trusted-proxy", + trustedProxy, + }, + bind: "loopback", + controlUiEnabled: false, + sidecarStartup: "defer", + }); + return { + cleanup: async () => { + await server.close({ reason: "control ui auth transports test cleanup" }); + await state.cleanup(); + }, + port, + server, + state, + url: `ws://127.0.0.1:${port}`, + }; + } catch (error) { + await state.cleanup(); + throw error; + } +} + +function withGatewayUrl(baseUrl: string, gatewayUrl: string): string { + const url = new URL("settings/connection", baseUrl); + url.searchParams.set("gatewayUrl", gatewayUrl); + return url.toString(); +} + +async function createBrowserPage( + baseUrl: string, + gatewayUrl: string, +): Promise<{ + context: BrowserContext; + evidenceStartIndex: number; + page: Page; +}> { + await mkdir(artifactDir, { recursive: true }); + const context = await browser.newContext({ + locale: "en-US", + recordVideo: captureUiProofEnabled ? { dir: artifactDir, size: viewport } : undefined, + serviceWorkers: "block", + viewport, + }); + openContexts.add(context); + const page = await context.newPage(); + page.setDefaultTimeout(15_000); + const evidenceStartIndex = proxy.evidence.length; + const response = await page.goto(withGatewayUrl(baseUrl, gatewayUrl), { + timeout: controlUiSettleTimeoutMs, + waitUntil: "domcontentloaded", + }); + expect(response?.status()).toBe(200); + // Source-served UI startup shares CI shard CPU. Bound navigation and the + // first rendered interaction separately; transport assertions stay narrow. + const confirmation = page.locator("openclaw-gateway-url-confirmation"); + await confirmation.waitFor({ timeout: controlUiSettleTimeoutMs }); + expect(await confirmation.textContent()).toContain(gatewayUrl); + expect(proxy.evidence).toHaveLength(evidenceStartIndex); + await confirmation + .getByRole("button", { name: "Confirm", exact: true }) + .click({ timeout: controlUiSettleTimeoutMs }); + await expect + .poll(() => proxy.evidence.length, { timeout: 15_000 }) + .toBeGreaterThan(evidenceStartIndex); + return { context, evidenceStartIndex, page }; +} + +async function warmControlUiSource(baseUrl: string): Promise { + const context = await browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport, + }); + try { + const page = await context.newPage(); + page.setDefaultTimeout(60_000); + const response = await page.goto(withGatewayUrl(baseUrl, proxy.trustedUrl)); + expect(response?.status()).toBe(200); + await page.locator("openclaw-gateway-url-confirmation").waitFor(); + } finally { + await context.close(); + } +} + +async function closeContext(context: BrowserContext): Promise { + openContexts.delete(context); + await context.close(); +} + +async function captureChromiumScreenshot(page: Page, fileName: string): Promise { + if (!captureUiProofEnabled) { + return; + } + const session = await page.context().newCDPSession(page); + try { + // The live dashboard keeps rendering while RPCs settle. Capture the current + // Chromium surface directly so proof does not wait on unrelated UI activity. + const result = await session.send("Page.captureScreenshot", { + captureBeyondViewport: false, + format: "png", + fromSurface: true, + }); + await writeFile(path.join(artifactDir, fileName), Buffer.from(result.data, "base64")); + } finally { + await session.detach(); + } +} + +async function waitForConnectionEvidence( + predicate: (entry: ProxyConnectionEvidence) => boolean, + evidenceStartIndex: number, +): Promise { + const currentPageEvidence = () => proxy.evidence.slice(evidenceStartIndex); + await expect.poll(() => currentPageEvidence().some(predicate), { timeout: 15_000 }).toBe(true); + const entry = currentPageEvidence().find(predicate); + if (!entry) { + throw new Error("expected reverse-proxy connection evidence"); + } + return entry; +} + +async function waitForVisibleFailure(page: Page, expectedText: string): Promise { + const failure = page.locator(".login-gate__failure"); + await failure.waitFor(); + expect(await failure.getAttribute("role")).toBe("alert"); + const raw = (await failure.locator(".login-gate__failure-raw").textContent()) ?? ""; + expect(raw.toLowerCase()).toContain(expectedText.toLowerCase()); + expect(await failure.locator(".login-gate__failure-steps").isVisible()).toBe(true); + expect(await page.locator("openclaw-app-shell").count()).toBe(0); + return raw; +} + +async function isPortClosed(host: string, port: number): Promise { + return await new Promise((resolve) => { + const socket = net.createConnection({ host, port }); + const finish = (closed: boolean) => { + socket.destroy(); + resolve(closed); + }; + socket.once("connect", () => finish(false)); + socket.once("error", () => finish(true)); + socket.setTimeout(1_000, () => finish(true)); + }); +} + +describeControlUiE2e("Control UI real auth transports E2E", () => { + beforeAll(async () => { + if (!chromiumAvailable) { + throw new Error( + `Playwright Chromium is not installed or cannot start at ${chromiumExecutablePath}.`, + ); + } + await mkdir(artifactDir, { recursive: true }); + allowedUi = await startControlUiE2eServer(); + rejectedUi = await startControlUiE2eServer(undefined, { source: true }); + gateway = await startRealGateway(new URL(allowedUi.baseUrl).origin); + proxy = await startRealTransportProxy(gateway.url); + browser = await chromium.launch({ executablePath: chromiumExecutablePath }); + await warmControlUiSource(allowedUi.baseUrl); + await warmControlUiSource(rejectedUi.baseUrl); + }, 120_000); + + afterAll(async () => { + await Promise.all([...openContexts].map((context) => context.close().catch(() => {}))); + openContexts.clear(); + const cleanupResults = await Promise.allSettled([ + browser?.close(), + proxy?.close(), + gateway?.cleanup(), + allowedUi?.close(), + rejectedUi?.close(), + ]); + expect( + cleanupResults + .filter((result) => result.status === "rejected") + .map((result) => String(result.reason)), + ).toEqual([]); + + const cleanup = { + gatewayPortClosed: gateway ? await isPortClosed("127.0.0.1", gateway.port) : true, + proxyPortClosed: proxy ? await isPortClosed("127.0.0.1", proxy.port) : true, + }; + await writeFile( + path.join(artifactDir, "cleanup-summary.json"), + `${JSON.stringify(cleanup, null, 2)}\n`, + "utf8", + ); + expect(cleanup).toEqual({ + gatewayPortClosed: true, + proxyPortClosed: true, + }); + }, 30_000); + + afterEach(async () => { + await Promise.all([...openContexts].map((context) => context.close().catch(() => {}))); + openContexts.clear(); + }); + + it("connects through the trusted path and rejects the untrusted proxy path", async () => { + const connected = await createBrowserPage(allowedUi.baseUrl, proxy.trustedUrl); + const connectedErrors: string[] = []; + connected.page.on("pageerror", (error) => connectedErrors.push(String(error))); + await connected.page + .locator("openclaw-app-shell") + .waitFor({ timeout: controlUiSettleTimeoutMs }); + const trustedEvidence = await waitForConnectionEvidence( + (entry) => + entry.route === "trusted" && + entry.gatewayResult?.ok === true && + entry.gatewayResult.helloType === "hello-ok", + connected.evidenceStartIndex, + ); + expect(trustedEvidence.browserConnect).toMatchObject({ + authFields: [], + clientId: "openclaw-control-ui", + clientMode: "webchat", + hasDevice: true, + }); + expect(trustedEvidence.identityInjected).toBe(true); + expect(trustedEvidence.requiredHeaderInjected).toBe(true); + await captureChromiumScreenshot(connected.page, "01-trusted-proxy-connected.png"); + expect(connectedErrors).toEqual([]); + await closeContext(connected.context); + + const rejected = await createBrowserPage(allowedUi.baseUrl, proxy.untrustedUrl); + const rejectedErrors: string[] = []; + rejected.page.on("pageerror", (error) => rejectedErrors.push(String(error))); + const expectedReason = "trusted_proxy_missing_header_x-forwarded-proto"; + await waitForVisibleFailure(rejected.page, "unauthorized"); + const untrustedEvidence = await waitForConnectionEvidence( + (entry) => entry.route === "untrusted" && entry.gatewayResult?.ok === false, + rejected.evidenceStartIndex, + ); + expect(untrustedEvidence.gatewayResult?.message).toContain("unauthorized"); + expect(untrustedEvidence.gatewayResult?.errorReason).toBe(expectedReason); + expect(untrustedEvidence.identityInjected).toBe(false); + expect(untrustedEvidence.requiredHeaderInjected).toBe(false); + await captureChromiumScreenshot(rejected.page, "02-untrusted-proxy-rejected.png"); + expect(rejectedErrors).toEqual([]); + + await writeFile( + path.join(artifactDir, "trusted-proxy-behavior.json"), + `${JSON.stringify( + { + connected: sanitizeProxyEvidence(trustedEvidence), + rejected: sanitizeProxyEvidence(untrustedEvidence), + visibleOutcomes: { + connectedShell: true, + rejectedRecovery: true, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + }); + + it("confirms gatewayUrl, accepts the allowed origin, and rejects an unlisted origin", async () => { + const allowed = await createBrowserPage(allowedUi.baseUrl, proxy.trustedUrl); + const allowedErrors: string[] = []; + allowed.page.on("pageerror", (error) => allowedErrors.push(String(error))); + await allowed.page.locator("openclaw-app-shell").waitFor({ timeout: controlUiSettleTimeoutMs }); + const allowedOrigin = new URL(allowedUi.baseUrl).origin; + const allowedEvidence = await waitForConnectionEvidence( + (entry) => + entry.route === "trusted" && + entry.browserOrigin === allowedOrigin && + entry.gatewayResult?.ok === true, + allowed.evidenceStartIndex, + ); + await captureChromiumScreenshot(allowed.page, "03-allowed-origin-connected.png"); + expect(allowedErrors).toEqual([]); + await closeContext(allowed.context); + + const rejected = await createBrowserPage(rejectedUi.baseUrl, proxy.trustedUrl); + const rejectedErrors: string[] = []; + rejected.page.on("pageerror", (error) => rejectedErrors.push(String(error))); + await waitForVisibleFailure(rejected.page, "origin not allowed"); + const rejectedOrigin = new URL(rejectedUi.baseUrl).origin; + const rejectedEvidence = await waitForConnectionEvidence( + (entry) => + entry.route === "trusted" && + entry.browserOrigin === rejectedOrigin && + (entry.gatewayResult?.errorCode === ConnectErrorDetailCodes.CONTROL_UI_ORIGIN_NOT_ALLOWED || + entry.upstreamHandshakeStatus === 403), + rejected.evidenceStartIndex, + ); + await captureChromiumScreenshot(rejected.page, "04-rejected-origin-recovery.png"); + expect(rejectedErrors).toEqual([]); + + await writeFile( + path.join(artifactDir, "allowed-origins-behavior.json"), + `${JSON.stringify( + { + allowed: sanitizeProxyEvidence(allowedEvidence), + rejected: sanitizeProxyEvidence(rejectedEvidence), + visibleOutcomes: { + explicitGatewayUrlConfirmed: true, + allowedOriginConnected: true, + rejectedOriginRecovery: true, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + }); +});