From abf0f81bd43184b7a42e54d8617c4c6c0b23810b Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 28 Aug 2026 02:11:32 +0900 Subject: [PATCH 1/8] feat(remote-gui): add remote session issuance and pairing --- gui/src/api.ts | 60 +++++-- src/cli/dispatch.ts | 49 ++--- src/cli/gui-pair-client.ts | 166 +++++++++++++++++ src/cli/gui.ts | 87 +++++++++ src/cli/help.ts | 3 +- src/cli/registry.ts | 10 +- src/config.ts | 94 ++++++++++ src/lib/gui-pair-capability.ts | 104 +++++++++++ src/remote/protocol.ts | 8 +- src/server/auth-cors.ts | 25 ++- src/server/gui-session.ts | 318 +++++++++++++++++++++++++++++++++ src/server/gui-static.ts | 5 +- src/server/index.ts | 74 +++++++- src/server/management-auth.ts | 186 ++++++++++--------- src/server/proxy-liveness.ts | 1 + src/types.ts | 2 + src/types/config.ts | 16 ++ 17 files changed, 1078 insertions(+), 130 deletions(-) create mode 100644 src/cli/gui-pair-client.ts create mode 100644 src/cli/gui.ts create mode 100644 src/lib/gui-pair-capability.ts create mode 100644 src/server/gui-session.ts diff --git a/gui/src/api.ts b/gui/src/api.ts index 658beac1ff..89b8a33d6b 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,19 @@ async function reBootstrapSessionToken(): Promise { return response.status >= 400 && response.status < 500 ? { kind: "unavailable" } : { kind: "failed" }; } const html = await response.text(); + let responseOrigin: string; + try { + responseOrigin = new URL(response.url || SESSION_REBOOTSTRAP_PATH, window.location.href).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 +219,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 +340,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/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..ab00b14e79 --- /dev/null +++ b/src/cli/gui-pair-client.ts @@ -0,0 +1,166 @@ +import { readRuntimePort, type RuntimePortState } from "../config/process-state"; +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 { + return !!right?.attestationSecret + && right.pid === left.pid + && right.port === left.port + && right.hostname === left.hostname + && right.attestationSecret === left.attestationSecret; +} + +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..8939dfe201 100644 --- a/src/config.ts +++ b/src/config.ts @@ -864,11 +864,60 @@ 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(), + 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 +1773,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 +1947,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 +1973,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 +1995,7 @@ export function loadConfig(): OcxConfig { warnDegradedUpstreamHostCircuitThreshold(parsed); warnDegradedAgentTaskRecovery(parsed); warnDegradedRuntimeRole(parsed); + warnDegradedOptionalRemoteBlocks(parsed); return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed)); } } @@ -2026,6 +2098,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 +2200,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 +2335,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..0cf408c993 --- /dev/null +++ b/src/server/gui-session.ts @@ -0,0 +1,318 @@ +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" + | "insecure-http-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, ...session }; +} + +function tailscaleLoginAllowed(req: Request, config: OcxConfig): boolean { + const login = req.headers.get("Tailscale-User-Login")?.trim(); + 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; + 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; + const serverUrl = new URL(serverOrigin); + let issuance: GuiSessionIssuance; + if (serverUrl.protocol === "https:") issuance = "pairing"; + else if ( + serverUrl.protocol === "http:" + && !isLoopbackHostname(serverUrl.hostname) + && config.remoteGui?.allowInsecureHttp === true + ) issuance = "insecure-http-pairing"; + else return null; + state.pairingGrants.delete(digest); + return mintSession(record.serverOrigin, record.browserOrigin, issuance, state, now); +} + +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..a8d4c7a7f5 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,7 @@ 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; /** * Name WHICH configured credential was admitted, so a multi-key operator can attribute a @@ -1037,6 +1048,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server GUI_PAIRING_EXCHANGE_BODY_LIMIT) { + return Response.json({ error: "pairing exchange body too large" }, { status: 413, headers: { "Cache-Control": "no-store" } }); + } + const text = await req.text(); + if (Buffer.byteLength(text) > GUI_PAIRING_EXCHANGE_BODY_LIMIT) { + return Response.json({ error: "pairing exchange body too large" }, { status: 413, headers: { "Cache-Control": "no-store" } }); + } + let body: unknown; + try { + body = JSON.parse(text); + } catch { + return Response.json({ error: "invalid pairing exchange body" }, { status: 400, headers: { "Cache-Control": "no-store" } }); + } + if (!body || typeof body !== "object" || Array.isArray(body) + || Object.keys(body as Record).length !== 1 + || typeof (body as Record).grant !== "string") { + return Response.json({ error: "invalid pairing exchange body" }, { status: 400, headers: { "Cache-Control": "no-store" } }); + } + const session = managementAuth.available + ? consumeGuiPairingGrant(req, body, config, managementAuth) + : null; + return session + ? withManagementCors(serveSessionBootstrap(session), req, config) + : new Response(null, { status: 401, headers: { "Cache-Control": "no-store" } }); + } + 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..a8c26c57a5 100644 --- a/src/server/management-auth.ts +++ b/src/server/management-auth.ts @@ -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,79 @@ 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 || consumedGuiPairCapabilities.has(capability)) return false; + if (consumedGuiPairCapabilities.size >= GUI_PAIR_REPLAY_LIMIT) return false; + consumedGuiPairCapabilities.set(capability, 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 +483,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 +492,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 +500,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..a2438b8b51 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -246,10 +246,26 @@ 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[]; + /** Explicitly permit one-time pairing exchange over non-loopback HTTP. */ + 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; /** From a68e95cd49418f224300bf9f303f85c38a11edb3 Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 28 Aug 2026 02:21:15 +0900 Subject: [PATCH 2/8] fix(remote-gui): harden identity and capability replay checks --- src/cli/gui-pair-client.ts | 6 +++++- src/server/gui-session.ts | 2 +- src/server/management-auth.ts | 8 +++++--- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/cli/gui-pair-client.ts b/src/cli/gui-pair-client.ts index ab00b14e79..87a164059d 100644 --- a/src/cli/gui-pair-client.ts +++ b/src/cli/gui-pair-client.ts @@ -1,4 +1,5 @@ import { readRuntimePort, type RuntimePortState } from "../config/process-state"; +import { timingSafeEqual } from "node:crypto"; import { LOCAL_ATTESTATION_CHALLENGE_HEADER, LOCAL_ATTESTATION_PROOF_HEADER, @@ -41,11 +42,14 @@ export interface GuiPairClientDeps { 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 - && right.attestationSecret === left.attestationSecret; + && leftSecret.length === rightSecret.length + && timingSafeEqual(leftSecret, rightSecret); } function canonicalHttpOrigin(value: unknown): string | null { diff --git a/src/server/gui-session.ts b/src/server/gui-session.ts index 0cf408c993..e726be0454 100644 --- a/src/server/gui-session.ts +++ b/src/server/gui-session.ts @@ -133,7 +133,7 @@ function mintSession( } function tailscaleLoginAllowed(req: Request, config: OcxConfig): boolean { - const login = req.headers.get("Tailscale-User-Login")?.trim(); + const login = req.headers.get("Tailscale-User-Login"); if (!login) return false; return (config.remoteGui?.allowedTailscaleUsers ?? []).some(user => user === login); } diff --git a/src/server/management-auth.ts b/src/server/management-auth.ts index a8c26c57a5..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, @@ -435,9 +435,11 @@ function hasGuiPairCapability( for (const [consumed, retainedUntil] of consumedGuiPairCapabilities) { if (retainedUntil <= now) consumedGuiPairCapabilities.delete(consumed); } - if (!capability || consumedGuiPairCapabilities.has(capability)) return false; + 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(capability, expiresAt); + consumedGuiPairCapabilities.set(capabilityDigest, expiresAt); admittedGuiPairRequests.add(req); return true; } From 8906173b46cb65b3c14fe09efa2d91b61969997b Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 28 Aug 2026 02:22:23 +0900 Subject: [PATCH 3/8] test(remote-gui): cover remote session consent boundaries --- gui/tests/api-auth-deadline.test.ts | 1 + gui/tests/api-auth-memory.test.ts | 82 +++++- tests/cli-dispatch.test.ts | 58 ++++- tests/cli-help.test.ts | 10 + tests/cli-registry.test.ts | 5 + tests/config.test.ts | 94 +++++++ tests/gui-management-session.test.ts | 1 + tests/gui-pair-capability.test.ts | 66 +++++ tests/gui-pair-client.test.ts | 135 ++++++++++ tests/native-profile-route-security.test.ts | 50 ++++ tests/proxy-liveness.test.ts | 18 ++ tests/server-auth.test.ts | 31 +++ tests/server-live.test.ts | 28 +++ tests/server-management-auth.test.ts | 264 +++++++++++++++++++- tests/sidebar-routes.test.ts | 16 +- 15 files changed, 850 insertions(+), 9 deletions(-) create mode 100644 tests/gui-pair-capability.test.ts create mode 100644 tests/gui-pair-client.test.ts diff --git a/gui/tests/api-auth-deadline.test.ts b/gui/tests/api-auth-deadline.test.ts index de4520ad88..a56f3c1ea7 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(""); } diff --git a/gui/tests/api-auth-memory.test.ts b/gui/tests/api-auth-memory.test.ts index ca70303e26..d5e77f9d34 100644 --- a/gui/tests/api-auth-memory.test.ts +++ b/gui/tests/api-auth-memory.test.ts @@ -352,11 +352,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 +366,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 @@ -446,3 +454,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/tests/cli-dispatch.test.ts b/tests/cli-dispatch.test.ts index a4ac1084ed..0ef0f26742 100644 --- a/tests/cli-dispatch.test.ts +++ b/tests/cli-dispatch.test.ts @@ -1,4 +1,4 @@ -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"; @@ -6,6 +6,7 @@ import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { getConfigDir } from "../src/config"; import { getAccountSet, removeCredential, saveCredential } from "../src/oauth/store"; +import { runGuiCommand } from "../src/cli/gui"; /** Minimal fake deps. dispatchCommand only touches deps for real command * runners, which these tests never invoke, so an empty object is enough. */ @@ -481,6 +482,61 @@ describe("doctor refuses --json rather than printing prose as success", () => { test("exact --json, --json=true, and Unicode dashes all exit 2", async () => { for (const flag of ["--json", "--json=true", "\u2014json"]) { expect(await runDoctor(flag), `${JSON.stringify(flag)} must be refused`).toBe(2); +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..70653fae24 100644 --- a/tests/cli-registry.test.ts +++ b/tests/cli-registry.test.ts @@ -103,6 +103,11 @@ describe("CLI command registry parity", () => { const details = findCommand("system")?.details ?? []; 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"); }); }); 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..3252af9dba --- /dev/null +++ b/tests/gui-pair-client.test.ts @@ -0,0 +1,135 @@ +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("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..104c985c80 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,53 @@ 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, + }; + 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({ ...base, Host: server.url.host })).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..5d4b95d104 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", 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..3f5197db61 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,227 @@ 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(); + expect(consumeGuiPairingGrant( + exchange("https://dashboard.example.test", "hub.example.test", { "x-opencodex-api-key": "admin-secret" }), + { 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("insecure HTTP pairing is explicit and a refused exchange can be retried after opt-in", () => { + 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(); + expect(state.pairingGrants.size).toBe(1); + config.remoteGui = { ...config.remoteGui, allowInsecureHttp: true }; + expect(consumeGuiPairingGrant(request, { grant: created.grant }, config, state, now + 2)).toMatchObject({ + issuance: "insecure-http-pairing", + }); + expect(state.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); + 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); + }); + + 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 createdResponse = await fetch(new URL(GUI_PAIR_PATH, server.url), { + method: "POST", + headers: { + "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, + }, + }); + 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 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 +1394,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. From e6c6e784de056f564fc56b377770b6e5ec155633 Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 28 Aug 2026 02:26:17 +0900 Subject: [PATCH 4/8] fix(remote-gui): enforce exact bootstrap destination --- gui/src/api.ts | 3 ++- gui/tests/api-auth-deadline.test.ts | 12 ++++++++---- gui/tests/api-auth-memory.test.ts | 16 ++++++++-------- src/server/index.ts | 17 ++++++++++------- 4 files changed, 28 insertions(+), 20 deletions(-) diff --git a/gui/src/api.ts b/gui/src/api.ts index 89b8a33d6b..cce2f98951 100644 --- a/gui/src/api.ts +++ b/gui/src/api.ts @@ -174,7 +174,8 @@ async function reBootstrapSessionToken(): Promise { const html = await response.text(); let responseOrigin: string; try { - responseOrigin = new URL(response.url || SESSION_REBOOTSTRAP_PATH, window.location.href).origin; + if (!response.url) throw new TypeError("bootstrap response URL is missing"); + responseOrigin = new URL(response.url).origin; } catch { clearToken(); return { kind: "unavailable" }; diff --git a/gui/tests/api-auth-deadline.test.ts b/gui/tests/api-auth-deadline.test.ts index a56f3c1ea7..08ff8d6afe 100644 --- a/gui/tests/api-auth-deadline.test.ts +++ b/gui/tests/api-auth-deadline.test.ts @@ -68,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 d5e77f9d34..3003bbebba 100644 --- a/gui/tests/api-auth-memory.test.ts +++ b/gui/tests/api-auth-memory.test.ts @@ -400,10 +400,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")); @@ -436,10 +436,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 }); diff --git a/src/server/index.ts b/src/server/index.ts index a8d4c7a7f5..3498970ca6 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1123,7 +1123,10 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server GUI_PAIRING_EXCHANGE_BODY_LIMIT) { - return Response.json({ error: "pairing exchange body too large" }, { status: 413, headers: { "Cache-Control": "no-store" } }); + return withManagementCors(Response.json({ error: "pairing exchange body too large" }, { status: 413, headers: { "Cache-Control": "no-store" } }), req, config); } const text = await req.text(); if (Buffer.byteLength(text) > GUI_PAIRING_EXCHANGE_BODY_LIMIT) { - return Response.json({ error: "pairing exchange body too large" }, { status: 413, headers: { "Cache-Control": "no-store" } }); + return withManagementCors(Response.json({ error: "pairing exchange body too large" }, { status: 413, headers: { "Cache-Control": "no-store" } }), req, config); } let body: unknown; try { body = JSON.parse(text); } catch { - return Response.json({ error: "invalid pairing exchange body" }, { status: 400, headers: { "Cache-Control": "no-store" } }); + 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 Response.json({ error: "invalid pairing exchange body" }, { status: 400, headers: { "Cache-Control": "no-store" } }); + 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) - : new Response(null, { status: 401, headers: { "Cache-Control": "no-store" } }); + : 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); } From 53970194d25746676905bd729da4e6864e1ddf66 Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 28 Aug 2026 02:27:06 +0900 Subject: [PATCH 5/8] test(remote-gui): lock replay and expiry negatives --- tests/gui-pair-client.test.ts | 27 ++++++++++++++++ tests/server-management-auth.test.ts | 47 +++++++++++++++++++++------- 2 files changed, 62 insertions(+), 12 deletions(-) diff --git a/tests/gui-pair-client.test.ts b/tests/gui-pair-client.test.ts index 3252af9dba..63bd9195db 100644 --- a/tests/gui-pair-client.test.ts +++ b/tests/gui-pair-client.test.ts @@ -77,6 +77,33 @@ describe("GUI pairing client", () => { 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; diff --git a/tests/server-management-auth.test.ts b/tests/server-management-auth.test.ts index 3f5197db61..3eb35155f5 100644 --- a/tests/server-management-auth.test.ts +++ b/tests/server-management-auth.test.ts @@ -996,10 +996,12 @@ describe("management and data-plane credential separation", () => { expect(consumeGuiPairingGrant( exchange("https://dashboard.example.test", "localhost:10100"), { grant: created.grant }, config, state, now + 1, )).toBeNull(); - expect(consumeGuiPairingGrant( - exchange("https://dashboard.example.test", "hub.example.test", { "x-opencodex-api-key": "admin-secret" }), - { 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, ); @@ -1082,8 +1084,22 @@ describe("management and data-plane credential separation", () => { 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 () => { @@ -1107,21 +1123,28 @@ describe("management and data-plane credential separation", () => { 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: { - "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, - }, + 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", From bf382c07d285c5cab0bb3ce8b9008b7b7ea04b1a Mon Sep 17 00:00:00 2001 From: jun Date: Fri, 28 Aug 2026 02:30:25 +0900 Subject: [PATCH 6/8] fix(remote-gui): preserve renewal and mutation origin checks --- src/server/gui-session.ts | 10 +++++++++- tests/native-profile-route-security.test.ts | 8 +++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/server/gui-session.ts b/src/server/gui-session.ts index e726be0454..3dc803c76b 100644 --- a/src/server/gui-session.ts +++ b/src/server/gui-session.ts @@ -129,7 +129,15 @@ function mintSession( issuance, }; state.sessions.set(token, session); - return { 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 { diff --git a/tests/native-profile-route-security.test.ts b/tests/native-profile-route-security.test.ts index 104c985c80..596852cccb 100644 --- a/tests/native-profile-route-security.test.ts +++ b/tests/native-profile-route-security.test.ts @@ -203,9 +203,15 @@ describe("native-main profile routes at the management admission boundary", () = "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({ ...base, Host: server.url.host })).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); From dbec9004001ff589bee6fafc2587e2ba2bddc3d7 Mon Sep 17 00:00:00 2001 From: jun Date: Tue, 1 Sep 2026 17:20:15 +0900 Subject: [PATCH 7/8] fix(remote-gui): drop plaintext pairing and bound the unauthenticated exchange body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects the review raised on this phase, both on the unauthenticated pairing path. Plaintext pairing is removed rather than gated. consumeGuiPairingGrant issued an "insecure-http-pairing" session over non-loopback HTTP whenever remoteGui.allowInsecureHttp was true. A reusable grant on plaintext 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. A grant now crosses loopback or authenticated HTTPS only, and no configuration re-opens it. The scheme check also moved ahead of the grant lookup. It previously ran after the grant was found and validated, so a refused exchange still consumed a single-use code — an attacker who strips TLS termination could spend every code the operator prints without ever authenticating. Refusing first leaves the grant intact, which the regression test asserts by replaying the same unspent grant successfully over HTTPS. allowInsecureHttp stays in the schema, marked retired. The config schema is strict, so deleting the key would make an existing config file fail to load entirely; accepting and ignoring it is the smaller harm. The exchange body bound now holds against caller-chosen framing. The endpoint is reachable without a credential, and the pre-check read Content-Length, which the caller controls: omit the header and Number(null ?? "0") is 0, or send chunked and there is no header at all. Both passed the check and reached req.text(), which buffers to completion — an unauthenticated caller decided how much memory the process spent, and the post-check only measured a string it had already been forced to hold. The read now stops at limit+1 bytes and cancels the body rather than draining it. The regression test streams 512 KiB against the 4 KiB limit with no Content-Length and asserts the server pulled fewer chunks than were offered; it fails against the previous implementation. Also repairs the rebase of tests/cli-dispatch.test.ts and tests/cli-registry.test.ts, where dev and this phase appended different tests at the same place. Both sides are kept. --- src/config.ts | 2 + src/server/gui-session.ts | 48 +++++++++++++++----- src/server/index.ts | 55 +++++++++++++++++++++- src/types/config.ts | 12 ++++- tests/cli-dispatch.test.ts | 6 ++- tests/cli-registry.test.ts | 2 + tests/server-auth.test.ts | 68 ++++++++++++++++++++++++++++ tests/server-management-auth.test.ts | 31 +++++++++++-- 8 files changed, 204 insertions(+), 20 deletions(-) diff --git a/src/config.ts b/src/config.ts index 8939dfe201..7cabaab64a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -906,6 +906,8 @@ const remoteGuiConfigSchema = z.object({ 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(); diff --git a/src/server/gui-session.ts b/src/server/gui-session.ts index 3dc803c76b..95a7008ff5 100644 --- a/src/server/gui-session.ts +++ b/src/server/gui-session.ts @@ -12,8 +12,7 @@ import { export type GuiSessionIssuance = | "loopback" | "tailscale-identity" - | "pairing" - | "insecure-http-pairing"; + | "pairing"; export interface GuiSessionRecord { serverOrigin: string; @@ -249,6 +248,19 @@ export function consumeGuiPairingGrant( 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; @@ -262,17 +274,29 @@ export function consumeGuiPairingGrant( if (browserOrigin !== record.browserOrigin) return null; const serverOrigin = managementRequestOrigin(req, config); if (serverOrigin !== record.serverOrigin) return null; - const serverUrl = new URL(serverOrigin); - let issuance: GuiSessionIssuance; - if (serverUrl.protocol === "https:") issuance = "pairing"; - else if ( - serverUrl.protocol === "http:" - && !isLoopbackHostname(serverUrl.hostname) - && config.remoteGui?.allowInsecureHttp === true - ) issuance = "insecure-http-pairing"; - else 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, issuance, state, now); + 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 { diff --git a/src/server/index.ts b/src/server/index.ts index 3498970ca6..18ca92c5ea 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -231,6 +231,46 @@ const WEBSOCKET_IDLE_TIMEOUT_SECONDS = 0; 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 * catalog read. @@ -1812,14 +1852,25 @@ 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 text = await req.text(); - if (Buffer.byteLength(text) > GUI_PAIRING_EXCHANGE_BODY_LIMIT) { + 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); diff --git a/src/types/config.ts b/src/types/config.ts index a2438b8b51..e75f3e438b 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -254,7 +254,17 @@ export interface OcxHubConfig { export interface OcxRemoteGuiConfig { /** Exact Tailscale login identities permitted to receive an automatic remote GUI session. */ allowedTailscaleUsers?: string[]; - /** Explicitly permit one-time pairing exchange over non-loopback HTTP. */ + /** + * 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; } diff --git a/tests/cli-dispatch.test.ts b/tests/cli-dispatch.test.ts index 0ef0f26742..be2425bebd 100644 --- a/tests/cli-dispatch.test.ts +++ b/tests/cli-dispatch.test.ts @@ -2,11 +2,11 @@ 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"; import { getAccountSet, removeCredential, saveCredential } from "../src/oauth/store"; -import { runGuiCommand } from "../src/cli/gui"; /** Minimal fake deps. dispatchCommand only touches deps for real command * runners, which these tests never invoke, so an empty object is enough. */ @@ -482,6 +482,10 @@ describe("doctor refuses --json rather than printing prose as success", () => { test("exact --json, --json=true, and Unicode dashes all exit 2", async () => { for (const flag of ["--json", "--json=true", "\u2014json"]) { expect(await runDoctor(flag), `${JSON.stringify(flag)} must be refused`).toBe(2); + } + }); +}); + describe("GUI command delegation", () => { const config = { port: 10100, diff --git a/tests/cli-registry.test.ts b/tests/cli-registry.test.ts index 70653fae24..7bbb1d5101 100644 --- a/tests/cli-registry.test.ts +++ b/tests/cli-registry.test.ts @@ -103,6 +103,8 @@ describe("CLI command registry parity", () => { const details = findCommand("system")?.details ?? []; 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]]"); diff --git a/tests/server-auth.test.ts b/tests/server-auth.test.ts index 5d4b95d104..7f4217b73c 100644 --- a/tests/server-auth.test.ts +++ b/tests/server-auth.test.ts @@ -4240,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-management-auth.test.ts b/tests/server-management-auth.test.ts index 3eb35155f5..d7713e0cca 100644 --- a/tests/server-management-auth.test.ts +++ b/tests/server-management-auth.test.ts @@ -1022,7 +1022,11 @@ describe("management and data-plane credential separation", () => { )).toBeNull(); }); - test("insecure HTTP pairing is explicit and a refused exchange can be retried after opt-in", () => { + 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"); @@ -1032,13 +1036,32 @@ describe("management and data-plane credential separation", () => { 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)).toMatchObject({ - issuance: "insecure-http-pairing", + 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(state.pairingGrants.size).toBe(0); + 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", () => { From dad17121d8cc747ef3c7076c2d5f370ef90497e2 Mon Sep 17 00:00:00 2001 From: jun Date: Tue, 1 Sep 2026 18:17:02 +0900 Subject: [PATCH 8/8] test(remote-gui): stub the prompt happy-dom does not implement Tests that clear a rejected session reach the admin-token fallback, which calls window.prompt. happy-dom does not implement it, so those tests died on a TypeError instead of asserting the behavior they were written for. Most tests in the file never reach the fallback, which is why it stayed hidden. A null-returning stub is the honest stand-in: it means "the operator dismissed the prompt", which is the path these tests want. Installed only when prompt is genuinely missing, so a real implementation is never shadowed. --- gui/tests/api-auth-memory.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/gui/tests/api-auth-memory.test.ts b/gui/tests/api-auth-memory.test.ts index 3003bbebba..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; });