diff --git a/gui/src/api.ts b/gui/src/api.ts index 658beac1ff..cce2f98951 100644 --- a/gui/src/api.ts +++ b/gui/src/api.ts @@ -53,8 +53,12 @@ function needsApiAuth(input: RequestInfo | URL): boolean { try { const raw = input instanceof Request ? input.url : String(input); const url = new URL(raw, window.location.href); - // Absolute cross-origin URLs must never get the local API token or 401 prompt. - if (url.origin !== window.location.origin) return false; + const admittedOrigin = memoryToken?.startsWith("ocx_session_") + ? memorySessionServerOrigin + : window.location.origin; + // A session is destination-bound. Third-party origins get neither credentials + // nor the local admin-token prompt. + if (!admittedOrigin || url.origin !== admittedOrigin) return false; return url.pathname.startsWith("/api/"); } catch { return false; @@ -67,7 +71,8 @@ const LEGACY_TOKEN_KEY = "opencodex-api-token"; /** In-memory only — never write tokens to web storage (XSS can read sessionStorage/localStorage). */ let memoryToken: string | null = null; let memoryCsrfToken: string | null = null; -let memorySessionOrigin: string | null = null; +let memorySessionBrowserOrigin: string | null = null; +let memorySessionServerOrigin: string | null = null; function readToken(): string | null { return memoryToken; @@ -80,7 +85,8 @@ function storeToken(token: string): void { function clearToken(): void { memoryToken = null; memoryCsrfToken = null; - memorySessionOrigin = null; + memorySessionBrowserOrigin = null; + memorySessionServerOrigin = null; } function takeMetaContent(name: string): string | null { @@ -93,8 +99,9 @@ function takeMetaContent(name: string): string | null { function loadInjectedSession(): void { const token = takeMetaContent("opencodex-session-token"); const csrfToken = takeMetaContent("opencodex-session-csrf"); - const origin = takeMetaContent("opencodex-session-origin"); - storeSession(token, csrfToken, origin); + const browserOrigin = takeMetaContent("opencodex-session-origin"); + const serverOrigin = takeMetaContent("opencodex-session-server-origin"); + storeSession(token, csrfToken, browserOrigin, serverOrigin, window.location.origin); } /** Clear memory only when it still holds `expected` (avoid wiping a newer concurrent store). */ @@ -103,11 +110,26 @@ function clearTokenIfCurrent(expected: string | null): void { } /** Validate and store a server-minted GUI session; rejects anything bound to another origin. */ -function storeSession(token: string | null, csrfToken: string | null, origin: string | null): boolean { - if (!token?.startsWith("ocx_session_") || !csrfToken || origin !== window.location.origin) return false; +function storeSession( + token: string | null, + csrfToken: string | null, + browserOrigin: string | null, + serverOrigin: string | null, + expectedServerOrigin: string, +): boolean { + if ( + !token?.startsWith("ocx_session_") + || !csrfToken + || browserOrigin !== window.location.origin + || serverOrigin !== expectedServerOrigin + ) { + clearToken(); + return false; + } memoryToken = token; memoryCsrfToken = csrfToken; - memorySessionOrigin = origin; + memorySessionBrowserOrigin = browserOrigin; + memorySessionServerOrigin = serverOrigin; return true; } @@ -150,10 +172,20 @@ async function reBootstrapSessionToken(): Promise { return response.status >= 400 && response.status < 500 ? { kind: "unavailable" } : { kind: "failed" }; } const html = await response.text(); + let responseOrigin: string; + try { + if (!response.url) throw new TypeError("bootstrap response URL is missing"); + responseOrigin = new URL(response.url).origin; + } catch { + clearToken(); + return { kind: "unavailable" }; + } const stored = storeSession( metaContentFromHtml(html, "opencodex-session-token"), metaContentFromHtml(html, "opencodex-session-csrf"), metaContentFromHtml(html, "opencodex-session-origin"), + metaContentFromHtml(html, "opencodex-session-server-origin"), + responseOrigin, ); const token = readToken(); if (stored && token) return { kind: "minted", token }; @@ -188,8 +220,12 @@ function clearLegacySessionToken(): void { function withToken(input: RequestInfo | URL, init: RequestInit | undefined, token: string): [RequestInfo | URL, RequestInit | undefined] { const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)); headers.set("X-OpenCodex-API-Key", token); - if (memorySessionOrigin && memoryCsrfToken && token.startsWith("ocx_session_")) { - headers.set("X-OpenCodex-GUI-Origin", memorySessionOrigin); + if (memorySessionBrowserOrigin && memorySessionServerOrigin && memoryCsrfToken && token.startsWith("ocx_session_")) { + const raw = input instanceof Request ? input.url : String(input); + let destinationOrigin: string | null = null; + try { destinationOrigin = new URL(raw, window.location.href).origin; } catch { /* leave null */ } + if (destinationOrigin !== memorySessionServerOrigin) return [input, init]; + headers.set("X-OpenCodex-GUI-Origin", memorySessionBrowserOrigin); const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase(); if (method !== "GET" && method !== "HEAD") { headers.set("X-OpenCodex-CSRF-Token", memoryCsrfToken); @@ -305,7 +341,8 @@ export function resetApiAuthFetchForTests(adminTokenPrompt: AdminTokenPrompt = p installed = false; memoryToken = null; memoryCsrfToken = null; - memorySessionOrigin = null; + memorySessionBrowserOrigin = null; + memorySessionServerOrigin = null; resolutionInFlight = null; rawFetch = null; promptCancelled = false; diff --git a/gui/tests/api-auth-deadline.test.ts b/gui/tests/api-auth-deadline.test.ts index de4520ad88..08ff8d6afe 100644 --- a/gui/tests/api-auth-deadline.test.ts +++ b/gui/tests/api-auth-deadline.test.ts @@ -52,6 +52,7 @@ function sessionDocumentHtml(token: string, csrf: string, origin: string): strin ``, ``, ``, + ``, "", ].join(""); } @@ -67,10 +68,14 @@ function hangUntilAborted(signal?: AbortSignal | null): Promise { }); } -const MINTED = () => new Response(sessionDocumentHtml("ocx_session_fresh", "fresh-csrf", "http://localhost"), { - status: 200, - headers: { "Content-Type": "text/html" }, -}); +const MINTED = () => { + const response = new Response(sessionDocumentHtml("ocx_session_fresh", "fresh-csrf", "http://localhost"), { + status: 200, + headers: { "Content-Type": "text/html" }, + }); + Object.defineProperty(response, "url", { configurable: true, value: "http://localhost/opencodex-session" }); + return response; +}; test("hung bootstrap fails the wave within the deadline and a later wave re-bootstraps to success", async () => { setRebootstrapTimeoutForTests(50); let bootstrapCalls = 0; diff --git a/gui/tests/api-auth-memory.test.ts b/gui/tests/api-auth-memory.test.ts index ca70303e26..834ecfa922 100644 --- a/gui/tests/api-auth-memory.test.ts +++ b/gui/tests/api-auth-memory.test.ts @@ -19,6 +19,14 @@ beforeEach(() => { fetch: { configurable: true, value: testWindow.fetch.bind(testWindow) }, }); originalPrompt = window.prompt; + // happy-dom does not implement `prompt`, so the admin-token fallback below throws a + // TypeError instead of returning null the moment a test actually reaches it. Most tests + // never do; the ones that clear a rejected session do, and they failed on a missing + // function rather than on the behavior they assert. A null-returning stub is the honest + // stand-in for "the operator dismissed the prompt". + if (typeof window.prompt !== "function") { + Object.defineProperty(testWindow, "prompt", { configurable: true, writable: true, value: () => null }); + } resetApiAuthFetchForTests(async () => { return window.prompt("OpenCodex admin token (OPENCODEX_ADMIN_AUTH_TOKEN)")?.trim() || null; }); @@ -352,11 +360,12 @@ test("data-plane requests never receive the management token or prompt", async ( expect(promptCalls).toBe(beforeCrossPrompts); }); -function injectSessionMeta(token: string, csrf: string, origin: string): void { +function injectSessionMeta(token: string, csrf: string, browserOrigin: string, serverOrigin = browserOrigin): void { for (const [name, content] of [ ["opencodex-session-token", token], ["opencodex-session-csrf", csrf], - ["opencodex-session-origin", origin], + ["opencodex-session-origin", browserOrigin], + ["opencodex-session-server-origin", serverOrigin], ] as const) { const meta = document.createElement("meta"); meta.setAttribute("name", name); @@ -365,16 +374,23 @@ function injectSessionMeta(token: string, csrf: string, origin: string): void { } } -function sessionDocumentHtml(token: string, csrf: string, origin: string): string { +function sessionDocumentHtml(token: string, csrf: string, browserOrigin: string, serverOrigin = browserOrigin): string { return [ "", ``, ``, - ``, + ``, + ``, "", ].join(""); } +function htmlResponseAt(html: string, url: string): Response { + const response = new Response(html, { status: 200, headers: { "Content-Type": "text/html" } }); + Object.defineProperty(response, "url", { configurable: true, value: url }); + return response; +} + test("expired session silently re-bootstraps from the served document without prompting", async () => { // Regression for the post-security-hardening UX bug: loopback sessions expire after the // 5-minute TTL (or die on proxy restart), and the dashboard used to demand an admin token @@ -392,10 +408,10 @@ test("expired session silently re-bootstraps from the served document without pr const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)); if (url.pathname === "/opencodex-session") { bootstrapFetches += 1; - return new Response(sessionDocumentHtml("ocx_session_fresh", "fresh-csrf", "http://localhost"), { - status: 200, - headers: { "Content-Type": "text/html" }, - }); + return htmlResponseAt( + sessionDocumentHtml("ocx_session_fresh", "fresh-csrf", "http://localhost"), + "http://localhost/opencodex-session", + ); } seenApiKeys.push(headers.get("X-OpenCodex-API-Key")); seenGuiOrigins.push(headers.get("X-OpenCodex-GUI-Origin")); @@ -428,10 +444,10 @@ test("a session minted for another origin is rejected and the prompt fallback st const url = new URL(raw, "http://localhost/"); const headers = new Headers(init?.headers); if (url.pathname === "/opencodex-session") { - return new Response(sessionDocumentHtml("ocx_session_foreign", "foreign-csrf", "http://192.0.2.10:10100"), { - status: 200, - headers: { "Content-Type": "text/html" }, - }); + return htmlResponseAt( + sessionDocumentHtml("ocx_session_foreign", "foreign-csrf", "http://192.0.2.10:10100"), + "http://localhost/opencodex-session", + ); } if (headers.get("X-OpenCodex-API-Key") === "manual-admin-token") return new Response("{}", { status: 200 }); return new Response("unauthorized", { status: 401 }); @@ -446,3 +462,69 @@ test("a session minted for another origin is rejected and the prompt fallback st expect(res.status).toBe(200); expect(promptCalls).toBe(1); }); + +test("a renewed two-origin session attaches only to its bound server and carries browser origin plus CSRF", async () => { + injectSessionMeta("ocx_session_stale", "stale-csrf", "http://localhost"); + const seen = new Map(); + let localApiCalls = 0; + const record = (origin: string, headers: Headers) => { + const entries = seen.get(origin) ?? []; + entries.push(headers); + seen.set(origin, entries); + }; + const mockFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input), "http://localhost/"); + const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)); + if (url.pathname === "/opencodex-session") { + return htmlResponseAt( + sessionDocumentHtml("ocx_session_remote", "remote-csrf", "http://localhost", "https://hub.example.test"), + "https://hub.example.test/opencodex-session", + ); + } + record(url.origin, headers); + if (url.origin === "http://localhost") { + localApiCalls += 1; + return new Response("{}", { status: localApiCalls === 1 ? 401 : 200 }); + } + return new Response("{}", { status: 200 }); + }) as typeof fetch; + await installMockAuthFetch(mockFetch); + + expect((await fetch("/api/config")).status).toBe(200); + expect((await fetch("https://hub.example.test/api/config", { method: "POST" })).status).toBe(200); + expect((await fetch("https://evil.example.test/api/config")).status).toBe(200); + + const hubHeaders = seen.get("https://hub.example.test")?.[0]; + expect(hubHeaders?.get("X-OpenCodex-API-Key")).toBe("ocx_session_remote"); + expect(hubHeaders?.get("X-OpenCodex-GUI-Origin")).toBe("http://localhost"); + expect(hubHeaders?.get("X-OpenCodex-CSRF-Token")).toBe("remote-csrf"); + const evilHeaders = seen.get("https://evil.example.test")?.[0]; + expect(evilHeaders?.get("X-OpenCodex-API-Key")).toBeNull(); + expect(evilHeaders?.get("X-OpenCodex-GUI-Origin")).toBeNull(); +}); + +test("a mismatched bootstrap response/server origin clears every in-memory session field", async () => { + injectSessionMeta("ocx_session_stale", "stale-csrf", "http://localhost"); + const seenKeys: Array = []; + let apiCalls = 0; + const mockFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input), "http://localhost/"); + if (url.pathname === "/opencodex-session") { + return htmlResponseAt( + sessionDocumentHtml("ocx_session_rejected", "new-csrf", "http://localhost", "https://evil.example.test"), + "https://hub.example.test/opencodex-session", + ); + } + const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)); + seenKeys.push(headers.get("X-OpenCodex-API-Key")); + apiCalls += 1; + return new Response("unauthorized", { status: 401 }); + }) as typeof fetch; + await installMockAuthFetch(mockFetch); + + expect((await fetch("/api/config")).status).toBe(401); + expect(apiCalls).toBe(1); + expect((await fetch("https://hub.example.test/api/config")).status).toBe(401); + expect(seenKeys).toEqual(["ocx_session_stale", null]); + expect(sessionStorage.getItem(LEGACY_TOKEN_KEY)).toBeNull(); +}); diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 3edd53ffb0..8e09cc29e8 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -451,27 +451,34 @@ const commandRunners: Record = { return ok ? 0 : 1; }, gui: async deps => { - const config = deps.loadConfig(); - // Identity-checked liveness (not the pid file + a fixed sleep): finds a fallback-port - // proxy and waits until the spawned one actually answers before opening the browser. - let live = await deps.findLiveProxy(); - if (!live) { - console.log("Proxy not running. Starting..."); - deps.spawnDetached(deps.startArgv((config.port ?? 10100) > 0 ? (config.port ?? 10100) : undefined)); - live = await deps.waitForProxy(); - if (!live) { - console.error("❌ Proxy did not become healthy after starting. Not opening the GUI."); - return 1; - } - } - // Open the host the proxy actually binds — `localhost` only answers for - // loopback/wildcard binds, not a concrete LAN/IPv6 hostname. - const guiHost = deps.probeHostname(live?.hostname ?? config.hostname); - const guiUrl = `http://${guiHost === "127.0.0.1" ? "localhost" : guiHost}:${live?.port ?? config.port}`; - console.log(`Opening ${guiUrl}`); - const { openUrl } = await import("../lib/open-url"); - openUrl(guiUrl); - return 0; + const { runGuiCommand } = await import("./gui"); + return runGuiCommand(deps.args.slice(1), { + loadConfig: deps.loadConfig, + findLiveProxy: deps.findLiveProxy, + openDefaultGui: async () => { + const config = deps.loadConfig(); + // Identity-checked liveness (not the pid file + a fixed sleep): finds a fallback-port + // proxy and waits until the spawned one actually answers before opening the browser. + let live = await deps.findLiveProxy(); + if (!live) { + console.log("Proxy not running. Starting..."); + deps.spawnDetached(deps.startArgv((config.port ?? 10100) > 0 ? (config.port ?? 10100) : undefined)); + live = await deps.waitForProxy(); + if (!live) { + console.error("❌ Proxy did not become healthy after starting. Not opening the GUI."); + return 1; + } + } + // Open the host the proxy actually binds — `localhost` only answers for + // loopback/wildcard binds, not a concrete LAN/IPv6 hostname. + const guiHost = deps.probeHostname(live?.hostname ?? config.hostname); + const guiUrl = `http://${guiHost === "127.0.0.1" ? "localhost" : guiHost}:${live?.port ?? config.port}`; + console.log(`Opening ${guiUrl}`); + const { openUrl } = await import("../lib/open-url"); + openUrl(guiUrl); + return 0; + }, + }); }, service: async deps => { process.exitCode = 0; diff --git a/src/cli/gui-pair-client.ts b/src/cli/gui-pair-client.ts new file mode 100644 index 0000000000..87a164059d --- /dev/null +++ b/src/cli/gui-pair-client.ts @@ -0,0 +1,170 @@ +import { readRuntimePort, type RuntimePortState } from "../config/process-state"; +import { timingSafeEqual } from "node:crypto"; +import { + LOCAL_ATTESTATION_CHALLENGE_HEADER, + LOCAL_ATTESTATION_PROOF_HEADER, + createLocalAttestationChallenge, + verifyLocalAttestationProof, +} from "../lib/local-management-attestation"; +import { + GUI_PAIR_BROWSER_ORIGIN_HEADER, + GUI_PAIR_CAPABILITY_HEADER, + GUI_PAIR_CAPABILITY_TTL_MS, + GUI_PAIR_CAPABILITY_VERSION, + GUI_PAIR_EXPECTED_PID_HEADER, + GUI_PAIR_EXPIRES_AT_HEADER, + GUI_PAIR_METHOD, + GUI_PAIR_NONCE_HEADER, + GUI_PAIR_PATH, + canonicalGuiBrowserOrigin, + createGuiPairCapability, +} from "../lib/gui-pair-capability"; +import { directLocalHttpFetch } from "../server/direct-local-http"; +import { + isOpencodexHealthz, + probeHostname, + type HealthzIdentity, + type LiveProxy, +} from "../server/proxy-liveness"; + +export type GuiPairRequestResult = + | { kind: "created"; grant: string; browserOrigin: string; serverOrigin: string; expiresAt: number } + | { kind: "unavailable"; reason: "unattested-target" | "runtime-mismatch" | "attestation" | "capability" | "transport" | "rejected" }; + +export interface GuiPairClientDeps { + fetchImpl?: typeof fetch; + readRuntime?: (pid: number) => RuntimePortState | null; + createChallenge?: () => string; + now?: () => number; + timeoutMs?: number; +} + +const GUI_PAIR_REQUEST_TIMEOUT_MS = 10_000; + +function sameRuntime(left: RuntimePortState, right: RuntimePortState | null): boolean { + const leftSecret = Buffer.from(left.attestationSecret ?? ""); + const rightSecret = Buffer.from(right?.attestationSecret ?? ""); + return !!right?.attestationSecret + && right.pid === left.pid + && right.port === left.port + && right.hostname === left.hostname + && leftSecret.length === rightSecret.length + && timingSafeEqual(leftSecret, rightSecret); +} + +function canonicalHttpOrigin(value: unknown): string | null { + if (typeof value !== "string") return null; + try { + const parsed = new URL(value); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null; + if (parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash) return null; + return parsed.origin; + } catch { + return null; + } +} + +function parseCreatedResult(value: unknown, browserOrigin: string): GuiPairRequestResult | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const record = value as Record; + if ( + typeof record.grant !== "string" + || !/^ocx_pair_[A-Za-z0-9_-]{43}$/.test(record.grant) + || canonicalGuiBrowserOrigin(record.browserOrigin) !== browserOrigin + || typeof record.expiresAt !== "number" + || !Number.isSafeInteger(record.expiresAt) + ) return null; + const serverOrigin = canonicalHttpOrigin(record.serverOrigin); + if (!serverOrigin) return null; + return { + kind: "created", + grant: record.grant, + browserOrigin, + serverOrigin, + expiresAt: record.expiresAt, + }; +} + +export async function requestBoundGuiPairingGrant( + target: LiveProxy, + browserOrigin: string, + deps: GuiPairClientDeps = {}, +): Promise { + if (target.source !== "runtime" || target.pid === null || target.pid <= 0) { + return { kind: "unavailable", reason: "unattested-target" }; + } + const canonicalOrigin = canonicalGuiBrowserOrigin(browserOrigin); + if (!canonicalOrigin || canonicalOrigin !== browserOrigin) { + return { kind: "unavailable", reason: "capability" }; + } + const readRuntime = deps.readRuntime ?? readRuntimePort; + const runtime = readRuntime(target.pid); + if (!runtime?.attestationSecret || runtime.pid !== target.pid || runtime.port !== target.port) { + return { kind: "unavailable", reason: "runtime-mismatch" }; + } + const fetchImpl = deps.fetchImpl ?? directLocalHttpFetch; + const timeoutMs = deps.timeoutMs ?? GUI_PAIR_REQUEST_TIMEOUT_MS; + const challenge = (deps.createChallenge ?? createLocalAttestationChallenge)(); + const baseUrl = `http://${probeHostname(target.hostname)}:${target.port}`; + let proofResponse: Response; + try { + proofResponse = await fetchImpl(`${baseUrl}/healthz`, { + headers: { [LOCAL_ATTESTATION_CHALLENGE_HEADER]: challenge }, + signal: AbortSignal.timeout(timeoutMs), + }); + } catch { + return { kind: "unavailable", reason: "transport" }; + } + const body = await proofResponse.json().catch(() => null) as HealthzIdentity | null; + if ( + !proofResponse.ok + || !isOpencodexHealthz(body) + || body?.pid !== target.pid + || body?.port !== target.port + || !verifyLocalAttestationProof( + runtime.attestationSecret, + challenge, + target.pid, + target.port, + proofResponse.headers.get(LOCAL_ATTESTATION_PROOF_HEADER), + ) + ) return { kind: "unavailable", reason: "attestation" }; + if (body.guiPairCapability !== GUI_PAIR_CAPABILITY_VERSION) { + return { kind: "unavailable", reason: "capability" }; + } + if (!sameRuntime(runtime, readRuntime(target.pid))) { + return { kind: "unavailable", reason: "runtime-mismatch" }; + } + const expiresAt = (deps.now ?? Date.now)() + GUI_PAIR_CAPABILITY_TTL_MS; + const capability = createGuiPairCapability( + runtime.attestationSecret, + challenge, + GUI_PAIR_METHOD, + GUI_PAIR_PATH, + browserOrigin, + target.pid, + target.port, + expiresAt, + ); + if (!capability) return { kind: "unavailable", reason: "capability" }; + let response: Response; + try { + response = await fetchImpl(`${baseUrl}${GUI_PAIR_PATH}`, { + method: GUI_PAIR_METHOD, + headers: { + "Content-Length": "0", + [GUI_PAIR_EXPECTED_PID_HEADER]: String(target.pid), + [GUI_PAIR_NONCE_HEADER]: challenge, + [GUI_PAIR_EXPIRES_AT_HEADER]: String(expiresAt), + [GUI_PAIR_BROWSER_ORIGIN_HEADER]: browserOrigin, + [GUI_PAIR_CAPABILITY_HEADER]: capability, + }, + signal: AbortSignal.timeout(timeoutMs), + }); + } catch { + return { kind: "unavailable", reason: "transport" }; + } + if (!response.ok) return { kind: "unavailable", reason: "rejected" }; + const result = parseCreatedResult(await response.json().catch(() => null), browserOrigin); + return result ?? { kind: "unavailable", reason: "rejected" }; +} diff --git a/src/cli/gui.ts b/src/cli/gui.ts new file mode 100644 index 0000000000..9308dad496 --- /dev/null +++ b/src/cli/gui.ts @@ -0,0 +1,87 @@ +import type { OcxConfig } from "../types"; +import { canonicalGuiBrowserOrigin } from "../lib/gui-pair-capability"; +import { findLiveProxy, type LiveProxy } from "../server/proxy-liveness"; +import { + requestBoundGuiPairingGrant, + type GuiPairClientDeps, + type GuiPairRequestResult, +} from "./gui-pair-client"; +import type { RuntimeApiDeps } from "./runtime-api"; + +const GUI_USAGE = "ocx gui [pair --origin [--json]]"; +const PAIRING_WARNING = "Pairing grants are secret, single-use, and expire quickly. Do not save them."; + +export interface GuiCommandDeps extends RuntimeApiDeps { + openDefaultGui: () => Promise; + loadConfig: () => OcxConfig; + findLiveProxy?: () => Promise; + requestPairingGrant?: ( + target: LiveProxy, + browserOrigin: string, + deps?: GuiPairClientDeps, + ) => Promise; +} + +function allowedPairingOrigin(origin: string, config: OcxConfig): boolean { + if (config.runtimeRole !== "hub") return false; + if (canonicalGuiBrowserOrigin(config.hub?.managementPublicOrigin) === origin) return true; + return (config.corsAllowOrigins ?? []).some(value => canonicalGuiBrowserOrigin(value) === origin); +} + +function parsePairArgs(args: string[]): { origin: string; json: boolean } | null { + let origin: string | undefined; + let json = false; + for (let index = 0; index < args.length; index++) { + const arg = args[index]!; + if (arg === "--json" && !json) { + json = true; + continue; + } + if (arg === "--origin" && origin === undefined) { + const value = args[++index]; + if (!value || value.startsWith("--")) return null; + origin = value; + continue; + } + return null; + } + return origin ? { origin, json } : null; +} + +export async function runGuiCommand(args: string[], deps: GuiCommandDeps): Promise { + if (args.length === 0) return deps.openDefaultGui(); + if (args[0] !== "pair") { + console.error(`Usage: ${GUI_USAGE}`); + return 1; + } + const parsed = parsePairArgs(args.slice(1)); + const canonicalOrigin = parsed ? canonicalGuiBrowserOrigin(parsed.origin) : null; + if (!parsed || !canonicalOrigin || canonicalOrigin !== parsed.origin) { + console.error(`Usage: ${GUI_USAGE}`); + return 1; + } + const config = deps.loadConfig(); + if (!allowedPairingOrigin(canonicalOrigin, config)) { + console.error("The pairing origin is not enabled by hub.managementPublicOrigin or corsAllowOrigins."); + return 1; + } + const target = await (deps.findLiveProxy ?? findLiveProxy)(); + if (!target) { + console.error("No running attested OpenCodex proxy is available for GUI pairing."); + return 1; + } + const result = await (deps.requestPairingGrant ?? requestBoundGuiPairingGrant)(target, canonicalOrigin, { + ...(deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}), + }); + if (result.kind !== "created") { + console.error(`GUI pairing failed (${result.reason}).`); + return 1; + } + if (parsed.json) { + console.log(JSON.stringify({ ...result, warning: PAIRING_WARNING })); + } else { + console.log(result.grant); + console.error(PAIRING_WARNING); + } + return 0; +} diff --git a/src/cli/help.ts b/src/cli/help.ts index cc1ef7cc58..95a0a8ebd1 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -50,7 +50,8 @@ Usage: ocx debug provider/usage/injection/claude on|off|status|reset ocx login OAuth or API-key provider login ocx logout Remove a stored OAuth login - ocx gui Open the opencodex dashboard + ocx gui [pair --origin [--json]] + Open the dashboard or create a single-use remote pairing grant ocx update [--tag ] Update opencodex (keeps preview installs on @preview) ocx restart Stop and restart the proxy ocx v2 multi_agent_v2 surface (status|on|off|mode|keep-native-v1|threads|mode-hint) diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 3031ccc544..d607c0b588 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -128,7 +128,15 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ }, { name: "login", usage: "ocx login ", summary: "OAuth or API-key login for a provider." }, { name: "logout", usage: "ocx logout ", summary: "Remove a stored provider login." }, - { name: "gui", usage: "ocx gui", summary: "Open the opencodex dashboard." }, + { + name: "gui", + usage: "ocx gui [pair --origin [--json]]", + summary: "Open the opencodex dashboard or create a secret single-use remote pairing grant.", + details: [ + "Pairing requires an explicit allowed --origin; there is no localhost or config-derived default.", + "The printed grant is secret, single-use, short-lived, and must not be persisted.", + ], + }, { name: "update", usage: "ocx update [--tag latest|preview]", diff --git a/src/config.ts b/src/config.ts index c77e1507ff..7cabaab64a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -864,11 +864,62 @@ const agentTaskRecoverySchema = z.object({ const runtimeRoleSchema = z.enum(["standalone", "hub", "client"]); +function canonicalHttpOrigin(value: string): string | null { + try { + const parsed = new URL(value); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null; + if (parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash) return null; + return parsed.origin; + } catch { + return null; + } +} + +const hubConfigSchema = z.object({ + managementPublicOrigin: z.string().transform((value, ctx) => { + const origin = canonicalHttpOrigin(value); + if (!origin) { + ctx.addIssue({ code: "custom", message: "must be a canonical http(s) origin without credentials, path, query, or fragment" }); + return z.NEVER; + } + return origin; + }).optional(), +}).strict(); + +const tailscaleUserSchema = z.string().trim().min(1).superRefine((value, ctx) => { + if (new TextEncoder().encode(value).byteLength > 320) { + ctx.addIssue({ code: "custom", message: "must be at most 320 UTF-8 bytes" }); + } + if (/[\x00-\x1f\x7f]/.test(value)) { + ctx.addIssue({ code: "custom", message: "must not contain ASCII control characters" }); + } +}); + +const remoteGuiConfigSchema = z.object({ + allowedTailscaleUsers: z.array(tailscaleUserSchema).max(64).superRefine((users, ctx) => { + const seen = new Set(); + for (let index = 0; index < users.length; index++) { + const user = users[index]!; + if (seen.has(user)) { + ctx.addIssue({ code: "custom", path: [index], message: "must contain unique users after trimming" }); + } + seen.add(user); + } + }).optional(), + // Retired (see OcxRemoteGuiConfig): accepted so an existing file still loads, ignored by + // the pairing path. Removing it from a strict schema would reject the whole config. + allowInsecureHttp: z.boolean().optional(), +}).strict(); + const configSchema = z.object({ port: z.number().int().min(0).max(65535).default(10100), // A malformed hand edit must disable only remote-role behavior, not discard // providers or data-plane keys. Live writes are rejected explicitly below. runtimeRole: runtimeRoleSchema.optional().catch(undefined), + // Malformed optional remote blocks disable only remote GUI behavior. Live + // candidates are rejected explicitly by remoteGuiConfigError below. + hub: hubConfigSchema.optional().catch(undefined), + remoteGui: remoteGuiConfigSchema.optional().catch(undefined), managementUsageMaxReadBytes: z.number().int().positive().default(64 * 1024 * 1024), // Invalid hand edits disable only this opt-in circuit. Live writes remain strict. upstreamHostCircuitThreshold: z.number().int() @@ -1724,6 +1775,26 @@ function warnDegradedRuntimeRole(rawParsed: unknown): void { if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`); } +function malformedOptionalRemoteBlockWarning( + rawParsed: unknown, + key: "hub" | "remoteGui", +): string | null { + const raw = rawConfigRecord(rawParsed); + if (!raw || !Object.hasOwn(raw, key) || raw[key] === undefined) return null; + const schema = key === "hub" ? hubConfigSchema : remoteGuiConfigSchema; + const result = schema.safeParse(raw[key]); + if (result.success) return null; + const field = result.error.issues[0]?.path.join("."); + return `${key}${field ? `.${field}` : ""} ignored: invalid remote GUI configuration`; +} + +function warnDegradedOptionalRemoteBlocks(rawParsed: unknown): void { + for (const key of ["hub", "remoteGui"] as const) { + const warning = malformedOptionalRemoteBlockWarning(rawParsed, key); + if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`); + } +} + type NativeSubagentPersistedField = "injectionModel" | "injectionEffort" | "syncCodexSubagentDefaults"; function rawConfigRecord(rawParsed: unknown): Record | null { @@ -1878,6 +1949,7 @@ export function loadConfig(): OcxConfig { warnDegradedUpstreamHostCircuitThreshold(parsed); warnDegradedAgentTaskRecovery(parsed); warnDegradedRuntimeRole(parsed); + warnDegradedOptionalRemoteBlocks(parsed); return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed)); } // Schema validation failed — merge defaults into the raw object instead of @@ -1903,6 +1975,7 @@ export function loadConfig(): OcxConfig { warnDegradedUpstreamHostCircuitThreshold(parsed); warnDegradedAgentTaskRecovery(parsed); warnDegradedRuntimeRole(parsed); + warnDegradedOptionalRemoteBlocks(parsed); return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed)); } // Still failing, but if every complaint is about one or more named entries @@ -1924,6 +1997,7 @@ export function loadConfig(): OcxConfig { warnDegradedUpstreamHostCircuitThreshold(parsed); warnDegradedAgentTaskRecovery(parsed); warnDegradedRuntimeRole(parsed); + warnDegradedOptionalRemoteBlocks(parsed); return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed)); } } @@ -2026,6 +2100,10 @@ function validFileConfigDiagnostics(config: OcxConfig, rawParsed: unknown): Conf if (recoveryWarning) warnings.push(recoveryWarning); const runtimeRoleWarning = malformedRuntimeRoleWarning(rawParsed); if (runtimeRoleWarning) warnings.push(runtimeRoleWarning); + const hubWarning = malformedOptionalRemoteBlockWarning(rawParsed, "hub"); + if (hubWarning) warnings.push(hubWarning); + const remoteGuiWarning = malformedOptionalRemoteBlockWarning(rawParsed, "remoteGui"); + if (remoteGuiWarning) warnings.push(remoteGuiWarning); if (syncDisabledReason) { warnings.push(`syncCodexSubagentDefaults ignored: ${syncDisabledReason}`); } @@ -2124,6 +2202,23 @@ function runtimeRoleError(value: unknown): string | null { return 'schema_invalid: runtimeRole: must be one of "standalone", "hub", or "client"'; } +function remoteGuiConfigError(value: unknown): string | null { + const raw = rawConfigRecord(value); + if (!raw) return null; + for (const [key, schema] of [ + ["hub", hubConfigSchema], + ["remoteGui", remoteGuiConfigSchema], + ] as const) { + if (!Object.hasOwn(raw, key) || raw[key] === undefined) continue; + const result = schema.safeParse(raw[key]); + if (result.success) continue; + const issue = result.error.issues[0]; + const field = issue?.path.join("."); + return `schema_invalid: ${key}${field ? `.${field}` : ""}: ${issue?.message ?? "invalid configuration"}`; + } + return null; +} + /** * Same reasoning as {@link blankHostnameError}, and more urgent: the read path degrades a * malformed selection-order map to undefined, which on a write would drop every entry the @@ -2242,6 +2337,7 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx ?? emptyCompletionRetryError(value) ?? oauthOpenBrowserError(value) ?? runtimeRoleError(value) + ?? remoteGuiConfigError(value) ?? loopbackListenerPortError(value); if (boundaryError) return { ok: false, error: boundaryError }; const result = configSchema.safeParse(value); diff --git a/src/lib/gui-pair-capability.ts b/src/lib/gui-pair-capability.ts new file mode 100644 index 0000000000..d3a409e595 --- /dev/null +++ b/src/lib/gui-pair-capability.ts @@ -0,0 +1,104 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; +import { isLocalAttestationSecret } from "./local-management-attestation"; + +export const GUI_PAIR_METHOD = "POST"; +export const GUI_PAIR_PATH = "/api/gui/pairing-grants"; +export const GUI_PAIR_CAPABILITY_VERSION = "v1"; +export const GUI_PAIR_EXPECTED_PID_HEADER = "x-opencodex-gui-pair-expected-pid"; +export const GUI_PAIR_NONCE_HEADER = "x-opencodex-gui-pair-nonce"; +export const GUI_PAIR_EXPIRES_AT_HEADER = "x-opencodex-gui-pair-expires-at"; +export const GUI_PAIR_BROWSER_ORIGIN_HEADER = "x-opencodex-gui-pair-origin"; +export const GUI_PAIR_CAPABILITY_HEADER = "x-opencodex-gui-pair-capability"; +export const GUI_PAIR_CAPABILITY_TTL_MS = 10_000; + +const BASE64URL_256 = /^[A-Za-z0-9_-]{43}$/; + +export type ExpectedGuiPairPid = + | { kind: "absent" } + | { kind: "invalid" } + | { kind: "present"; pid: number }; + +export function parseExpectedGuiPairPid(value: string | null): ExpectedGuiPairPid { + if (value === null) return { kind: "absent" }; + if (!/^[1-9]\d*$/.test(value)) return { kind: "invalid" }; + const pid = Number(value); + return Number.isSafeInteger(pid) ? { kind: "present", pid } : { kind: "invalid" }; +} + +export function canonicalGuiBrowserOrigin(value: unknown): string | null { + if (typeof value !== "string" || value !== value.trim()) return null; + try { + const parsed = new URL(value); + if (!parsed.host || parsed.username || parsed.password || parsed.search || parsed.hash) return null; + if (parsed.pathname !== "" && parsed.pathname !== "/") return null; + if (parsed.protocol === "http:" || parsed.protocol === "https:") return parsed.origin; + return `${parsed.protocol}//${parsed.host}`; + } catch { + return null; + } +} + +function capabilityPayload( + nonce: string, + method: string, + path: string, + browserOrigin: string, + pid: number, + port: number, + expiresAt: number, +): string | null { + if (!BASE64URL_256.test(nonce)) return null; + if (method !== GUI_PAIR_METHOD || path !== GUI_PAIR_PATH) return null; + const canonicalOrigin = canonicalGuiBrowserOrigin(browserOrigin); + if (!canonicalOrigin || canonicalOrigin !== browserOrigin) return null; + if (!Number.isSafeInteger(pid) || pid <= 0) return null; + if (!Number.isInteger(port) || port <= 0 || port > 65535) return null; + if (!Number.isSafeInteger(expiresAt) || expiresAt <= 0) return null; + return `opencodex-gui-pair-v1\n${nonce}\n${method}\n${path}\n${browserOrigin}\n${pid}\n${port}\n${expiresAt}`; +} + +export function createGuiPairCapability( + secret: string, + nonce: string, + method: string, + path: string, + browserOrigin: string, + pid: number, + port: number, + expiresAt: number, +): string | null { + if (!isLocalAttestationSecret(secret)) return null; + const payload = capabilityPayload(nonce, method, path, browserOrigin, pid, port, expiresAt); + if (!payload) return null; + return createHmac("sha256", secret).update(payload).digest("base64url"); +} + +export function verifyGuiPairCapability( + secret: string, + nonce: string | null, + method: string, + path: string, + browserOrigin: string | null, + pid: number, + port: number, + expiresAt: number, + capability: string | null, + now = Date.now(), +): boolean { + if (!nonce || !browserOrigin || !capability || !BASE64URL_256.test(capability)) return false; + if (!Number.isSafeInteger(now) || expiresAt <= now || expiresAt > now + GUI_PAIR_CAPABILITY_TTL_MS) return false; + const expected = createGuiPairCapability( + secret, + nonce, + method, + path, + browserOrigin, + pid, + port, + expiresAt, + ); + if (!expected) return false; + const expectedBytes = Buffer.from(expected); + const actualBytes = Buffer.from(capability); + return expectedBytes.length === actualBytes.length && timingSafeEqual(expectedBytes, actualBytes); +} diff --git a/src/remote/protocol.ts b/src/remote/protocol.ts index 71ee0c10b5..ba192f0066 100644 --- a/src/remote/protocol.ts +++ b/src/remote/protocol.ts @@ -43,10 +43,10 @@ function observedManagementOrigin(req: Request): string | null { } export function readyProtocolMetadata(config: OcxConfig, req: Request): RemoteReadyMetadata { - // Phase 2 will consult config.hub.managementPublicOrigin here. Keeping the - // parameter now fixes the consumer signature without changing Phase 1 behavior. - void config; - const managementUrl = observedManagementOrigin(req); + const configured = config.runtimeRole === "hub" + ? managementOrigin(config.hub?.managementPublicOrigin) + : null; + const managementUrl = configured ?? observedManagementOrigin(req); if (!managementUrl) throw new Error("Readiness request does not have an HTTP(S) management origin"); return { protocol: REMOTE_HUB_PROTOCOL, diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 0174794c6a..afc93ee528 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -121,7 +121,29 @@ export function managementRequestOrigin(req: Request, config: OcxConfig): string const host = req.headers.get("Host"); const parsedHost = parseHttpHost(host); if (!host || !parsedHost) return null; - if (!isApiAuthRequired(config) && !isLoopbackHostname(parsedHost.hostname)) return null; + if (isLoopbackHostname(parsedHost.hostname)) { + try { + const protocol = new URL(req.url).protocol; + if (protocol !== "http:" && protocol !== "https:") return null; + return new URL(`${protocol}//${host}`).origin; + } catch { + return null; + } + } + if (!isApiAuthRequired(config)) return null; + if (config.runtimeRole === "hub" && config.hub?.managementPublicOrigin) { + try { + const configured = new URL(config.hub.managementPublicOrigin); + if ( + (configured.protocol === "http:" || configured.protocol === "https:") + && !configured.username + && !configured.password + && configured.pathname === "/" + && !configured.search + && !configured.hash + ) return configured.origin; + } catch { /* malformed direct fixture: fall through to observed origin */ } + } try { const protocol = new URL(req.url).protocol; if (protocol !== "http:" && protocol !== "https:") return null; @@ -200,6 +222,7 @@ export function corsHeaders(req?: Request, config?: RequestPolicyView): Record { const headers = corsHeaders(); + headers["Access-Control-Allow-Headers"] = `${STATIC_ALLOWED_REQUEST_HEADERS}, X-OpenCodex-GUI-Origin, X-OpenCodex-CSRF-Token`; const origin = req?.headers.get("Origin"); if (origin && req && config && isAllowedManagementOrigin(req, config)) { headers["Access-Control-Allow-Origin"] = origin; diff --git a/src/server/gui-session.ts b/src/server/gui-session.ts new file mode 100644 index 0000000000..95a7008ff5 --- /dev/null +++ b/src/server/gui-session.ts @@ -0,0 +1,350 @@ +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; +import type { OcxConfig } from "../types"; +import { canonicalGuiBrowserOrigin } from "../lib/gui-pair-capability"; +import { + isAllowedManagementOrigin, + isApiAuthRequired, + isLoopbackHostname, + managementRequestOrigin, + parseHttpHost, +} from "./auth-cors"; + +export type GuiSessionIssuance = + | "loopback" + | "tailscale-identity" + | "pairing"; + +export interface GuiSessionRecord { + serverOrigin: string; + browserOrigin: string; + csrfToken: string; + expiresAt: number; + issuance: GuiSessionIssuance; +} + +export interface GuiSessionBootstrap extends GuiSessionRecord { + token: string; +} + +export interface GuiPairingGrantRecord { + serverOrigin: string; + browserOrigin: string; + expiresAt: number; +} + +export interface GuiSessionState { + sessions: Map; + pairingGrants: Map; +} + +export interface GuiSessionRequestContext { + trustedTailscaleIngress: boolean; + now?: number; +} + +export type GuiSessionAdmission = + | { ok: true; principal: "gui-session"; session: GuiSessionRecord } + | { ok: false; reason: "missing" | "expired" | "server-origin" | "browser-origin" | "csrf" }; + +export const LOOPBACK_GUI_SESSION_TTL_MS = 5 * 60_000; +export const REMOTE_GUI_SESSION_TTL_MS = 12 * 60 * 60_000; +export const GUI_PAIRING_GRANT_TTL_MS = 5 * 60_000; +export const GUI_SESSION_LIMIT = 128; +export const GUI_PAIRING_GRANT_LIMIT = 64; +export const GUI_PAIRING_GRANT_RATE_LIMIT = 8; +export const GUI_PAIRING_GRANT_RATE_WINDOW_MS = 60_000; + +const pairingGrantCreations = new WeakMap(); + +export class GuiPairingGrantRateLimitError extends Error { + constructor() { + super("GUI pairing grant rate limit exceeded"); + this.name = "GuiPairingGrantRateLimitError"; + } +} + +function equalSecret(actual: string, expected: string): boolean { + const encoder = new TextEncoder(); + const left = encoder.encode(actual); + const right = encoder.encode(expected); + return left.length === right.length && timingSafeEqual(left, right); +} + +function canonicalHttpOrigin(value: unknown): string | null { + if (typeof value !== "string") return null; + try { + const parsed = new URL(value); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null; + if (parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash) return null; + return parsed.origin; + } catch { + return null; + } +} + +export function isRemoteGuiBrowserOriginAllowed(browserOrigin: string, config: OcxConfig): boolean { + const canonical = canonicalGuiBrowserOrigin(browserOrigin); + if (!canonical || canonical !== browserOrigin) return false; + const publicOrigin = canonicalHttpOrigin(config.hub?.managementPublicOrigin); + if (publicOrigin === canonical) return true; + return (config.corsAllowOrigins ?? []).some(value => canonicalGuiBrowserOrigin(value) === canonical); +} + +function pruneExpired(state: GuiSessionState, now: number): void { + for (const [token, session] of state.sessions) { + if (session.expiresAt <= now) state.sessions.delete(token); + } + for (const [digest, grant] of state.pairingGrants) { + if (grant.expiresAt <= now) state.pairingGrants.delete(digest); + } +} + +function evictOldestSession(state: GuiSessionState): void { + while (state.sessions.size >= GUI_SESSION_LIMIT) { + const oldest = state.sessions.keys().next().value as string | undefined; + if (!oldest) return; + state.sessions.delete(oldest); + } +} + +function mintSession( + serverOrigin: string, + browserOrigin: string, + issuance: GuiSessionIssuance, + state: GuiSessionState, + now: number, +): GuiSessionBootstrap { + pruneExpired(state, now); + evictOldestSession(state); + let token: string; + do { + token = `ocx_session_${randomBytes(32).toString("base64url")}`; + } while (state.sessions.has(token)); + const session: GuiSessionRecord = { + serverOrigin, + browserOrigin, + csrfToken: randomBytes(32).toString("base64url"), + expiresAt: now + (issuance === "loopback" ? LOOPBACK_GUI_SESSION_TTL_MS : REMOTE_GUI_SESSION_TTL_MS), + issuance, + }; + state.sessions.set(token, session); + return { + token, + serverOrigin: session.serverOrigin, + browserOrigin: session.browserOrigin, + csrfToken: session.csrfToken, + issuance: session.issuance, + get expiresAt() { return session.expiresAt; }, + set expiresAt(value) { session.expiresAt = value; }, + }; +} + +function tailscaleLoginAllowed(req: Request, config: OcxConfig): boolean { + const login = req.headers.get("Tailscale-User-Login"); + if (!login) return false; + return (config.remoteGui?.allowedTailscaleUsers ?? []).some(user => user === login); +} + +export function issueGuiSession( + req: Request, + config: OcxConfig, + state: GuiSessionState, + context: GuiSessionRequestContext = { trustedTailscaleIngress: false }, +): GuiSessionBootstrap | null { + if (req.method !== "GET") return null; + const host = parseHttpHost(req.headers.get("Host")); + if (!host) return null; + const now = context.now ?? Date.now(); + + if (!isApiAuthRequired(config)) { + if (!isLoopbackHostname(host.hostname) || !isAllowedManagementOrigin(req, config)) return null; + const origin = managementRequestOrigin(req, config); + return origin ? mintSession(origin, origin, "loopback", state, now) : null; + } + + if ( + config.runtimeRole !== "hub" + || !context.trustedTailscaleIngress + || !tailscaleLoginAllowed(req, config) + || !isAllowedManagementOrigin(req, config) + ) return null; + const serverOrigin = managementRequestOrigin(req, config); + if (!serverOrigin || new URL(serverOrigin).protocol !== "https:") return null; + const browserOrigin = canonicalGuiBrowserOrigin(req.headers.get("Origin") ?? serverOrigin); + if (!browserOrigin || !isRemoteGuiBrowserOriginAllowed(browserOrigin, config)) return null; + return mintSession(serverOrigin, browserOrigin, "tailscale-identity", state, now); +} + +function pairingGrantDigest(grant: string): string { + return createHash("sha256").update(grant).digest("base64url"); +} + +function findPairingGrant( + grant: string, + state: GuiSessionState, +): [string, GuiPairingGrantRecord] | null { + const digest = pairingGrantDigest(grant); + for (const [candidate, record] of state.pairingGrants) { + if (equalSecret(candidate, digest)) return [candidate, record]; + } + return null; +} + +function consumeGrantRateSlot(state: GuiSessionState, now: number): void { + const recent = (pairingGrantCreations.get(state) ?? []) + .filter(createdAt => createdAt > now - GUI_PAIRING_GRANT_RATE_WINDOW_MS); + if (recent.length >= GUI_PAIRING_GRANT_RATE_LIMIT) throw new GuiPairingGrantRateLimitError(); + recent.push(now); + pairingGrantCreations.set(state, recent); +} + +export function createGuiPairingGrant( + browserOrigin: string, + config: OcxConfig, + state: GuiSessionState, + now = Date.now(), +): { grant: string; browserOrigin: string; serverOrigin: string; expiresAt: number } { + const canonicalBrowserOrigin = canonicalGuiBrowserOrigin(browserOrigin); + const serverOrigin = canonicalHttpOrigin(config.hub?.managementPublicOrigin); + if ( + config.runtimeRole !== "hub" + || !canonicalBrowserOrigin + || canonicalBrowserOrigin !== browserOrigin + || !serverOrigin + || !isRemoteGuiBrowserOriginAllowed(canonicalBrowserOrigin, config) + ) throw new TypeError("remote GUI origin is not allowed"); + pruneExpired(state, now); + consumeGrantRateSlot(state, now); + if (state.pairingGrants.size >= GUI_PAIRING_GRANT_LIMIT) throw new GuiPairingGrantRateLimitError(); + let grant: string; + let digest: string; + do { + grant = `ocx_pair_${randomBytes(32).toString("base64url")}`; + digest = pairingGrantDigest(grant); + } while (state.pairingGrants.has(digest)); + const expiresAt = now + GUI_PAIRING_GRANT_TTL_MS; + state.pairingGrants.set(digest, { browserOrigin: canonicalBrowserOrigin, serverOrigin, expiresAt }); + return { grant, browserOrigin: canonicalBrowserOrigin, serverOrigin, expiresAt }; +} + +function strictPairingGrantBody(body: unknown): string | null { + if (!body || typeof body !== "object" || Array.isArray(body)) return null; + const record = body as Record; + if (Object.keys(record).length !== 1 || typeof record.grant !== "string") return null; + return /^ocx_pair_[A-Za-z0-9_-]{43}$/.test(record.grant) ? record.grant : null; +} + +function hasAlternateCredential(req: Request): boolean { + return req.headers.has("authorization") + || req.headers.has("x-opencodex-api-key") + || req.headers.has("x-api-key"); +} + +export function consumeGuiPairingGrant( + req: Request, + body: unknown, + config: OcxConfig, + state: GuiSessionState, + now = Date.now(), +): GuiSessionBootstrap | null { + if (req.method !== "POST" || hasAlternateCredential(req) || config.runtimeRole !== "hub") return null; + // Scheme check FIRST, before the grant is parsed or looked up. + // + // A grant is single-use, so consuming one and then refusing to mint would burn the + // operator's code on a request that was never going to succeed — an unauthenticated + // caller could strip TLS termination and spend every code the operator prints. Refusing + // here leaves the grant intact for a later request over a scheme that can carry it. + // + // There is no opt-in for plaintext. An earlier revision allowed non-loopback HTTP when + // `remoteGui.allowInsecureHttp` was true; a reusable grant on plaintext HTTP is readable + // by anything on the path and the session it mints is reusable, so the flag recorded a + // risk the operator could not bound rather than controlling one. + const destination = managementRequestOrigin(req, config); + if (!destination || !isPairingTransportPermitted(destination)) return null; + const grant = strictPairingGrantBody(body); + const browserOrigin = canonicalGuiBrowserOrigin(req.headers.get("Origin")); + if (!grant || !browserOrigin) return null; + const found = findPairingGrant(grant, state); + if (!found) return null; + const [digest, record] = found; + if (record.expiresAt <= now) { + state.pairingGrants.delete(digest); + return null; + } + if (browserOrigin !== record.browserOrigin) return null; + const serverOrigin = managementRequestOrigin(req, config); + if (serverOrigin !== record.serverOrigin) return null; + // Re-checked against the grant's own recorded origin rather than only the request's: + // the two are compared just above, but this keeps the transport rule true of the value + // the session is actually minted from. + if (!isPairingTransportPermitted(record.serverOrigin)) return null; + state.pairingGrants.delete(digest); + return mintSession(record.serverOrigin, record.browserOrigin, "pairing", state, now); +} + +/** + * A pairing grant may cross loopback or authenticated HTTPS, and nothing else. + * + * Loopback plaintext is admissible because the bytes never leave the machine. Non-loopback + * plaintext is not, and no configuration re-opens it. + */ +function isPairingTransportPermitted(origin: string): boolean { + let url: URL; + try { + url = new URL(origin); + } catch { + return false; + } + if (url.protocol === "https:") return true; + return url.protocol === "http:" && isLoopbackHostname(url.hostname); +} + +function requestCredential(req: Request): string | null { + return req.headers.get("x-opencodex-api-key")?.trim() + || req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim() + || null; +} + +function findSession( + credential: string, + state: GuiSessionState, +): [string, GuiSessionRecord] | null { + for (const [token, session] of state.sessions) { + if (equalSecret(credential, token)) return [token, session]; + } + return null; +} + +export function authorizeGuiSessionRequest( + req: Request, + config: OcxConfig, + state: GuiSessionState, + now = Date.now(), +): GuiSessionAdmission { + const credential = requestCredential(req); + if (!credential) return { ok: false, reason: "missing" }; + const found = findSession(credential, state); + if (!found) return { ok: false, reason: "missing" }; + const [token, session] = found; + if (session.expiresAt <= now) { + state.sessions.delete(token); + return { ok: false, reason: "expired" }; + } + if (managementRequestOrigin(req, config) !== session.serverOrigin) { + return { ok: false, reason: "server-origin" }; + } + const claimedBrowserOrigin = req.headers.get("x-opencodex-gui-origin"); + const browserOrigin = req.headers.get("Origin"); + const safeMethod = req.method === "GET" || req.method === "HEAD"; + if ( + claimedBrowserOrigin !== session.browserOrigin + || (browserOrigin !== null && browserOrigin !== session.browserOrigin) + || (!safeMethod && browserOrigin !== session.browserOrigin) + ) return { ok: false, reason: "browser-origin" }; + if (!safeMethod) { + const csrf = req.headers.get("x-opencodex-csrf-token")?.trim(); + if (!csrf || !equalSecret(csrf, session.csrfToken)) return { ok: false, reason: "csrf" }; + } + if (session.issuance !== "loopback") session.expiresAt = now + REMOTE_GUI_SESSION_TTL_MS; + return { ok: true, principal: "gui-session", session }; +} diff --git a/src/server/gui-static.ts b/src/server/gui-static.ts index 85968c58e9..c93299da3c 100644 --- a/src/server/gui-static.ts +++ b/src/server/gui-static.ts @@ -1,7 +1,7 @@ import { existsSync, readFileSync, statSync } from "node:fs"; import { extname, isAbsolute, join, relative, resolve } from "node:path"; import { browserSecurityHeaders } from "./auth-cors"; -import type { GuiSessionBootstrap } from "./management-auth"; +import type { GuiSessionBootstrap } from "./gui-session"; /** opencodex version, read from the packaged package.json (same source as the server bootstrap). */ const VERSION = (() => { @@ -70,7 +70,8 @@ function sessionBootstrapMeta(session: GuiSessionBootstrap): string { return [ ``, ``, - ``, + ``, + ``, ].join(""); } diff --git a/src/server/index.ts b/src/server/index.ts index 35c7361be8..18ca92c5ea 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -205,6 +205,16 @@ import { } from "../lib/local-management-attestation"; import { SYSTEM_RESTART_CAPABILITY_VERSION } from "../lib/system-restart-contract"; import { LOCAL_PROVIDER_RELOAD_CAPABILITY_VERSION } from "../lib/local-provider-reload-contract"; +import { + GUI_PAIR_BROWSER_ORIGIN_HEADER, + GUI_PAIR_CAPABILITY_VERSION, + GUI_PAIR_PATH, +} from "../lib/gui-pair-capability"; +import { + GuiPairingGrantRateLimitError, + consumeGuiPairingGrant, + createGuiPairingGrant, +} from "./gui-session"; import { createReadinessGate, type ReadinessGate } from "./readiness"; import { createRuntimePackageTreeIntegrityGuard, @@ -219,6 +229,47 @@ const WEBSOCKET_IDLE_TIMEOUT_SECONDS = 0; // Header-safe by construction: a key id reaches a response header, so anything outside this // class could inject a header break or a control character into a response we control. const REMOTE_CATALOG_KEY_ID_PATTERN = /^[A-Za-z0-9._-]{1,64}$/; +const GUI_PAIRING_EXCHANGE_BODY_LIMIT = 4 * 1024; + +/** + * Read at most `limit` bytes of a request body, or refuse. + * + * Returns null the moment the body is known to exceed `limit`, without retaining the excess. + * `req.text()` cannot express that: it buffers to completion first, so a caller who omits + * Content-Length or uses chunked framing decides how much memory the process spends. That + * matters here because the one caller is an unauthenticated endpoint. + * + * limit+1 is the stopping point rather than limit, so a body exactly at the limit is still + * accepted and only a genuinely over-limit body is rejected. + */ +async function readBoundedRequestText(req: Request, limit: number): Promise { + const body = req.body; + if (!body) return ""; + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (!value || value.byteLength === 0) continue; + total += value.byteLength; + if (total > limit) return null; + chunks.push(value); + } + } finally { + // Cancel rather than only releasing the lock: on the reject path the peer may still be + // sending, and an uncancelled body keeps that transfer alive. + await reader.cancel().catch(() => {}); + } + const joined = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + joined.set(chunk, offset); + offset += chunk.byteLength; + } + return new TextDecoder().decode(joined); +} /** * Name WHICH configured credential was admitted, so a multi-key operator can attribute a @@ -1037,6 +1088,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server GUI_PAIRING_EXCHANGE_BODY_LIMIT) { + return withManagementCors(Response.json({ error: "pairing exchange body too large" }, { status: 413, headers: { "Cache-Control": "no-store" } }), req, config); + } + const bounded = await readBoundedRequestText(req, GUI_PAIRING_EXCHANGE_BODY_LIMIT); + if (bounded === null) { + return withManagementCors(Response.json({ error: "pairing exchange body too large" }, { status: 413, headers: { "Cache-Control": "no-store" } }), req, config); + } + const text = bounded; + let body: unknown; + try { + body = JSON.parse(text); + } catch { + return withManagementCors(Response.json({ error: "invalid pairing exchange body" }, { status: 400, headers: { "Cache-Control": "no-store" } }), req, config); + } + if (!body || typeof body !== "object" || Array.isArray(body) + || Object.keys(body as Record).length !== 1 + || typeof (body as Record).grant !== "string") { + return withManagementCors(Response.json({ error: "invalid pairing exchange body" }, { status: 400, headers: { "Cache-Control": "no-store" } }), req, config); + } + const session = managementAuth.available + ? consumeGuiPairingGrant(req, body, config, managementAuth) + : null; + return session + ? withManagementCors(serveSessionBootstrap(session), req, config) + : withManagementCors(new Response(null, { status: 401, headers: { "Cache-Control": "no-store" } }), req, config); + } + return withCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, policy); + } const guiSessionCandidate = req.method === "GET" && (url.pathname === "/" || !url.pathname.includes(".")) - ? issueGuiSession(req, config, managementAuth) + ? issueGuiSession(req, config, managementAuth, { trustedTailscaleIngress: false }) : null; - // Dedicated bootstrap path: answer without requiring a packaged GUI build, so the - // Vite dev server can mint an origin-bound loopback session on a fresh checkout. - if (url.pathname === "/opencodex-session" && guiSessionCandidate) { - return serveSessionBootstrap(guiSessionCandidate); - } const guiFile = serveGuiFile(url.pathname, undefined, guiSessionCandidate ?? undefined); if (guiFile) return guiFile; if (url.pathname === "/" && req.method === "GET") { diff --git a/src/server/management-auth.ts b/src/server/management-auth.ts index 83c59d8d06..0bd29d0556 100644 --- a/src/server/management-auth.ts +++ b/src/server/management-auth.ts @@ -1,4 +1,4 @@ -import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto"; +import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto"; import { chmodSync, closeSync, @@ -39,35 +39,41 @@ import { parseExpectedLocalProviderReloadPid, verifyLocalProviderReloadCapability, } from "../lib/local-provider-reload-contract"; +import { + GUI_PAIR_BROWSER_ORIGIN_HEADER, + GUI_PAIR_CAPABILITY_HEADER, + GUI_PAIR_EXPECTED_PID_HEADER, + GUI_PAIR_EXPIRES_AT_HEADER, + GUI_PAIR_NONCE_HEADER, + GUI_PAIR_PATH, + parseExpectedGuiPairPid, + verifyGuiPairCapability, +} from "../lib/gui-pair-capability"; import { forgetEphemeralSecretPath, forgetHardenedSecretPath, hardenSecretDir, hardenSecretPath } from "../lib/windows-secret-acl"; import type { OcxConfig } from "../types"; import { - isAllowedManagementOrigin, - isApiAuthRequired, isDataPlaneAdmissionSecret, - isLoopbackHostname, - managementRequestOrigin, - parseHttpHost, } from "./auth-cors"; +import { + authorizeGuiSessionRequest, + issueGuiSession as issueGuiSessionFromState, + type GuiPairingGrantRecord, + type GuiSessionBootstrap, + type GuiSessionRecord, + type GuiSessionRequestContext, +} from "./gui-session"; +export type { GuiSessionBootstrap, GuiSessionRequestContext } from "./gui-session"; -const GUI_SESSION_TTL_MS = 5 * 60_000; -const GUI_SESSION_LIMIT = 128; const LOCAL_READ_REPLAY_LIMIT = 256; const consumedLocalReadCapabilities = new Map(); const admittedLocalReadRequests = new WeakSet(); const LOCAL_PROVIDER_RELOAD_REPLAY_LIMIT = 256; const consumedLocalProviderReloadCapabilities = new Map(); const admittedLocalProviderReloadRequests = new WeakSet(); - -interface GuiSessionRecord { - csrfToken: string; - origin: string; - expiresAt: number; -} - -export interface GuiSessionBootstrap extends GuiSessionRecord { - token: string; -} +const GUI_PAIR_REPLAY_LIMIT = 256; +const consumedGuiPairCapabilities = new Map(); +const admittedGuiPairRequests = new WeakSet(); +const admittedManagementRequests = new WeakMap(); export type ManagementAuthState = | { @@ -75,6 +81,7 @@ export type ManagementAuthState = token: string; source: "environment" | "file"; sessions: Map; + pairingGrants: Map; } | { available: false; reason: string }; @@ -201,7 +208,7 @@ function ready(token: string, source: "environment" | "file", config: OcxConfig) if (isDataPlaneAdmissionSecret(token, config)) { return fail("management credential conflicts with a data-plane credential"); } - return { available: true, token, source, sessions: new Map() }; + return { available: true, token, source, sessions: new Map(), pairingGrants: new Map() }; } export function initializeManagementAuthState(config: OcxConfig): ManagementAuthState { @@ -232,41 +239,14 @@ function equalSecret(actual: string, expected: string): boolean { return left.length === right.length && timingSafeEqual(left, right); } -function removeExpiredSessions(state: Extract, now = Date.now()): void { - for (const [token, session] of state.sessions) { - if (session.expiresAt <= now) state.sessions.delete(token); - } -} - -function randomSessionSecret(prefix: "ocx_session_"): string { - return `${prefix}${randomBytes(32).toString("base64url")}`; -} - export function issueGuiSession( req: Request, config: OcxConfig, state: ManagementAuthState, + context?: GuiSessionRequestContext, ): GuiSessionBootstrap | null { - if (isApiAuthRequired(config) || !state.available || req.method !== "GET" || !isAllowedManagementOrigin(req, config)) return null; - const host = parseHttpHost(req.headers.get("Host")); - if (!host || !isLoopbackHostname(host.hostname)) return null; - const origin = managementRequestOrigin(req, config); - if (!origin) return null; - const now = Date.now(); - removeExpiredSessions(state, now); - while (state.sessions.size >= GUI_SESSION_LIMIT) { - const oldest = state.sessions.keys().next().value as string | undefined; - if (!oldest) break; - state.sessions.delete(oldest); - } - const token = randomSessionSecret("ocx_session_"); - const session: GuiSessionRecord = { - csrfToken: randomBytes(32).toString("base64url"), - origin, - expiresAt: now + GUI_SESSION_TTL_MS, - }; - state.sessions.set(token, session); - return { token, ...session }; + if (!state.available) return null; + return issueGuiSessionFromState(req, config, state, context); } /** @@ -284,6 +264,7 @@ export function issueGuiSession( export type ManagementPrincipal = | "admin-token" | "gui-session" + | "gui-pair-capability" | "local-read-capability" | "local-provider-reload-capability" | "system-restart-capability"; @@ -416,6 +397,81 @@ function hasLocalProviderReloadCapability( return true; } +function hasGuiPairCapability( + req: Request, + local: LocalManagementAuthContext | undefined, +): boolean { + if (admittedGuiPairRequests.has(req)) return true; + if (!local || req.method !== "POST") return false; + let url: URL; + try { + url = new URL(req.url); + } catch { + return false; + } + if (url.pathname !== GUI_PAIR_PATH || url.search !== "") return false; + const contentLength = req.headers.get("content-length"); + if (contentLength !== "0" || req.headers.has("transfer-encoding")) return false; + const expectedPid = parseExpectedGuiPairPid(req.headers.get(GUI_PAIR_EXPECTED_PID_HEADER)); + if (expectedPid.kind !== "present" || expectedPid.pid !== local.pid) return false; + const expiresAtRaw = req.headers.get(GUI_PAIR_EXPIRES_AT_HEADER); + if (!expiresAtRaw || !/^[1-9]\d*$/.test(expiresAtRaw)) return false; + const expiresAt = Number(expiresAtRaw); + if (!Number.isSafeInteger(expiresAt)) return false; + const capability = req.headers.get(GUI_PAIR_CAPABILITY_HEADER); + const now = Date.now(); + if (!verifyGuiPairCapability( + local.attestationSecret, + req.headers.get(GUI_PAIR_NONCE_HEADER), + req.method, + url.pathname, + req.headers.get(GUI_PAIR_BROWSER_ORIGIN_HEADER), + local.pid, + local.port, + expiresAt, + capability, + now, + )) return false; + for (const [consumed, retainedUntil] of consumedGuiPairCapabilities) { + if (retainedUntil <= now) consumedGuiPairCapabilities.delete(consumed); + } + if (!capability) return false; + const capabilityDigest = createHash("sha256").update(capability).digest("base64url"); + if (consumedGuiPairCapabilities.has(capabilityDigest)) return false; + if (consumedGuiPairCapabilities.size >= GUI_PAIR_REPLAY_LIMIT) return false; + consumedGuiPairCapabilities.set(capabilityDigest, expiresAt); + admittedGuiPairRequests.add(req); + return true; +} + +function requestManagementCredential(req: Request): string | null { + return req.headers.get("x-opencodex-api-key")?.trim() + || req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim() + || null; +} + +function resolveManagementAdmission( + req: Request, + state: ManagementAuthState, + config?: OcxConfig, + local?: LocalManagementAuthContext, +): ManagementPrincipal | null { + const cached = admittedManagementRequests.get(req); + if (cached) return cached; + let principal: ManagementPrincipal | null = null; + if (hasSystemRestartCapability(req, local)) principal = "system-restart-capability"; + else if (hasLocalProviderReloadCapability(req, local)) principal = "local-provider-reload-capability"; + else if (hasLocalReadCapability(req, local)) principal = "local-read-capability"; + else if (hasGuiPairCapability(req, local)) principal = "gui-pair-capability"; + else if (state.available) { + const actual = requestManagementCredential(req); + if (actual && equalSecret(actual, state.token)) principal = "admin-token"; + else if (config && authorizeGuiSessionRequest(req, config, state).ok) principal = "gui-session"; + } + if (principal) admittedManagementRequests.set(req, principal); + return principal; +} + /** * The principal for a request that already passed `requireManagementAuth`. Kept as a * separate resolution (rather than a changed return type) so every existing caller @@ -429,17 +485,7 @@ export function managementPrincipal( config?: OcxConfig, local?: LocalManagementAuthContext, ): ManagementPrincipal | null { - if (hasSystemRestartCapability(req, local)) return "system-restart-capability"; - if (hasLocalProviderReloadCapability(req, local)) return "local-provider-reload-capability"; - if (hasLocalReadCapability(req, local)) return "local-read-capability"; - if (!state.available) return null; - const actual = req.headers.get("x-opencodex-api-key")?.trim() - || req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); - if (!actual) return null; - if (equalSecret(actual, state.token)) return "admin-token"; - if (!config) return null; - removeExpiredSessions(state); - return state.sessions.has(actual) ? "gui-session" : null; + return resolveManagementAdmission(req, state, config, local); } export function requireManagementAuth( @@ -448,9 +494,7 @@ export function requireManagementAuth( config?: OcxConfig, local?: LocalManagementAuthContext, ): Response | null { - if (hasSystemRestartCapability(req, local)) return null; - if (hasLocalProviderReloadCapability(req, local)) return null; - if (hasLocalReadCapability(req, local)) return null; + if (resolveManagementAdmission(req, state, config, local)) return null; if (!state.available) { return Response.json({ error: "management API unavailable", @@ -458,25 +502,5 @@ export function requireManagementAuth( hint: "Set OPENCODEX_ADMIN_AUTH_TOKEN to bypass file-backed admin token ACL hardening", }, { status: 503 }); } - const actual = req.headers.get("x-opencodex-api-key")?.trim() - || req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); - if (actual && equalSecret(actual, state.token)) return null; - if (actual && config) { - removeExpiredSessions(state); - const session = state.sessions.get(actual); - if (session) { - const requestOrigin = managementRequestOrigin(req, config); - const claimedOrigin = req.headers.get("x-opencodex-gui-origin"); - const browserOrigin = req.headers.get("Origin"); - const sameOrigin = requestOrigin === session.origin - && claimedOrigin === session.origin - && (!browserOrigin || browserOrigin === session.origin); - const safeMethod = req.method === "GET" || req.method === "HEAD"; - const csrf = req.headers.get("x-opencodex-csrf-token")?.trim(); - if (sameOrigin && (safeMethod || (browserOrigin === session.origin && !!csrf && equalSecret(csrf, session.csrfToken)))) { - return null; - } - } - } return Response.json({ error: "opencodex admin token required" }, { status: 401 }); } diff --git a/src/server/proxy-liveness.ts b/src/server/proxy-liveness.ts index 412cf2709d..df7d8d7281 100644 --- a/src/server/proxy-liveness.ts +++ b/src/server/proxy-liveness.ts @@ -22,6 +22,7 @@ export interface HealthzIdentity { port?: unknown; restartCapability?: unknown; providerReloadCapability?: unknown; + guiPairCapability?: unknown; } export interface LivenessIo { diff --git a/src/types.ts b/src/types.ts index 71171957aa..c4b0b6ed8a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -63,6 +63,8 @@ export type { OcxApiKeyEntry, OcxClientIntegrationsConfig, OcxConfigRebaseProvenance, + OcxHubConfig, + OcxRemoteGuiConfig, OcxConfig, OcxAccountPoolRotationStrategy, OcxAccountPoolQuotaWindow, diff --git a/src/types/config.ts b/src/types/config.ts index 07ba0cc4f3..e75f3e438b 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -246,10 +246,36 @@ export interface OcxConfigRebaseProvenance { export type OcxRuntimeRole = "standalone" | "hub" | "client"; +export interface OcxHubConfig { + /** Canonical browser-reachable management origin advertised by a hub. */ + managementPublicOrigin?: string; +} + +export interface OcxRemoteGuiConfig { + /** Exact Tailscale login identities permitted to receive an automatic remote GUI session. */ + allowedTailscaleUsers?: string[]; + /** + * Retired. Once permitted a one-time pairing exchange over non-loopback plaintext HTTP. + * + * Still parsed so an existing config file keeps loading, but it grants nothing: a pairing + * grant now crosses loopback or authenticated HTTPS only. A persisted `true` is reported + * once and otherwise ignored. Kept in the type rather than deleted because the schema is + * strict — dropping the key outright would make an older config fail to load entirely, + * which is a worse outcome than ignoring one retired field. + * + * @deprecated has no effect; remove it from your config. + */ + allowInsecureHttp?: boolean; +} + export interface OcxConfig { port: number; /** Runtime topology role. Absence preserves the historical standalone behavior. */ runtimeRole?: OcxRuntimeRole; + /** Hub-only public management metadata. Presence is inert outside the hub role. */ + hub?: OcxHubConfig; + /** Opt-in remote dashboard issuance policy. Presence is inert outside the hub role. */ + remoteGui?: OcxRemoteGuiConfig; /** Opt in to one identical-turn retry when a Responses completion has no text or tool call. */ emptyCompletionRetry?: boolean; /** diff --git a/tests/cli-dispatch.test.ts b/tests/cli-dispatch.test.ts index a4ac1084ed..be2425bebd 100644 --- a/tests/cli-dispatch.test.ts +++ b/tests/cli-dispatch.test.ts @@ -1,7 +1,8 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; import { CLI_COMMANDS } from "../src/cli/registry"; import { DISPATCH_ALIASES, DISPATCH_COMMANDS, dispatchCommand, resolveDispatchCommand } from "../src/cli/dispatch"; import type { CliDispatchDeps } from "../src/cli/dispatch"; +import { runGuiCommand } from "../src/cli/gui"; import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { getConfigDir } from "../src/config"; @@ -484,3 +485,62 @@ describe("doctor refuses --json rather than printing prose as success", () => { } }); }); + +describe("GUI command delegation", () => { + const config = { + port: 10100, + runtimeRole: "hub" as const, + hub: { managementPublicOrigin: "https://hub.example.test" }, + corsAllowOrigins: ["https://dashboard.example.test"], + providers: {}, + defaultProvider: "openai", + }; + + test("keeps the default open behavior and requires an explicit pairing origin", async () => { + let opens = 0; + const deps = { + loadConfig: () => config, + openDefaultGui: async () => { opens += 1; return 0; }, + }; + expect(await runGuiCommand([], deps)).toBe(0); + expect(opens).toBe(1); + expect(await runGuiCommand(["pair"], deps)).toBe(1); + expect(await runGuiCommand(["pair", "--origin", "https://dashboard.example.test", "extra"], deps)).toBe(1); + }); + + test("prints a created grant once and maps remote API refusal to exit 1 without echoing response data", async () => { + const stdout: string[] = []; + const stderr: string[] = []; + const logSpy = spyOn(console, "log").mockImplementation(value => { stdout.push(String(value)); }); + const errorSpy = spyOn(console, "error").mockImplementation(value => { stderr.push(String(value)); }); + try { + const base = { + loadConfig: () => config, + openDefaultGui: async () => 0, + findLiveProxy: async () => ({ pid: 4242, port: 10100, source: "runtime" as const }), + }; + const grant = `ocx_pair_${"C".repeat(43)}`; + expect(await runGuiCommand(["pair", "--origin", "https://dashboard.example.test", "--json"], { + ...base, + requestPairingGrant: async () => ({ + kind: "created", + grant, + browserOrigin: "https://dashboard.example.test", + serverOrigin: "https://hub.example.test", + expiresAt: 1_800_000_300_000, + }), + })).toBe(0); + expect(stdout.join(" ").split(grant)).toHaveLength(2); + + stdout.length = 0; + expect(await runGuiCommand(["pair", "--origin", "https://dashboard.example.test"], { + ...base, + requestPairingGrant: async () => ({ kind: "unavailable", reason: "rejected" }), + })).toBe(1); + expect(`${stdout.join(" ")} ${stderr.join(" ")}`).not.toContain("remote-response-secret"); + } finally { + logSpy.mockRestore(); + errorSpy.mockRestore(); + } + }); +}); diff --git a/tests/cli-help.test.ts b/tests/cli-help.test.ts index 1edfb67a99..ef0a475116 100644 --- a/tests/cli-help.test.ts +++ b/tests/cli-help.test.ts @@ -128,6 +128,16 @@ describe("CLI subcommand help", () => { expect(result.stdout).toContain("--no-start"); }); + test("GUI help documents explicit-origin pairing without making a live request", () => { + const result = runCli(["help", "gui"]); + expectSpawnFinished(result, "ocx help gui"); + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toContain("Usage: ocx gui [pair --origin [--json]]"); + expect(result.stdout).toContain("single-use"); + expect(result.stdout).toContain("must not be persisted"); + }); + test("unknown command with help flag remains an error", () => { const result = runCli(["foobar", "--help"]); expectSpawnFinished(result, "ocx foobar --help"); diff --git a/tests/cli-registry.test.ts b/tests/cli-registry.test.ts index 4accf73048..7bbb1d5101 100644 --- a/tests/cli-registry.test.ts +++ b/tests/cli-registry.test.ts @@ -104,6 +104,13 @@ describe("CLI command registry parity", () => { expect(details).toContain("ocx system codex-cli-update check [--json]"); expect(details.some(line => line.includes("dry-run"))).toBe(false); }); + + test("GUI registry usage documents explicit-origin single-use pairing", () => { + const gui = findCommand("gui"); + expect(gui?.usage).toBe("ocx gui [pair --origin [--json]]"); + expect(gui?.details?.join(" ")).toContain("single-use"); + expect(gui?.details?.join(" ")).toContain("no localhost or config-derived default"); + }); }); describe("help banner command coverage", () => { diff --git a/tests/config.test.ts b/tests/config.test.ts index de026c8dee..aba5aaf9c7 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -178,6 +178,100 @@ describe("opencodex config defaults", () => { } }); + test("hub and remote GUI config normalize valid origins and exact Tailscale users", () => { + expect(validateConfigCandidate({ + ...getDefaultConfig(), + runtimeRole: "hub", + hub: { managementPublicOrigin: "https://hub.example.test:443" }, + remoteGui: { + allowedTailscaleUsers: [" alice@example.test ", "bob@example.test"], + allowInsecureHttp: false, + }, + })).toMatchObject({ + ok: true, + config: { + hub: { managementPublicOrigin: "https://hub.example.test" }, + remoteGui: { allowedTailscaleUsers: ["alice@example.test", "bob@example.test"] }, + }, + }); + expect(validateConfigCandidate({ + ...getDefaultConfig(), + runtimeRole: "hub", + hub: { managementPublicOrigin: "http://hub.example.test" }, + remoteGui: { allowInsecureHttp: true }, + }).ok).toBe(true); + }); + + test("remote GUI live candidates reject unsafe origins and malformed identity allowlists", () => { + for (const managementPublicOrigin of [ + "ftp://hub.example.test", + "https://user@hub.example.test", + "https://hub.example.test/path", + "https://hub.example.test/?query=1", + "https://hub.example.test/#fragment", + ]) { + const result = validateConfigCandidate({ + ...getDefaultConfig(), + hub: { managementPublicOrigin }, + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("hub.managementPublicOrigin"); + } + for (const allowedTailscaleUsers of [ + [""], + ["alice@example.test", " alice@example.test "], + ["alice\n@example.test"], + ["x".repeat(321)], + Array.from({ length: 65 }, (_, index) => `user-${index}@example.test`), + ]) { + const result = validateConfigCandidate({ + ...getDefaultConfig(), + remoteGui: { allowedTailscaleUsers }, + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("remoteGui.allowedTailscaleUsers"); + } + }); + + test("a malformed persisted remote GUI block is disabled without discarding providers or API keys", () => { + const malformedValue = "https://hub.example.test/private-secret-path"; + writeConfig({ + port: 12345, + runtimeRole: "hub", + hub: { managementPublicOrigin: malformedValue }, + remoteGui: { allowedTailscaleUsers: ["alice@example.test"] }, + defaultProvider: "custom", + providers: { custom: { adapter: "openai-chat", baseUrl: "https://example.test/v1", apiKey: "upstream-secret" } }, + apiKeys: [{ id: "key-1", name: "default", key: "ocx_persisted", createdAt: "2026-08-28T00:00:00.000Z" }], + }); + const warnSpy = spyOn(console, "warn").mockImplementation(() => {}); + try { + const loaded = loadConfig(); + expect(loaded.hub).toBeUndefined(); + expect(loaded.remoteGui).toEqual({ allowedTailscaleUsers: ["alice@example.test"] }); + expect(loaded.providers.custom?.apiKey).toBe("upstream-secret"); + expect(loaded.apiKeys?.[0]?.key).toBe("ocx_persisted"); + expect(readConfigDiagnostics().warnings?.join(" ")).toContain("hub.managementPublicOrigin"); + expect(warnSpy.mock.calls.flat().join(" ")).not.toContain(malformedValue); + expect(backupNames()).toEqual([]); + } finally { + warnSpy.mockRestore(); + } + }); + + test("remote GUI config round-trips but remains inert outside the hub role", () => { + for (const runtimeRole of [undefined, "standalone", "client"] as const) { + const result = validateConfigCandidate({ + ...getDefaultConfig(), + ...(runtimeRole ? { runtimeRole } : {}), + hub: { managementPublicOrigin: "https://hub.example.test" }, + remoteGui: { allowedTailscaleUsers: ["alice@example.test"], allowInsecureHttp: true }, + }); + expect(result.ok).toBe(true); + if (result.ok) expect(result.config.runtimeRole).toBe(runtimeRole); + } + }); + test("malformed classifier config is normalized at load, even with subagentEffort absent (#1697)", () => { // normalizePersistedClaudeCode used to be reached only through a subagentEffort short-circuit, // so a config whose ONLY defect was elsewhere in claudeCode was never normalized. These diff --git a/tests/gui-management-session.test.ts b/tests/gui-management-session.test.ts index 44e74cc3d1..a6f32aa5db 100644 --- a/tests/gui-management-session.test.ts +++ b/tests/gui-management-session.test.ts @@ -21,6 +21,7 @@ describe("GUI management session bootstrap", () => { ["opencodex-session-token", "ocx_session_browser-secret"], ["opencodex-session-csrf", "csrf-browser-secret"], ["opencodex-session-origin", "http://localhost:10100"], + ["opencodex-session-server-origin", "http://localhost:10100"], ]); const fetchImpl = async (input: RequestInfo | URL, init?: RequestInit): Promise => { seen.push({ diff --git a/tests/gui-pair-capability.test.ts b/tests/gui-pair-capability.test.ts new file mode 100644 index 0000000000..cab507afb9 --- /dev/null +++ b/tests/gui-pair-capability.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "bun:test"; +import { + GUI_PAIR_CAPABILITY_TTL_MS, + GUI_PAIR_METHOD, + GUI_PAIR_PATH, + createGuiPairCapability, + verifyGuiPairCapability, +} from "../src/lib/gui-pair-capability"; + +const SECRET = "A".repeat(43); +const NONCE = "B".repeat(43); +const ORIGIN = "https://dashboard.example.test"; +const PID = 4242; +const PORT = 10100; +const NOW = 1_800_000_000_000; +const EXPIRES_AT = NOW + GUI_PAIR_CAPABILITY_TTL_MS; + +function capability(): string { + const value = createGuiPairCapability( + SECRET, + NONCE, + GUI_PAIR_METHOD, + GUI_PAIR_PATH, + ORIGIN, + PID, + PORT, + EXPIRES_AT, + ); + if (!value) throw new Error("test GUI pair capability could not be created"); + return value; +} + +describe("GUI pairing operation capability", () => { + test("authenticates the exact method path browser origin process and listener", () => { + expect(verifyGuiPairCapability( + SECRET, NONCE, GUI_PAIR_METHOD, GUI_PAIR_PATH, ORIGIN, PID, PORT, EXPIRES_AT, capability(), NOW, + )).toBe(true); + for (const changed of [ + ["DELETE", GUI_PAIR_PATH, ORIGIN, PID, PORT], + [GUI_PAIR_METHOD, "/api/config", ORIGIN, PID, PORT], + [GUI_PAIR_METHOD, GUI_PAIR_PATH, "https://evil.example.test", PID, PORT], + [GUI_PAIR_METHOD, GUI_PAIR_PATH, ORIGIN, PID + 1, PORT], + [GUI_PAIR_METHOD, GUI_PAIR_PATH, ORIGIN, PID, PORT + 1], + ] as const) { + expect(verifyGuiPairCapability( + SECRET, NONCE, changed[0], changed[1], changed[2], changed[3], changed[4], EXPIRES_AT, capability(), NOW, + )).toBe(false); + } + }); + + test("rejects malformed nonce expiry origin and same-length signature mismatches", () => { + const mismatched = `${capability().slice(0, -1)}${capability().endsWith("C") ? "D" : "C"}`; + expect(verifyGuiPairCapability( + SECRET, "short", GUI_PAIR_METHOD, GUI_PAIR_PATH, ORIGIN, PID, PORT, EXPIRES_AT, capability(), NOW, + )).toBe(false); + expect(verifyGuiPairCapability( + SECRET, NONCE, GUI_PAIR_METHOD, GUI_PAIR_PATH, "https://dashboard.example.test/path", PID, PORT, EXPIRES_AT, capability(), NOW, + )).toBe(false); + expect(verifyGuiPairCapability( + SECRET, NONCE, GUI_PAIR_METHOD, GUI_PAIR_PATH, ORIGIN, PID, PORT, NOW, capability(), NOW, + )).toBe(false); + expect(verifyGuiPairCapability( + SECRET, NONCE, GUI_PAIR_METHOD, GUI_PAIR_PATH, ORIGIN, PID, PORT, EXPIRES_AT, mismatched, NOW, + )).toBe(false); + }); +}); diff --git a/tests/gui-pair-client.test.ts b/tests/gui-pair-client.test.ts new file mode 100644 index 0000000000..63bd9195db --- /dev/null +++ b/tests/gui-pair-client.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, test } from "bun:test"; +import { requestBoundGuiPairingGrant } from "../src/cli/gui-pair-client"; +import { + LOCAL_ATTESTATION_CHALLENGE_HEADER, + LOCAL_ATTESTATION_PROOF_HEADER, + createLocalAttestationProof, +} from "../src/lib/local-management-attestation"; +import { + GUI_PAIR_BROWSER_ORIGIN_HEADER, + GUI_PAIR_CAPABILITY_HEADER, + GUI_PAIR_CAPABILITY_VERSION, + GUI_PAIR_PATH, + verifyGuiPairCapability, +} from "../src/lib/gui-pair-capability"; +import type { LiveProxy } from "../src/server/proxy-liveness"; + +const secret = "A".repeat(43); +const nonce = "B".repeat(43); +const browserOrigin = "https://dashboard.example.test"; +const target: LiveProxy = { pid: 4242, port: 10100, hostname: "127.0.0.1", source: "runtime" }; + +function proofResponse(init?: RequestInit, capabilityVersion: unknown = GUI_PAIR_CAPABILITY_VERSION): Response { + const challenge = new Headers(init?.headers).get(LOCAL_ATTESTATION_CHALLENGE_HEADER)!; + return Response.json({ + service: "opencodex", + status: "ok", + version: "test", + uptime: 1, + pid: target.pid, + port: target.port, + guiPairCapability: capabilityVersion, + }, { + headers: { + [LOCAL_ATTESTATION_PROOF_HEADER]: createLocalAttestationProof(secret, challenge, target.pid!, target.port)!, + }, + }); +} + +describe("GUI pairing client", () => { + test("refuses unattested targets and unsupported capability versions before POST", async () => { + let calls = 0; + expect(await requestBoundGuiPairingGrant( + { ...target, source: "config" }, browserOrigin, + { fetchImpl: async () => { calls += 1; return new Response(); } }, + )).toEqual({ kind: "unavailable", reason: "unattested-target" }); + expect(calls).toBe(0); + + const result = await requestBoundGuiPairingGrant(target, browserOrigin, { + readRuntime: () => ({ ...target, attestationSecret: secret }), + createChallenge: () => nonce, + fetchImpl: async (_input, init) => { + calls += 1; + return proofResponse(init, "v0"); + }, + }); + expect(result).toEqual({ kind: "unavailable", reason: "capability" }); + expect(calls).toBe(1); + }); + + test("rechecks PID and port after proof", async () => { + let reads = 0; + let calls = 0; + const result = await requestBoundGuiPairingGrant(target, browserOrigin, { + readRuntime: () => { + reads += 1; + return reads === 1 + ? { ...target, attestationSecret: secret } + : { ...target, port: target.port + 1, attestationSecret: secret }; + }, + createChallenge: () => nonce, + fetchImpl: async (_input, init) => { + calls += 1; + return proofResponse(init); + }, + }); + expect(result).toEqual({ kind: "unavailable", reason: "runtime-mismatch" }); + expect(calls).toBe(1); + }); + + test("stops after one failed attestation or transport attempt", async () => { + let calls = 0; + const unattested = await requestBoundGuiPairingGrant(target, browserOrigin, { + readRuntime: () => ({ ...target, attestationSecret: secret }), + createChallenge: () => nonce, + fetchImpl: async () => { + calls += 1; + return Response.json({ service: "opencodex", pid: target.pid, port: target.port }); + }, + }); + expect(unattested).toEqual({ kind: "unavailable", reason: "attestation" }); + expect(calls).toBe(1); + + calls = 0; + const transport = await requestBoundGuiPairingGrant(target, browserOrigin, { + readRuntime: () => ({ ...target, attestationSecret: secret }), + createChallenge: () => nonce, + fetchImpl: async () => { + calls += 1; + throw new Error("response body contains secret-that-must-be-redacted"); + }, + }); + expect(transport).toEqual({ kind: "unavailable", reason: "transport" }); + expect(JSON.stringify(transport)).not.toContain("secret-that-must-be-redacted"); + expect(calls).toBe(1); + }); + + test("sends one bodyless origin-bound capability and redacts rejected bodies", async () => { + const requests: Array<{ url: string; init?: RequestInit }> = []; + const now = 1_800_000_000_000; + const result = await requestBoundGuiPairingGrant(target, browserOrigin, { + readRuntime: () => ({ ...target, attestationSecret: secret }), + createChallenge: () => nonce, + now: () => now, + fetchImpl: async (input, init) => { + requests.push({ url: String(input), init }); + if (requests.length === 1) return proofResponse(init); + return Response.json({ + grant: `ocx_pair_${"C".repeat(43)}`, + browserOrigin, + serverOrigin: "https://hub.example.test", + expiresAt: now + 300_000, + }); + }, + }); + expect(result).toEqual({ + kind: "created", + grant: `ocx_pair_${"C".repeat(43)}`, + browserOrigin, + serverOrigin: "https://hub.example.test", + expiresAt: now + 300_000, + }); + expect(requests).toHaveLength(2); + expect(requests[1]!.url).toBe(`http://127.0.0.1:10100${GUI_PAIR_PATH}`); + expect(requests[1]!.init?.body).toBeUndefined(); + const headers = new Headers(requests[1]!.init?.headers); + expect(headers.get(GUI_PAIR_BROWSER_ORIGIN_HEADER)).toBe(browserOrigin); + expect(headers.has("authorization")).toBe(false); + expect(headers.has("x-opencodex-api-key")).toBe(false); + expect(verifyGuiPairCapability( + secret, + nonce, + "POST", + GUI_PAIR_PATH, + browserOrigin, + target.pid!, + target.port, + Number(headers.get("x-opencodex-gui-pair-expires-at")), + headers.get(GUI_PAIR_CAPABILITY_HEADER), + now, + )).toBe(true); + + const rejected = await requestBoundGuiPairingGrant(target, browserOrigin, { + readRuntime: () => ({ ...target, attestationSecret: secret }), + createChallenge: () => nonce, + fetchImpl: async (_input, init) => init?.method + ? Response.json({ grant: "secret-must-not-surface" }, { status: 403 }) + : proofResponse(init), + }); + expect(rejected).toEqual({ kind: "unavailable", reason: "rejected" }); + expect(JSON.stringify(rejected)).not.toContain("secret-must-not-surface"); + }); +}); diff --git a/tests/native-profile-route-security.test.ts b/tests/native-profile-route-security.test.ts index 7542fa6e50..596852cccb 100644 --- a/tests/native-profile-route-security.test.ts +++ b/tests/native-profile-route-security.test.ts @@ -6,6 +6,7 @@ import { saveConfig } from "../src/config"; import type { NativeProfileManager } from "../src/codex/native-profile-manager"; import { startServer } from "../src/server"; import { initializeManagementAuthState, issueGuiSession, type ManagementAuthState } from "../src/server/management-auth"; +import { consumeGuiPairingGrant, createGuiPairingGrant } from "../src/server/gui-session"; import type { OcxConfig } from "../src/types"; import { SERVER_BUDGET_MS } from "./helpers/test-budget"; @@ -164,4 +165,59 @@ describe("native-main profile routes at the management admission boundary", () = await server.stop(true); } }, SERVER_BUDGET_MS); + + test("remote GUI sessions require the bound server, browser origin, and CSRF before native mutation dispatch", async () => { + const config: OcxConfig = { + ...loopbackConfig(), + hostname: "0.0.0.0", + runtimeRole: "hub", + hub: { managementPublicOrigin: "https://hub.example.test" }, + remoteGui: {}, + corsAllowOrigins: ["https://dashboard.example.test"], + apiKeys: [{ id: "data", name: "data", key: "data-secret", createdAt: "2026-08-28T00:00:00.000Z" }], + }; + saveConfig(config); + const calls: string[] = []; + const managementAuth = initializeManagementAuthState(config); + if (!managementAuth.available) throw new Error("expected management auth state"); + const grant = createGuiPairingGrant("https://dashboard.example.test", config, managementAuth); + const session = consumeGuiPairingGrant(new Request("https://hub.example.test/opencodex-session", { + method: "POST", + headers: { Host: "hub.example.test", Origin: "https://dashboard.example.test" }, + }), { grant: grant.grant }, config, managementAuth); + if (!session) throw new Error("expected remote GUI session"); + const server = startServer(0, { + managementAuthState: managementAuth, + managementApi: { nativeProfileApi: { manager: testManager(calls) } }, + }); + try { + const operation = operations.find(candidate => candidate.method === "POST")!; + const request = (headers: Record) => fetch(new URL(operation.path, server.url), { + method: "POST", + headers: { "content-type": "application/json", Host: "hub.example.test", ...headers }, + body: operation.body ? JSON.stringify(operation.body) : undefined, + }); + const base = { + Origin: "https://dashboard.example.test", + "x-opencodex-api-key": session.token, + "x-opencodex-gui-origin": "https://dashboard.example.test", + "x-opencodex-csrf-token": session.csrfToken, + }; + const withoutHeader = (name: string): Record => Object.fromEntries( + Object.entries(base).filter(([header]) => header !== name), + ); + expect((await request({ ...base, "x-opencodex-gui-origin": "https://evil.example.test" })).status).toBe(401); + expect((await request({ ...base, Origin: "https://evil.example.test" })).status).toBe(401); + expect((await request(withoutHeader("Origin"))).status).toBe(401); + expect((await request(withoutHeader("x-opencodex-gui-origin"))).status).toBe(401); + expect((await request(withoutHeader("x-opencodex-csrf-token"))).status).toBe(401); + expect((await request({ ...base, Host: `127.0.0.1:${server.port}` })).status).toBe(401); + expect((await request({ ...base, "x-opencodex-csrf-token": "" })).status).toBe(401); + expect(calls).toEqual([]); + expect((await request(base)).status).toBe(200); + expect(calls).toEqual([operation.name]); + } finally { + await server.stop(true); + } + }, SERVER_BUDGET_MS); }); diff --git a/tests/proxy-liveness.test.ts b/tests/proxy-liveness.test.ts index 7a00da7077..52c2b91919 100644 --- a/tests/proxy-liveness.test.ts +++ b/tests/proxy-liveness.test.ts @@ -27,6 +27,7 @@ const OURS = { status: "ok", service: "opencodex", version: "2.6.17", uptime: 12 describe("isOpencodexHealthz", () => { test("accepts the explicit service marker", () => { expect(isOpencodexHealthz(OURS)).toBe(true); + expect(isOpencodexHealthz({ ...OURS, guiPairCapability: "v1" })).toBe(true); }); test("accepts the legacy pre-identity body (still-running old proxy after update)", () => { @@ -38,6 +39,7 @@ describe("isOpencodexHealthz", () => { expect(isOpencodexHealthz({ status: "ok" })).toBe(false); expect(isOpencodexHealthz({ service: "something-else", status: "ok", version: "1", uptime: 1 })).toBe(false); expect(isOpencodexHealthz({ healthy: true } as never)).toBe(false); + expect(isOpencodexHealthz({ guiPairCapability: "v1", pid: 4242, port: 10100 })).toBe(false); }); }); @@ -671,6 +673,22 @@ describe("remote readiness protocol metadata", () => { }); }); + test("configured hub management origin wins while other roles keep the observed fallback", () => { + const request = new Request("http://127.0.0.1/readyz", { + headers: { Host: "observed.example.test:8443" }, + }); + expect(readyProtocolMetadata({ + ...getDefaultConfig(), + runtimeRole: "hub", + hub: { managementPublicOrigin: "https://hub.example.test:443" }, + }, request).managementUrl).toBe("https://hub.example.test"); + expect(readyProtocolMetadata({ + ...getDefaultConfig(), + runtimeRole: "client", + hub: { managementPublicOrigin: "https://ignored.example.test" }, + }, request).managementUrl).toBe("http://observed.example.test:8443"); + }); + test("classifies a hub that requires a newer client with the exact message", () => { expect(checkRemoteProtocolCompatibility({ ...metadata, protocol: 2, minimumClientProtocol: 2 })).toEqual({ ok: false, diff --git a/tests/server-auth.test.ts b/tests/server-auth.test.ts index e4030f8a59..7f4217b73c 100644 --- a/tests/server-auth.test.ts +++ b/tests/server-auth.test.ts @@ -46,6 +46,7 @@ import { ownedServiceHomeInspection } from "./helpers/owned-service-home-inspect import { configuredAdminToken } from "../src/lib/admin-secrets"; import { SYSTEM_RESTART_CAPABILITY_VERSION } from "../src/lib/system-restart-contract"; import { LOCAL_PROVIDER_RELOAD_CAPABILITY_VERSION } from "../src/lib/local-provider-reload-contract"; +import { GUI_PAIR_CAPABILITY_VERSION } from "../src/lib/gui-pair-capability"; import { resetCodexModelEntitlementCacheForTests } from "../src/codex/model-entitlements"; import { getDebugLogEntries, resetDebugLogBufferForTests } from "../src/lib/debug-log-buffer"; import { resetDebugSettingsForTests, setDebugSettings } from "../src/lib/debug-settings"; @@ -1071,6 +1072,7 @@ describe("server local API auth", () => { expect(health.status).toBe(200); const healthBody = await health.json() as Record; expect(Object.keys(healthBody).sort()).toEqual([ + "guiPairCapability", "pid", "port", "providerReloadCapability", @@ -1082,6 +1084,7 @@ describe("server local API auth", () => { ]); expect(healthBody.restartCapability).toBe(SYSTEM_RESTART_CAPABILITY_VERSION); expect(healthBody.providerReloadCapability).toBe(LOCAL_PROVIDER_RELOAD_CAPABILITY_VERSION); + expect(healthBody.guiPairCapability).toBe(GUI_PAIR_CAPABILITY_VERSION); expect("rss" in healthBody).toBe(false); } finally { await server.stop(true); @@ -1115,6 +1118,10 @@ describe("server local API auth", () => { }); expect(accepted.status).toBe(204); expect(accepted.headers.get("access-control-allow-origin")).toBe(loopbackOrigin); + const allowedHeaders = accepted.headers.get("access-control-allow-headers") ?? ""; + expect(allowedHeaders).toContain("X-OpenCodex-GUI-Origin"); + expect(allowedHeaders).toContain("X-OpenCodex-CSRF-Token"); + expect(allowedHeaders).not.toContain("X-Unrelated-Custom-Header"); } finally { await server.stop(true); } @@ -1165,6 +1172,30 @@ describe("server local API auth", () => { }); expect(managementPreflight.status).toBe(204); expect(managementPreflight.headers.get("access-control-allow-origin")).toBe(extensionOrigin); + expect(managementPreflight.headers.get("access-control-allow-headers")).toContain("X-OpenCodex-GUI-Origin"); + expect(managementPreflight.headers.get("access-control-allow-headers")).toContain("X-OpenCodex-CSRF-Token"); + + const managementUnrelated = await fetch(managementUrl, { + method: "OPTIONS", + headers: { + origin: extensionOrigin, + "access-control-request-method": "GET", + "access-control-request-headers": "X-Unrelated-Custom-Header", + }, + }); + expect(managementUnrelated.status).toBe(204); + expect(managementUnrelated.headers.get("access-control-allow-headers")).not.toContain("X-Unrelated-Custom-Header"); + + const dataPlaneDynamic = await fetch(modelsUrl, { + method: "OPTIONS", + headers: { + origin: extensionOrigin, + "access-control-request-method": "GET", + "access-control-request-headers": "X-Unrelated-Custom-Header", + }, + }); + expect(dataPlaneDynamic.status).toBe(204); + expect(dataPlaneDynamic.headers.get("access-control-allow-headers")).toContain("X-Unrelated-Custom-Header"); const managementRejected = await fetch(managementUrl, { method: "OPTIONS", @@ -4209,3 +4240,71 @@ describe("GET /v1/catalog remote data plane", () => { } }); }); + +describe("POST /opencodex-session pairing body bound", () => { + // This endpoint is reachable without a credential, so the body bound has to hold against a + // caller who controls the framing. The pre-check reads Content-Length, which the caller + // chooses: omit it and `Number(null ?? "0")` is 0, or send chunked and there is no header + // to read. Both used to pass the check and reach `req.text()`, which buffers whatever + // arrives — an unauthenticated caller decided how much memory the process spent. + + test("a chunked body with no Content-Length is bounded rather than buffered whole", async () => { + saveConfig(remoteCatalogConfig()); + const server = startServer(0); + try { + // 512 KiB against a 4 KiB limit, streamed so no Content-Length is sent. The stream + // reports how many chunks the server actually pulled: a bounded read stops early, an + // unbounded one drains all of them. + const chunkCount = 128; + const chunkBytes = 4 * 1024; + let pulled = 0; + const body = new ReadableStream({ + pull(controller) { + if (pulled >= chunkCount) { + controller.close(); + return; + } + pulled += 1; + controller.enqueue(new Uint8Array(chunkBytes).fill(0x61)); + }, + }); + + const response = await fetch(new URL("/opencodex-session", server.url), { + method: "POST", + headers: { "content-type": "application/json", Origin: "http://localhost" }, + body, + // Required by fetch for a streaming request body. + duplex: "half", + } as RequestInit & { duplex: "half" }); + + expect(response.status).toBe(413); + // The bound is what stopped it, not the peer running out of data. + expect(pulled).toBeLessThan(chunkCount); + } finally { + await server.stop(true); + } + }); + + test("a body exactly at the limit is still accepted for parsing", async () => { + saveConfig(remoteCatalogConfig()); + const server = startServer(0); + try { + // Exactly 4096 bytes of valid JSON: the bound must reject over-limit bodies without + // also rejecting one that sits on the limit. + const filler = "a".repeat(4096 - '{"grant":""}'.length); + const atLimit = `{"grant":"${filler}"}`; + expect(Buffer.byteLength(atLimit)).toBe(4096); + + const response = await fetch(new URL("/opencodex-session", server.url), { + method: "POST", + headers: { "content-type": "application/json", Origin: "http://localhost" }, + body: atLimit, + }); + + // 401, not 413: the body was read and parsed, and the grant simply does not exist. + expect(response.status).toBe(401); + } finally { + await server.stop(true); + } + }); +}); diff --git a/tests/server-live.test.ts b/tests/server-live.test.ts index 53418c4627..7dda68a77a 100644 --- a/tests/server-live.test.ts +++ b/tests/server-live.test.ts @@ -1292,6 +1292,34 @@ describe("GET /readyz", () => { } }); + test("hub readiness prefers the configured public management origin over the observed listener", async () => { + saveConfig({ + ...forwardConfig(), + runtimeRole: "hub", + hub: { managementPublicOrigin: "https://hub.example.test" }, + }); + const server = startServer(0); + try { + const ready = await fetch(new URL("/readyz", server.url), { + headers: { + Host: "observed.example.test:8443", + "X-Forwarded-Host": "attacker.example.test", + "X-Forwarded-Proto": "http", + }, + }); + const body = await ready.json() as Record; + expectReadyProtocolMetadata(body, "https://hub.example.test"); + + const health = await fetch(new URL("/healthz", server.url)); + const healthBody = await health.json() as Record; + expect(healthBody.guiPairCapability).toBe("v1"); + expect(JSON.stringify(healthBody)).not.toContain("ocx_session_"); + expect(JSON.stringify(healthBody)).not.toContain("csrf"); + } finally { + await server.stop(true); + } + }); + test("/readyz is 200 with status ready only after gate.markReady()", async () => { saveConfig(forwardConfig()); const gate = createReadinessGate(); diff --git a/tests/server-management-auth.test.ts b/tests/server-management-auth.test.ts index 335392ea8d..d7713e0cca 100644 --- a/tests/server-management-auth.test.ts +++ b/tests/server-management-auth.test.ts @@ -62,6 +62,25 @@ import { createLocalProviderReloadCapability, verifyLocalProviderReloadCapability, } from "../src/lib/local-provider-reload-contract"; +import { + GUI_PAIR_BROWSER_ORIGIN_HEADER, + GUI_PAIR_CAPABILITY_HEADER, + GUI_PAIR_CAPABILITY_TTL_MS, + GUI_PAIR_EXPECTED_PID_HEADER, + GUI_PAIR_EXPIRES_AT_HEADER, + GUI_PAIR_METHOD, + GUI_PAIR_NONCE_HEADER, + GUI_PAIR_PATH, + createGuiPairCapability, +} from "../src/lib/gui-pair-capability"; +import { + GUI_PAIRING_GRANT_TTL_MS, + LOOPBACK_GUI_SESSION_TTL_MS, + REMOTE_GUI_SESSION_TTL_MS, + authorizeGuiSessionRequest, + consumeGuiPairingGrant, + createGuiPairingGrant, +} from "../src/server/gui-session"; import { setSystemRestartIoForTests } from "../src/server/management/system-restart"; const previousHome = process.env.OPENCODEX_HOME; @@ -85,6 +104,16 @@ function remoteConfig(): OcxConfig { }; } +function hubConfig(publicOrigin = "https://hub.example.test"): OcxConfig { + return { + ...remoteConfig(), + runtimeRole: "hub", + hub: { managementPublicOrigin: publicOrigin }, + remoteGui: { allowedTailscaleUsers: ["alice@example.test"] }, + corsAllowOrigins: ["https://dashboard.example.test"], + }; +} + function websocketHandshakeOpens(url: URL, token: string): Promise { return new Promise(resolve => { const target = new URL("/v1/responses", url); @@ -794,8 +823,15 @@ describe("management and data-plane credential separation", () => { const pageRequest = new Request("http://localhost:10100/", { headers: { Host: "localhost:10100" }, }); - const session = issueGuiSession(pageRequest, config, state); + const now = 1_800_000_000_000; + const session = issueGuiSession(pageRequest, config, state, { trustedTailscaleIngress: false, now }); expect(session).not.toBeNull(); + expect(session).toMatchObject({ + serverOrigin: "http://localhost:10100", + browserOrigin: "http://localhost:10100", + issuance: "loopback", + expiresAt: now + LOOPBACK_GUI_SESSION_TTL_MS, + }); const guiDist = join(testHome, "gui"); const { mkdirSync, writeFileSync } = await import("node:fs"); @@ -812,7 +848,8 @@ describe("management and data-plane credential separation", () => { // source checkout (no packaged build) can still mint an origin-bound session. const bootstrapPage = serveSessionBootstrap(session!); const bootstrapHtml = await bootstrapPage.text(); - expect(bootstrapHtml).toContain(`name="opencodex-session-origin" content="${session?.origin}"`); + expect(bootstrapHtml).toContain(`name="opencodex-session-origin" content="${session?.browserOrigin}"`); + expect(bootstrapHtml).toContain(`name="opencodex-session-server-origin" content="${session?.serverOrigin}"`); expect(bootstrapHtml).toContain(`name="opencodex-session-token" content="${session?.token}"`); const sameOriginRead = new Request("http://localhost:10100/api/config", { @@ -883,6 +920,273 @@ describe("management and data-plane credential separation", () => { expect(html).toContain('name="opencodex-session-token"'); expect(html).toContain('name="opencodex-session-csrf"'); expect(html).toContain('name="opencodex-session-origin"'); + expect(html).toContain('name="opencodex-session-server-origin"'); + } finally { + await server.stop(true); + } + }); + + test("session bootstrap escapes both browser and server origin attributes", async () => { + const response = serveSessionBootstrap({ + token: "ocx_session_safe", + csrfToken: "csrf-safe", + browserOrigin: 'https://browser.example.test/\">', + serverOrigin: 'https://hub.example.test/\">', + expiresAt: Date.now() + 1_000, + issuance: "pairing", + }); + const html = await response.text(); + expect(html).not.toContain(""); + expect(html).not.toContain(" { + const config = hubConfig(); + const state = initializeManagementAuthState(config); + if (!state.available) throw new Error("expected management auth state"); + const now = 1_800_000_000_000; + const request = new Request("https://hub.example.test/", { + headers: { + Host: "hub.example.test", + Origin: "https://dashboard.example.test", + "Tailscale-User-Login": "alice@example.test", + }, + }); + expect(issueGuiSession(request, config, state, { trustedTailscaleIngress: false, now })).toBeNull(); + expect(issueGuiSession(request, config, state, { trustedTailscaleIngress: true, now })).toMatchObject({ + serverOrigin: "https://hub.example.test", + browserOrigin: "https://dashboard.example.test", + issuance: "tailscale-identity", + expiresAt: now + REMOTE_GUI_SESSION_TTL_MS, + }); + expect(issueGuiSession(new Request(request, { + headers: { ...Object.fromEntries(request.headers), "Tailscale-User-Login": "mallory@example.test" }, + }), config, state, { trustedTailscaleIngress: true, now })).toBeNull(); + expect(issueGuiSession(new Request(request, { + headers: { ...Object.fromEntries(request.headers), "Tailscale-User-Login": " alice@example.test " }, + }), config, state, { trustedTailscaleIngress: true, now })).toBeNull(); + expect(issueGuiSession(request, { ...config, runtimeRole: "client" }, state, { trustedTailscaleIngress: true, now })).toBeNull(); + expect(issueGuiSession(request, { ...config, remoteGui: { allowedTailscaleUsers: [] } }, state, { trustedTailscaleIngress: true, now })).toBeNull(); + const httpConfig = hubConfig("http://hub.example.test"); + expect(issueGuiSession(new Request("http://hub.example.test/", { + headers: { Host: "hub.example.test", "Tailscale-User-Login": "alice@example.test" }, + }), httpConfig, state, { trustedTailscaleIngress: true, now })).toBeNull(); + }); + + test("pairing grants are digest-only, origin-bound, single-use, and never accept alternate credentials", () => { + const config = hubConfig(); + const state = initializeManagementAuthState(config); + if (!state.available) throw new Error("expected management auth state"); + const now = 1_800_000_000_000; + const created = createGuiPairingGrant("https://dashboard.example.test", config, state, now); + expect(created.expiresAt).toBe(now + GUI_PAIRING_GRANT_TTL_MS); + expect(state.sessions.size).toBe(0); + expect(state.pairingGrants.size).toBe(1); + expect([...state.pairingGrants.keys()].join(" ")).not.toContain(created.grant); + + const exchange = (origin: string, host = "hub.example.test", headers: HeadersInit = {}) => new Request( + "https://hub.example.test/opencodex-session", + { method: "POST", headers: { Host: host, Origin: origin, ...headers } }, + ); + expect(consumeGuiPairingGrant( + exchange("https://evil.example.test"), { grant: created.grant }, config, state, now + 1, + )).toBeNull(); + expect(consumeGuiPairingGrant( + exchange("https://dashboard.example.test", "localhost:10100"), { grant: created.grant }, config, state, now + 1, + )).toBeNull(); + for (const alternateCredential of ["admin-secret", "data-secret", "ocx_session_not-a-grant"]) { + expect(consumeGuiPairingGrant( + exchange("https://dashboard.example.test", "hub.example.test", { "x-opencodex-api-key": alternateCredential }), + { grant: created.grant }, config, state, now + 1, + )).toBeNull(); + } + const session = consumeGuiPairingGrant( + exchange("https://dashboard.example.test"), { grant: created.grant }, config, state, now + 1, + ); + expect(session).toMatchObject({ + serverOrigin: "https://hub.example.test", + browserOrigin: "https://dashboard.example.test", + issuance: "pairing", + expiresAt: now + 1 + REMOTE_GUI_SESSION_TTL_MS, + }); + expect(state.pairingGrants.size).toBe(0); + expect(consumeGuiPairingGrant( + exchange("https://dashboard.example.test"), { grant: created.grant }, config, state, now + 2, + )).toBeNull(); + + const expired = createGuiPairingGrant("https://dashboard.example.test", config, state, now + 10); + expect(consumeGuiPairingGrant( + exchange("https://dashboard.example.test"), { grant: expired.grant }, config, state, expired.expiresAt, + )).toBeNull(); + }); + + test("non-loopback plaintext HTTP cannot carry a pairing grant, and no opt-in re-opens it", () => { + // An earlier revision let this exchange succeed when `remoteGui.allowInsecureHttp` was + // true, and this test asserted exactly that. The flag is retired: a reusable grant on + // plaintext HTTP is readable by anything on the path, and the session it mints is + // reusable, so operator opt-in recorded a risk it could not bound. + const config = hubConfig("http://hub.example.test"); + const state = initializeManagementAuthState(config); + if (!state.available) throw new Error("expected management auth state"); + const now = 1_800_000_000_000; + const created = createGuiPairingGrant("https://dashboard.example.test", config, state, now); + const request = new Request("http://hub.example.test/opencodex-session", { + method: "POST", + headers: { Host: "hub.example.test", Origin: "https://dashboard.example.test" }, + }); + + expect(consumeGuiPairingGrant(request, { grant: created.grant }, config, state, now + 1)).toBeNull(); + // The grant SURVIVES the refusal. Rejecting before the grant is read is what stops an + // attacker who strips TLS from burning every code the operator prints. + expect(state.pairingGrants.size).toBe(1); + + // The retired flag is still accepted by the schema so old configs load, and still grants + // nothing. + config.remoteGui = { ...config.remoteGui, allowInsecureHttp: true }; + expect(consumeGuiPairingGrant(request, { grant: created.grant }, config, state, now + 2)).toBeNull(); + expect(state.pairingGrants.size).toBe(1); + + // The same unspent grant still works over HTTPS, proving the refusal was about transport + // rather than the grant being invalidated. + const secureConfig = hubConfig("https://hub.example.test"); + const secureState = initializeManagementAuthState(secureConfig); + if (!secureState.available) throw new Error("expected management auth state"); + const secureGrant = createGuiPairingGrant("https://dashboard.example.test", secureConfig, secureState, now); + const secureRequest = new Request("https://hub.example.test/opencodex-session", { + method: "POST", + headers: { Host: "hub.example.test", Origin: "https://dashboard.example.test" }, + }); + expect(consumeGuiPairingGrant(secureRequest, { grant: secureGrant.grant }, secureConfig, secureState, now + 1)).toMatchObject({ + issuance: "pairing", + }); + expect(secureState.pairingGrants.size).toBe(0); + }); + + test("pairing grant creation is bounded by a per-state rate limit", () => { + const config = hubConfig(); + const state = initializeManagementAuthState(config); + if (!state.available) throw new Error("expected management auth state"); + const now = 1_800_000_000_000; + for (let index = 0; index < 8; index++) { + createGuiPairingGrant("https://dashboard.example.test", config, state, now + index); + } + expect(() => createGuiPairingGrant("https://dashboard.example.test", config, state, now + 9)).toThrow("rate limit"); + expect(state.sessions.size).toBe(0); + }); + + test("remote session admission shares the full predicate and renews only after success", () => { + const config = hubConfig(); + const state = initializeManagementAuthState(config); + if (!state.available) throw new Error("expected management auth state"); + const issuedAt = 1_800_000_000_000; + const created = createGuiPairingGrant("https://dashboard.example.test", config, state, issuedAt); + const session = consumeGuiPairingGrant(new Request("https://hub.example.test/opencodex-session", { + method: "POST", + headers: { Host: "hub.example.test", Origin: "https://dashboard.example.test" }, + }), { grant: created.grant }, config, state, issuedAt + 1)!; + const before = session.expiresAt; + const request = (overrides: Record = {}, method = "GET", host = "hub.example.test") => new Request( + `https://${host}/api/config`, + { + method, + headers: { + Host: host, + Origin: "https://dashboard.example.test", + "x-opencodex-api-key": session.token, + "x-opencodex-gui-origin": "https://dashboard.example.test", + ...(method === "GET" ? {} : { "x-opencodex-csrf-token": session.csrfToken }), + ...overrides, + }, + }, + ); + expect(authorizeGuiSessionRequest(request({ "x-opencodex-gui-origin": "https://evil.example.test" }), config, state, issuedAt + 2)).toMatchObject({ ok: false, reason: "browser-origin" }); + expect(session.expiresAt).toBe(before); + expect(authorizeGuiSessionRequest(request({}, "POST", "localhost:10100"), config, state, issuedAt + 3)).toMatchObject({ ok: false, reason: "server-origin" }); + expect(session.expiresAt).toBe(before); + expect(authorizeGuiSessionRequest(request({ "x-opencodex-csrf-token": "wrong" }, "POST"), config, state, issuedAt + 4)).toMatchObject({ ok: false, reason: "csrf" }); + expect(session.expiresAt).toBe(before); + const missingCsrf = new Request("https://hub.example.test/api/config", { + method: "POST", + headers: { + Host: "hub.example.test", + Origin: "https://dashboard.example.test", + "x-opencodex-api-key": session.token, + "x-opencodex-gui-origin": "https://dashboard.example.test", + }, + }); + expect(authorizeGuiSessionRequest(missingCsrf, config, state, issuedAt + 4)).toMatchObject({ ok: false, reason: "csrf" }); + expect(session.expiresAt).toBe(before); + expect(authorizeGuiSessionRequest(request({}, "POST"), config, state, issuedAt + 5)).toMatchObject({ ok: true, principal: "gui-session" }); + expect(session.expiresAt).toBe(issuedAt + 5 + REMOTE_GUI_SESSION_TTL_MS); + session.expiresAt = issuedAt + 6; + expect(authorizeGuiSessionRequest(request(), config, state, issuedAt + 7)).toMatchObject({ ok: false, reason: "expired" }); + expect(state.sessions.has(session.token)).toBe(false); + }); + + test("the live pairing route refuses admin authority and exchanges only a capability-created grant", async () => { + const config = hubConfig(); + saveConfig(config); + const state = initializeManagementAuthState(config); + if (!state.available) throw new Error("expected management auth state"); + const secret = "G".repeat(43); + const server = startServer(0, { managementAuthState: state, localAttestationSecret: secret }); + try { + const adminAttempt = await fetch(new URL(GUI_PAIR_PATH, server.url), { + method: "POST", + headers: { "content-length": "0", "x-opencodex-api-key": "admin-secret" }, + }); + expect(adminAttempt.status).toBe(403); + expect(state.pairingGrants.size).toBe(0); + + const nonce = "H".repeat(43); + const expiresAt = Date.now() + GUI_PAIR_CAPABILITY_TTL_MS; + const capability = createGuiPairCapability( + secret, nonce, GUI_PAIR_METHOD, GUI_PAIR_PATH, "https://dashboard.example.test", + process.pid, server.port, expiresAt, + )!; + const capabilityHeaders = { + "content-length": "0", + [GUI_PAIR_EXPECTED_PID_HEADER]: String(process.pid), + [GUI_PAIR_NONCE_HEADER]: nonce, + [GUI_PAIR_EXPIRES_AT_HEADER]: String(expiresAt), + [GUI_PAIR_BROWSER_ORIGIN_HEADER]: "https://dashboard.example.test", + [GUI_PAIR_CAPABILITY_HEADER]: capability, + }; + const createdResponse = await fetch(new URL(GUI_PAIR_PATH, server.url), { + method: "POST", + headers: capabilityHeaders, + }); + expect(createdResponse.status).toBe(201); + expect(createdResponse.headers.get("cache-control")).toBe("no-store"); + const created = await createdResponse.json() as { grant: string }; + expect(state.pairingGrants.size).toBe(1); + const replayedCapability = await fetch(new URL(GUI_PAIR_PATH, server.url), { + method: "POST", + headers: capabilityHeaders, + }); + expect(replayedCapability.status).toBe(401); + expect(state.pairingGrants.size).toBe(1); + + const adminExchange = await fetch(new URL("/opencodex-session", server.url), { + method: "POST", + headers: { Host: "hub.example.test", Origin: "https://dashboard.example.test", "content-type": "application/json" }, + body: JSON.stringify({ grant: "admin-secret" }), + }); + expect(adminExchange.status).toBe(401); + + const exchanged = await fetch(new URL("/opencodex-session", server.url), { + method: "POST", + headers: { Host: "hub.example.test", Origin: "https://dashboard.example.test", "content-type": "application/json" }, + body: JSON.stringify({ grant: created.grant }), + }); + expect(exchanged.status).toBe(200); + expect(exchanged.headers.get("cache-control")).toBe("no-store"); + const html = await exchanged.text(); + expect(html).toContain('name="opencodex-session-origin" content="https://dashboard.example.test"'); + expect(html).toContain('name="opencodex-session-server-origin" content="https://hub.example.test"'); + expect(state.pairingGrants.size).toBe(0); } finally { await server.stop(true); } @@ -1136,4 +1440,4 @@ describe("codex app-server restart routes ride the management gate", () => { await server.stop(true); } }); -}); \ No newline at end of file +}); diff --git a/tests/sidebar-routes.test.ts b/tests/sidebar-routes.test.ts index 4a311e1125..8a84f82254 100644 --- a/tests/sidebar-routes.test.ts +++ b/tests/sidebar-routes.test.ts @@ -19,7 +19,7 @@ async function call( method: string, pathname: string, headers: Record = {}, - principal?: "admin-token" | "gui-session", + principal?: "admin-token" | "gui-session" | "gui-pair-capability", ): Promise<{ status: number; body: unknown; raw: string; routed: boolean }> { // `isAllowedManagementOrigin` derives the expected origin from the Host header and // rejects the request outright when it is missing, so Host is required here. Omitting @@ -221,6 +221,20 @@ describe("route surface", () => { expect(calls).toEqual([]); }); + test("a GUI pairing capability is not a consent-bearing session principal", async () => { + const calls: string[][] = []; + await withStarDeps({ + nowMs: () => 0, + async runGh(args) { calls.push(args); return { status: 0 }; }, + }, async () => { + invalidateStarStatusCache(); + const { status, body } = await call("POST", "/api/github/star", {}, "gui-pair-capability"); + expect(status).toBe(403); + expect((body as Record).code).toBe("agent_consent_required"); + }); + expect(calls).toEqual([]); + }); + test("a direct dispatch with no resolved principal is treated as untrusted", async () => { // Defense in depth for callers that bypass the HTTP gate (route-level tests, future // internal dispatchers): an unknown principal must never satisfy the consent check.