From 54599cd759ad40aa4214fdae897acf3573a855b7 Mon Sep 17 00:00:00 2001 From: lidge-jun Date: Tue, 15 Sep 2026 11:50:59 +0900 Subject: [PATCH 1/6] refactor(server): split server/index.ts behind a facade src/server/index.ts was 3,400 lines and 2,395 of them were startServer. Moving only the module-scope symbols out left a 2,661-line facade, so the split had to reach inside that function. It now stands at 892 lines. Five leaves under src/server/index/: bounded-request.ts 88 bounded request-text reader and pairing limits startup-warnings.ts 204 startup ownership probe and the startup warnings websocket-handler.ts 334 the websocket half of the Bun.serve options live-sideband.ts 540 the live-sideband upstream socket subsystem serve-options.ts 1,764 the HTTP fetch handler and the serve options The first three plus live-sideband are pure moves of module-scope declarations. serve-options is not: the `const serveOptions = { ... }` block captured 24 startServer locals, so it becomes `createServeOptions(ctx)`. Twenty-one of those are immutable and are destructured at the top of the factory, leaving the body byte-identical. The other three are mutable `let` bindings that the body reads after startServer has moved on -- `server`, `boundPort` and `remoteWorkspaceStopping` -- so the facade passes them as getters and exactly seven lines in the body changed from `x` to `ctx.x`. Destructuring those three would have snapshotted `null`, `null` and `false` at construction time and the health port, the pairing port and every remote-workspace shutdown check would have silently read the wrong value. The synchronous activation window is untouched. `Bun.serve` through `return server` stays in the facade byte for byte, which is what tests/lab/core-lab-boundary.test.ts anchors on, and the free functions that window calls keep their imports in the facade so the callee check added in #4674 still resolves them. That suite is 19 pass / 0 fail against this tree. Four source oracles that read src/server/index.ts as text were repointed at the leaf that now holds what they check: the runAdmittedHttpTurn call sites, the Anthropic route branches, the catalog-busy mapping, and the websocket idle-timeout policy. Their assertion strings are unchanged except one: ws-endpoint pinned an inline `websocket: {` block that is now a factory call, so it pins the call instead. The invariant is the same -- the serve options declare an explicit idle timeout rather than inheriting a default. Four more oracles needed no change because what they read stayed in the facade. That was determined by resolving every string literal in a file-reading test against the real src tree rather than grepping for the literal path, which is the check that caught the equivalent miss on the bridge split. Ratchet cap lowered from 3,400 to 892. --- src/server/index.ts | 2578 +---------------- src/server/index/bounded-request.ts | 88 + src/server/index/live-sideband.ts | 540 ++++ src/server/index/serve-options.ts | 1766 +++++++++++ src/server/index/startup-warnings.ts | 204 ++ src/server/index/websocket-handler.ts | 335 +++ .../model-visibility-management-api.test.ts | 4 +- tests/fixtures/file-size-baseline.json | 4 +- tests/lib/workflow-budget.test.ts | 9 +- tests/responses/ws-endpoint.test.ts | 15 +- .../loopback-listener-admission.test.ts | 5 +- 11 files changed, 3001 insertions(+), 2547 deletions(-) create mode 100644 src/server/index/bounded-request.ts create mode 100644 src/server/index/live-sideband.ts create mode 100644 src/server/index/serve-options.ts create mode 100644 src/server/index/startup-warnings.ts create mode 100644 src/server/index/websocket-handler.ts diff --git a/src/server/index.ts b/src/server/index.ts index d26814b0ed..5b38cbc39c 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,8 +1,5 @@ import { remoteWorkspaceEnabled } from "../remote-control/workspace-activation"; - import { AuxiliaryListenerBindError } from "./ports"; -import { markActivity } from "../lib/sidecar-tracker"; -import { knownModelIdsForProvider } from "../router"; import { buildWarmupCompletionFrames, buildWsErrorFrame, @@ -25,7 +22,6 @@ import { loopbackCompanionBindError, websocketsEnabled, } from "../config"; -import { grokDefaultReasoningEffort } from "../grok/effort"; import { flushConfigDirHardening } from "../config/paths"; import { migrateStartupSubagentModels } from "./subagent-models-startup"; import { migrateStartupXaiResponses } from "./xai-responses-startup"; @@ -77,26 +73,12 @@ import { MIN_CONFIGURABLE_INBOUND_BODY_BYTES, resolveInboundBodyLimitBytes, } from "./request-decompress"; -import { - CodexAccountCooldownError, - cooldownErrorMessage, -} from "../codex/auth-context"; -import { codexAccountNamespaceForModel } from "../codex/account-namespace-match"; -import { codexAccountNamespaceEntries, isMainCodexAccountTarget } from "../codex/account-namespaces"; import { MAIN_CODEX_ACCOUNT_ID } from "../codex/main-account"; -import { - availableAccountGatedNativeModels, - codexModelEntitlementStateForAccount, - resolveCodexModelEntitlements, -} from "../codex/model-entitlements"; export { clearThreadAccountMap, formatCodexProviderForLog, resolveCodexAccountForThread, } from "../codex/routing"; -import { formatCodexProviderForLog } from "../codex/routing"; -import { CatalogGatherBusyError } from "../codex/catalog/provider-fetch"; -import { registerCodexWebSocket, tryReserveCodexWebSocket, unregisterCodexWebSocket, updateCodexWebSocketAuthContext } from "../codex/websocket-registry"; import { resolveGuiFilePath, rootFallbackPayload, serveGuiFile, serveSessionBootstrap } from "./gui-static"; export { resolveGuiFilePath, rootFallbackPayload } from "./gui-static"; export { resolveAdapter } from "./adapter-resolve"; @@ -149,13 +131,6 @@ export { type RequestLogContext, type RequestLogEntry, } from "./request-log"; -import { - consumeForInspection, - relaySseWithHeartbeat, - relayWithAbort, - responseWithDeferredRequestLog, - sanitizePassthroughHeaders, -} from "./relay"; export { consumeForInspection, codexSafetyBufferingFilterOptions, @@ -194,13 +169,7 @@ export { jsonResponse, safeConfigDTO, } from "./auth-cors"; -import { disableResponsesRequestTimeout, handleResponses, handleResponsesCompact } from "./responses"; export { disableResponsesRequestTimeout, linkAbortSignal } from "./responses"; -import { handleClaudeCountTokens, handleClaudeMessages } from "./claude-messages"; -import { handleChatCompletions } from "./chat-completions"; -import { anthropicErrorResponse } from "../claude/outbound"; -import { buildDesktop3pRegistry, generateDesktop3pModels } from "../claude/desktop-3p"; -import { buildDesktopDiscoveryInputs } from "../claude/desktop-discovery-inputs"; import { runClaudeAuthModeMigration } from "../claude/auth-mode-migration"; import { runRetiredCodexModelMigration } from "../codex/retired-model-migration"; import { @@ -211,17 +180,7 @@ import { type NativeMainStartupGateDeps, type NativeMainStartupLifecycle, } from "../codex/native-profile-startup"; -import { handleImages } from "./images"; -import { handleLive, logLiveSidebandFrame, parseLiveSidebandTarget, resolveLiveSidebandUpgrade } from "./live"; -import { handleAudioTranscriptions } from "./audio-transcriptions"; -import { resolveAudioAdmission, TRANSCRIPTION_MODEL } from "./audio-upstream"; -import { resolveAudioClient } from "./audio-client"; -import { resolveDictationSocket } from "./audio-dictation"; -import { handleExternalLive, resolveExternalLiveSocket } from "./audio-live"; import { EXTERNAL_CALL_PREFIX, LiveCallBindings } from "./live-call-bindings"; -import { clearableDeadline } from "../lib/abort"; -import { handleSearch } from "./search"; -import { handleContextHistory } from "./context-history"; import { codexCompatibleUrl, contextEndpoint, contextRelayActivated } from "../codex/context-compat"; import { fetchAllModels, handleManagementAPI, VERSION, type ManagementApiDeps } from "./management-api"; import { @@ -238,770 +197,19 @@ import { createLocalAttestationProof, createLocalAttestationSecret, } 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, type PackageTreeIntegrityGuard, } from "../lib/package-tree-integrity"; import { detectInstall } from "../update/index"; -import { readyProtocolMetadata } from "../remote/protocol"; -import { modelCapabilityFields } from "./models-capabilities"; -import { recordCursorSeen } from "../integrations/cursor-seen"; -import { detectCursorInstalls } from "../integrations/cursor-detect"; -import { loadCursorEffortTable } from "../integrations/cursor-effort-table"; -import { expandCursorEffortRow, knownEffortRowIds } from "./effort-row"; -import { catalogFastRowEligible, expandFastRow } from "./fast-row"; - -export const MAX_WS_FRAME_BYTES = 50 * 1024 * 1024; -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; -const REMOTE_WORKSPACE_PAIRING_BODY_LIMIT = 32 * 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. - * - * Scoped to configured keys on purpose: an environment token or a loopback bind has no key - * to name, and emitting one anyway would invent an attribution that does not exist. 200 only - * — this route emits no validator and therefore never answers 304. - * - * An id that fails the header-safe pattern is omitted rather than sanitized, with one warning - * that does NOT repeat the id: logging the offending value is how a malformed id becomes a - * log-injection vector instead of a dropped header. - */ -function withRemoteCatalogKeyId(response: Response, admission: DataPlaneAdmission): Response { - if (response.status !== 200 || admission.kind !== "configured") return response; - if (!REMOTE_CATALOG_KEY_ID_PATTERN.test(admission.keyId)) { - console.warn("[remote-catalog] configured API key id is not header-safe; omitting x-opencodex-key-id"); - return response; - } - response.headers.set("x-opencodex-key-id", admission.keyId); - return response; -} - -const LIVE_SIDEBAND_PENDING_MAX = 32; -const LIVE_SIDEBAND_PENDING_BYTES_MAX = 1024 * 1024; -const LIVE_SIDEBAND_CLOSE_FALLBACK_MS = 1_000; -/** - * Bound the pre-upgrade upstream handshake. A sideband join that cannot reach 101 - * must fail the client upgrade promptly rather than hold it open indefinitely. - */ -export const LIVE_SIDEBAND_UPSTREAM_OPEN_TIMEOUT_MS = 10_000; - -/** - * Outcome of the upstream sideband handshake performed before the client upgrade. - * - * `ok: false` carries the HTTP status the client upgrade must fail with. Only an - * upgrade failure reaches codex-rs as a connect error, and only a connect error - * ends its sideband reconnect loop (`realtime_conversation/sideband.rs`: the `Err` - * arm always breaks). A 101 followed by a close is instead read as `TransportLost` - * and retried forever against the same, permanently dead call id. - */ -export type LiveSidebandUpstreamOpenResult = - | { - ok: true; - socket: WebSocket; - /** Owns capture and terminal events until the downstream relay attaches. */ - handoff: LiveSidebandUpstreamHandoff; - } - | { ok: false; status: number; code: string; message: string; socket?: WebSocket }; - -export function exceedsLiveSidebandFrameByteLimit(frameBytes: number): boolean { - return frameBytes > MAX_WS_FRAME_BYTES; -} - -export function exceedsLiveSidebandPendingByteLimit(pendingBytes: number, incomingBytes: number): boolean { - return incomingBytes > LIVE_SIDEBAND_PENDING_BYTES_MAX - pendingBytes; -} - -function webSocketFrameBytes(frame: string | ArrayBuffer | ArrayBufferView | Blob | Buffer): number { - if (typeof frame === "string") return Buffer.byteLength(frame); - if (frame instanceof ArrayBuffer || ArrayBuffer.isView(frame)) return frame.byteLength; - return frame.size; -} - -export type LiveSidebandPendingEnqueueResult = "queued" | "too-many-frames" | "too-many-bytes"; - -export function enqueueLiveSidebandPendingFrame( - data: Pick, - frame: string | Buffer, - frameBytes = webSocketFrameBytes(frame), -): LiveSidebandPendingEnqueueResult { - const pending = data.livePending ?? (data.livePending = []); - if (pending.length >= LIVE_SIDEBAND_PENDING_MAX) return "too-many-frames"; - const pendingBytes = data.livePendingBytes ?? 0; - if (exceedsLiveSidebandPendingByteLimit(pendingBytes, frameBytes)) return "too-many-bytes"; - pending.push(frame); - data.livePendingBytes = pendingBytes + frameBytes; - return "queued"; -} - -type LiveSidebandWebSocketFactory = ( - url: string, - headers: Record, - protocols?: string[], -) => WebSocket; - -function releaseLiveSidebandAdmission(ws: ServerWebSocket): void { - ws.data.liveTurnAdmissionLease?.release(); - ws.data.liveTurnAdmissionLease = undefined; -} - -/** - * Send one live-sideband frame to the upstream socket. - * - * Bun's `WebSocket.send` accepts `string | Blob | BufferSource`, but the DOM-lib - * `Buffer` can be backed by a `SharedArrayBuffer`, which `BufferSource` rejects. - * `Uint8Array.from` copies into a fresh `ArrayBuffer`-backed view, so a frame - * arriving from `node:buffer` still round-trips byte-for-byte. - */ -function sendUpstreamFrame(upstream: WebSocket, frame: string | Buffer): void { - if (typeof frame === "string") { - upstream.send(frame); - return; - } - upstream.send(Uint8Array.from(frame)); -} - -function finalizeLiveSideband(ws: ServerWebSocket, upstream?: WebSocket): void { - if (upstream && ws.data.liveUpstream !== upstream) return; - if (ws.data.liveCloseFallback !== undefined) { - clearTimeout(ws.data.liveCloseFallback); - ws.data.liveCloseFallback = undefined; - } - ws.data.liveUpstream = undefined; - ws.data.livePending = undefined; - ws.data.livePendingBytes = undefined; - if (ws.data.liveConnectTimer !== undefined) clearTimeout(ws.data.liveConnectTimer); - if (ws.data.liveSessionTimer !== undefined) clearTimeout(ws.data.liveSessionTimer); - ws.data.liveConnectTimer = undefined; - ws.data.liveSessionTimer = undefined; - ws.data.liveUpstreamHeaders = undefined; - ws.data.liveUpstreamProtocols = undefined; - ws.data.liveValidateFrame = undefined; - if (ws.data.liveAbortListener) ws.data.liveAbortSignal?.removeEventListener("abort", ws.data.liveAbortListener); - ws.data.liveAbortSignal = undefined; - ws.data.liveAbortListener = undefined; - ws.data.cancel = undefined; - const finish = ws.data.liveFinish; - ws.data.liveFinish = undefined; - try { finish?.(ws.data.liveOutcome); } - catch { console.warn("[audio] upstream accounting failed during close"); } - finally { releaseLiveSidebandAdmission(ws); } -} - -function armLiveSidebandCloseFallback(ws: ServerWebSocket, upstream: WebSocket): void { - if (ws.data.liveCloseFallback !== undefined) return; - ws.data.liveCloseFallback = setTimeout(() => { - ws.data.liveCloseFallback = undefined; - if (ws.data.liveUpstream !== upstream) return; - if (upstream.readyState === WebSocket.CLOSED) { - finalizeLiveSideband(ws, upstream); - return; - } - // A close frame was already sent below. Retry once, but never surrender - // native-main ownership while the authenticated transport remains live. - try { - upstream.close(1000, "upstream close timeout"); - } catch { - /* upstream is already unusable */ - } - // Some implementations transition synchronously without delivering the - // close event. That is still an observed CLOSED transport and is safe to - // finalize. CONNECTING/CLOSING peers keep the lease so profile switching - // fails at its own bounded drain deadline instead of racing live traffic. - // The earlier CLOSED check narrowed `readyState` to 0|1|2 in the type - // system, but the socket can still transition to CLOSED (3) before this - // fallback fires; the cast keeps the runtime-identical check. - if ((upstream.readyState as number) === 3) finalizeLiveSideband(ws, upstream); - }, LIVE_SIDEBAND_CLOSE_FALLBACK_MS); -} - -function closeLiveSidebandBeforeUpgrade( - upstream: WebSocket, - release: () => void, - code = 1000, - reason = "", -): void { - // There is no downstream socket to own this transport yet. Mirror - // closeLiveSideband's bounded close contract directly: release only after a - // close event or an observed CLOSED state, never merely after requesting close. - let released = false; - let fallback: ReturnType | undefined; - const releaseOnce = (): void => { - if (released) return; - released = true; - if (fallback !== undefined) clearTimeout(fallback); - release(); - }; - upstream.addEventListener("close", releaseOnce, { once: true }); - if (upstream.readyState === WebSocket.CLOSED) { - releaseOnce(); - return; - } - fallback = setTimeout(() => { - if (upstream.readyState === WebSocket.CLOSED) { - releaseOnce(); - return; - } - try { - upstream.close(1000, "upstream close timeout"); - } catch { - /* retain ownership until CLOSED is observed */ - } - if ((upstream.readyState as number) === 3) releaseOnce(); - }, LIVE_SIDEBAND_CLOSE_FALLBACK_MS); - try { - upstream.close(code, reason); - } catch { - /* the bounded fallback retries without releasing ownership */ - } - if ((upstream.readyState as number) === 3) releaseOnce(); -} - -function closeLiveSideband(ws: ServerWebSocket, code = 1000, reason = ""): void { - if (ws.data.liveClosing) return; - ws.data.liveClosing = true; - if (ws.data.liveConnectTimer !== undefined) clearTimeout(ws.data.liveConnectTimer); - if (ws.data.liveSessionTimer !== undefined) clearTimeout(ws.data.liveSessionTimer); - ws.data.liveConnectTimer = undefined; - ws.data.liveSessionTimer = undefined; - ws.data.livePending = undefined; - ws.data.livePendingBytes = undefined; - ws.data.cancel = undefined; - const upstream = ws.data.liveUpstream; - // Bun's `WebSocket` type narrows `readyState` to 0|1|2 even though the DOM - // constant CLOSED is 3; the numeric literal is the runtime-identical check. - if (!upstream || upstream.readyState === 3) { - finalizeLiveSideband(ws, upstream); - } else { - // The sideband holds a native-main admission lease. Do not release it just - // because the downstream left: its authenticated upstream remains live - // until the close event arrives or the transport is observed CLOSED. The - // bounded fallback only retries close; it does not release ownership. - armLiveSidebandCloseFallback(ws, upstream); - try { - upstream.close(code, reason); - } catch { - /* the fallback retries close without releasing ownership */ - } - } - try { - if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { - ws.close(code, reason); - } - } catch { - /* client already gone */ - } -} - -/** - * Dial the upstream sideband and report whether its handshake reached 101. - * - * Bun's client WebSocket does not surface the upstream handshake status, so the - * result is "opened" or "failed" and nothing finer. That is sufficient for the - * property this exists to guarantee: the client is never told the relay is live - * when it is not. Frames the upstream sends before the client socket exists are - * captured and handed back by `drain`, because a session preamble such as - * `session.created` arrives immediately after the upstream opens. - */ -export function openLiveSidebandUpstream( - url: string, - headers: Record, - createWebSocket: LiveSidebandWebSocketFactory = (socketUrl, socketHeaders) => ( - new WebSocket(socketUrl, { headers: socketHeaders } as unknown as string[]) - ), - timeoutMs: number = LIVE_SIDEBAND_UPSTREAM_OPEN_TIMEOUT_MS, - signal?: AbortSignal, -): Promise { - return new Promise(resolve => { - let socket: WebSocket; - try { - socket = createWebSocket(url, headers); - } catch { - resolve({ ok: false, status: 502, code: "upstream_error", message: "voice upstream connect failed" }); - return; - } - - const buffered: Array = []; - let bufferedBytes = 0; - let capturing = true; - let settled = false; - let terminalFailure: LiveSidebandUpstreamFailure | undefined; - let removeAbortListener = (): void => {}; - - const finish = (result: LiveSidebandUpstreamOpenResult): void => { - if (settled) return; - settled = true; - clearTimeout(timer); - removeAbortListener(); - resolve(result); - }; - const timer = setTimeout(() => { - const failure = { status: 504, code: "upstream_timeout", message: "voice upstream did not open in time" }; - terminalFailure = failure; - capturing = false; - buffered.length = 0; - bufferedBytes = 0; - finish({ ok: false, ...failure, socket }); - try { - socket.close(); - } catch { - /* ignore */ - } - }, timeoutMs); - - const failCapture = (failure: LiveSidebandUpstreamFailure): void => { - if (!capturing || terminalFailure) return; - terminalFailure = failure; - capturing = false; - buffered.length = 0; - bufferedBytes = 0; - finish({ ok: false, ...failure, socket }); - try { - socket.close(1009, "sideband preamble overflow"); - } catch { - /* the terminal failure is already retained for the downstream handoff */ - } - }; - const handoff: LiveSidebandUpstreamHandoff = { - failure: () => terminalFailure, - take: () => { - capturing = false; - if (terminalFailure) return { ok: false, failure: terminalFailure }; - const frames = buffered.slice(); - buffered.length = 0; - bufferedBytes = 0; - return { ok: true, frames }; - }, - }; - - socket.addEventListener("message", event => { - if (!capturing) return; - const frameBytes = webSocketFrameBytes(event.data); - if (exceedsLiveSidebandFrameByteLimit(frameBytes)) { - failCapture({ status: 502, code: "upstream_overflow", message: "voice upstream preamble frame is too large" }); - return; - } - if (buffered.length >= LIVE_SIDEBAND_PENDING_MAX) { - failCapture({ status: 502, code: "upstream_overflow", message: "voice upstream sent too many preamble frames" }); - return; - } - if (exceedsLiveSidebandPendingByteLimit(bufferedBytes, frameBytes)) { - failCapture({ status: 502, code: "upstream_overflow", message: "voice upstream preamble is too large" }); - return; - } - if (typeof event.data === "string") buffered.push(event.data); - else if (event.data instanceof ArrayBuffer) buffered.push(Buffer.from(new Uint8Array(event.data))); - else if (ArrayBuffer.isView(event.data)) { - buffered.push(Buffer.from(new Uint8Array(event.data.buffer, event.data.byteOffset, event.data.byteLength))); - } else return; - bufferedBytes += frameBytes; - }); - socket.addEventListener("open", () => { - finish({ - ok: true, - socket, - handoff, - }); - }); - socket.addEventListener("error", () => { - const failure = { status: 502, code: "upstream_error", message: "voice upstream rejected the sideband join" }; - terminalFailure ??= failure; - capturing = false; - buffered.length = 0; - bufferedBytes = 0; - finish({ ok: false, ...terminalFailure, socket }); - try { - socket.close(); - } catch { - /* the terminal failure is already retained */ - } - }); - socket.addEventListener("close", event => { - const failure = { - status: 502, - code: "upstream_error", - message: `voice upstream closed before opening (code ${event.code})`, - closeCode: event.code, - closeReason: event.reason, - }; - terminalFailure ??= failure; - capturing = false; - buffered.length = 0; - bufferedBytes = 0; - finish({ ok: false, ...terminalFailure, socket }); - }); - const abortOpen = (): void => { - const failure = { status: 499, code: "request_cancelled", message: "voice sideband join was cancelled" }; - terminalFailure ??= failure; - capturing = false; - buffered.length = 0; - bufferedBytes = 0; - finish({ ok: false, ...terminalFailure, socket }); - try { - socket.close(); - } catch { - /* the cancelled join no longer owns the socket */ - } - }; - if (signal) { - signal.addEventListener("abort", abortOpen, { once: true }); - removeAbortListener = () => signal.removeEventListener("abort", abortOpen); - if (signal.aborted) abortOpen(); - } - }); -} - -export function attachLiveSidebandUpstream( - ws: ServerWebSocket, - createWebSocket: LiveSidebandWebSocketFactory = (url, headers, protocols) => ( - new WebSocket(url, { headers, protocols } as unknown as string[]) - ), -): void { - if (ws.data.liveAbortSignal?.aborted) { - closeLiveSideband(ws, 1000, "audio connection canceled"); - return; - } - const preOpened = ws.data.liveUpstream; - let upstream: WebSocket; - if (preOpened) { - upstream = preOpened; - } else { - const url = ws.data.liveUpstreamUrl; - if (!url) { - closeLiveSideband(ws, 1011, "missing upstream"); - return; - } - try { - // Bun accepts per-handshake headers; the DOM lib types only list protocol arrays. - upstream = createWebSocket(url, ws.data.liveUpstreamHeaders ?? {}, ws.data.liveUpstreamProtocols); - } catch { - closeLiveSideband(ws, 1011, "upstream connect failed"); - return; - } - } - ws.data.liveUpstream = upstream; - ws.data.liveUpstreamHeaders = undefined; - ws.data.liveUpstreamProtocols = undefined; - ws.data.liveClosing = false; - ws.data.cancel = () => closeLiveSideband(ws, 1000, "client closed"); - if (ws.data.liveMaxSessionMs !== undefined) { - ws.data.liveConnectTimer = setTimeout(() => { - ws.data.liveOutcome = "timeout"; - closeLiveSideband(ws, 1011, "audio connection timed out"); - }, 10_000); - ws.data.liveSessionTimer = setTimeout(() => closeLiveSideband(ws, 1000, "audio session expired"), ws.data.liveMaxSessionMs); - } - - upstream.addEventListener("close", (event) => { - if (ws.data.liveUpstream !== upstream) return; - if (ws.data.liveFinish && !ws.data.liveClosing && event.code !== 1000) ws.data.liveOutcome = "connect_error"; - ws.data.liveClosing = true; - finalizeLiveSideband(ws, upstream); - try { - const external = ws.data.liveMaxSessionMs !== undefined; - const validCode = event.code === 1000 || (event.code >= 1001 && event.code <= 1014 && ![1004, 1005, 1006].includes(event.code)) - || (event.code >= 3000 && event.code <= 4999); - ws.close(external && !validCode ? 1011 : event.code || 1000, external ? "audio upstream closed" : event.reason || ""); - } catch { - /* ignore */ - } - }); - upstream.addEventListener("error", () => { - if (ws.data.liveUpstream !== upstream) return; - if (ws.data.liveFinish && !ws.data.liveClosing) ws.data.liveOutcome = "connect_error"; - closeLiveSideband(ws, 1011, "upstream error"); - }); - if (ws.data.liveAbortSignal) { - ws.data.liveAbortListener = () => closeLiveSideband(ws, 1000, "audio connection canceled"); - ws.data.liveAbortSignal.addEventListener("abort", ws.data.liveAbortListener, { once: true }); - if (ws.data.liveAbortSignal.aborted) closeLiveSideband(ws, 1000, "audio connection canceled"); - } - - if (preOpened) { - // The upstream opened before this socket existed, so its `open` event has already - // fired and the listener below will never run. Its early frames were captured for - // us; forward the capture now rather than dropping the session preamble. - const handoff = ws.data.liveUpstreamHandoff; - ws.data.liveUpstreamHandoff = undefined; - const takeover = handoff?.take(); - if (!takeover?.ok || preOpened.readyState !== WebSocket.OPEN) { - const failure = takeover && !takeover.ok ? takeover.failure : undefined; - closeLiveSideband( - ws, - failure?.closeCode ?? 1011, - failure?.closeReason ?? "upstream closed before relay attachment", - ); - return; - } - ws.data.liveOpened = true; - // The upstream opened before this socket existed, so the "open" listener - // below can never fire for it. Disarm the connect watchdog exactly as that - // listener would, or every session with a max lifetime is force-closed ten - // seconds after attach. The session timer stays armed: it bounds the whole - // session, not the connect phase. - if (ws.data.liveConnectTimer !== undefined) clearTimeout(ws.data.liveConnectTimer); - ws.data.liveConnectTimer = undefined; - for (const frame of takeover.frames) { - try { - // Mirror the live message listener exactly: same ceiling, same diagnostic - // record. These frames are upstream-to-client like any other. - if (exceedsLiveSidebandFrameByteLimit(webSocketFrameBytes(frame))) { - closeLiveSideband(ws, 1009, "message too large"); - return; - } - logLiveSidebandFrame("u2c", frame); - ws.send(frame); - } catch { - closeLiveSideband(ws, 1011, "client send failed"); - return; - } - } - } - - upstream.addEventListener("open", () => { - if (ws.data.liveUpstream !== upstream || ws.data.liveClosing) return; - ws.data.liveOpened = true; - if (ws.data.liveConnectTimer !== undefined) clearTimeout(ws.data.liveConnectTimer); - ws.data.liveConnectTimer = undefined; - // An accepted transport alone does not prove inference/quota recovery. - // Keep healthy closes neutral; explicit transport failures are recorded below. - const pending = ws.data.livePending ?? []; - ws.data.livePending = undefined; - ws.data.livePendingBytes = undefined; - for (const frame of pending) { - try { - sendUpstreamFrame(upstream, frame); - } catch { - closeLiveSideband(ws, 1011, "upstream send failed"); - return; - } - } - }); - upstream.addEventListener("message", (event) => { - if (ws.data.liveUpstream !== upstream || ws.data.liveClosing) return; - try { - if (exceedsLiveSidebandFrameByteLimit(webSocketFrameBytes(event.data))) { - closeLiveSideband(ws, 1009, "message too large"); - return; - } - logLiveSidebandFrame("u2c", event.data); - let sent: number; - if (typeof event.data === "string") sent = ws.send(event.data); - else if (event.data instanceof ArrayBuffer) sent = ws.send(event.data); - else if (ArrayBuffer.isView(event.data)) { - sent = ws.send(event.data.buffer.slice(event.data.byteOffset, event.data.byteOffset + event.data.byteLength)); - } else sent = ws.send(event.data as Buffer); - if (ws.data.liveMaxSessionMs !== undefined && (sent === 0 || ws.getBufferedAmount() > MAX_WS_FRAME_BYTES)) { - closeLiveSideband(ws, 1013, "audio client backpressure"); - } - } catch { - closeLiveSideband(ws, 1011, "client send failed"); - } - }); -} - -// GUI static serving extracted to ./server/gui-static. Re-exported below to keep the -// "../src/server" import surface stable for tests/callers. - -// Adapter resolution + wire-protocol override extracted to ./server/adapter-resolve. - -// Source invariant for tests/responses/passthrough-abort.test.ts after the pure module split: -// if (isEventStream && upstreamResponse.body) { -// const repairConfig = route.provider.responsesItemIdRepair; -// const needsClientRewrite = imageGenCallAliases.size > 0 -// #314 gated shape: win32 always uses the terminal-aware eager relay so a keep-alive -// upstream cannot hold Codex open after response.completed; darwin no-rewrite traffic -// requires explicit config-eager opt-in (`auto` always stays tee on darwin). -// selectEagerPath(process.platform, needsClientRewrite, config.streamMode ?? "auto") -// Codex upstream WS runtime gating and the forced bounded single-reader branch -// are owned by responses/ws-upstream.ts and responses/core.ts respectively. -// relaySseEagerBounded(upstreamResponse.body, turnAc, -// new Response(eagerBody, -// Default shape (tee + background inspection): -// upstreamResponse.body.tee() -// const repairedBody = hasResponsesItemIdRepair(repairConfig) -// relaySseWithFailedTail(repairedBody, upstream) -// new Response(clientBody -// markNativePassthroughSseResponse -// const body = relayWithAbort(upstreamResponse.body, upstream); -// function responseWithDeferredRequestLog -// isNativePassthroughSseResponse(response) -// trackSseForRequestLog( -// export function relaySseWithHeartbeat - -const REQUEST_LOG_ID_RESPONSE_HEADER = "x-opencodex-request-id"; - -function withRequestLogId(response: Response, requestId: string): Response { - const headers = new Headers(response.headers); - headers.set(REQUEST_LOG_ID_RESPONSE_HEADER, requestId); - // A custom `x-` header is not CORS-safelisted, so cross-origin JavaScript gets null from - // `response.headers.get()` even though the header is on the wire. Naming it here is what - // makes the id readable by a browser client — the only caller that needs a correlation id - // it did not send itself. - // - // Appending to whatever `withCors` already set, rather than overwriting, keeps this - // independent of the CORS layer: if the data plane later exposes another header, both - // survive. Duplicate names are harmless, and the header stays absent from responses that - // never reach this wrapper, so no management or rejected-origin response is widened. - const exposed = headers.get("Access-Control-Expose-Headers"); - const already = (exposed ?? "") - .split(",") - .some(name => name.trim().toLowerCase() === REQUEST_LOG_ID_RESPONSE_HEADER); - if (!already) { - headers.set( - "Access-Control-Expose-Headers", - exposed ? `${exposed}, ${REQUEST_LOG_ID_RESPONSE_HEADER}` : REQUEST_LOG_ID_RESPONSE_HEADER, - ); - } - return new Response(response.body, { - status: response.status, - statusText: response.statusText, - headers, - }); -} - -export interface StartServerDeps { - /** Test-only seam; production always initializes its own management credential state. */ - managementAuthState?: ManagementAuthState; - /** Test-only route dependencies, forwarded only after management admission succeeds. */ - managementApi?: ManagementApiDeps; - /** Test-only native-main recovery dependencies; production constructs the normal manager. */ - nativeMainStartup?: NativeMainStartupGateDeps; - /** Test-only ownership evidence; production inspects the installed service state. */ - inspectNativeCodexOwnership?: typeof inspectNativeCodexOwnership; - /** Test-only service-home resolver; production resolves the current homes directly. */ - resolveServiceHomes?: typeof currentServiceHomes; - /** Test-only seam for an upstream that cannot complete its WebSocket close handshake. */ - liveSidebandWebSocketFactory?: LiveSidebandWebSocketFactory; - /** Test-only seam; production derives a fresh local-attestation secret per process. */ - localAttestationSecret?: string; - /** Optional readiness gate; a fresh pending gate is created when omitted. */ - readinessGate?: ReadinessGate; - /** Test-only package-tree observation; production captures package.json identity at boot. */ - packageTreeIntegrity?: PackageTreeIntegrityGuard; - /** Test-only seam for observing quota-worker registration ownership. */ - registerCodexQuotaAutoRefreshWorker?: typeof registerCodexQuotaAutoRefreshWorker; -} - -function inspectStartupOwnership( - deps: StartServerDeps, - currentHomes: ReturnType | null, - statePaths: readonly string[] | null, - windowsTaskListingCache?: ReturnType, -): OwnershipInspection { - try { - if (currentHomes === null || statePaths === null) { - return { - ownership: "unknown", - reason: "startup service-home resolution failed", - }; - } - if (deps.inspectNativeCodexOwnership) { - return deps.inspectNativeCodexOwnership({ currentHomes, statePaths, windowsTaskListingCache }); - } - return inspectNativeCodexOwnership({ currentHomes, statePaths, windowsTaskListingCache }); - } catch { - return { - ownership: "unknown", - reason: "service-home ownership inspection failed", - }; - } -} - -/* - * #1046. `startServer` rewrites the Codex models cache during boot, and an - * app-server that started earlier keeps its own in-memory model list. The stale - * warning is not emitted here: `handleStart` runs a catalog sync moments later, - * so warning now would read an mtime that write is about to move, and both sites - * calling the helper independently would warn twice. This records the fact; the - * CLI start path owns the single decision. - * - * A caller that starts a server without `handleStart` (tests, embedded use) - * deliberately gets no warning — lifecycle diagnostics belong to whoever owns - * the lifecycle. - */ -let startupCacheInvalidationWrote = false; - -/** #1046: did this process's startup cache invalidation actually write? */ -export function consumeStartupCacheInvalidationWrite(): boolean { - const wrote = startupCacheInvalidationWrote; - startupCacheInvalidationWrote = false; - return wrote; -} - -export function warnAgentTaskRecoveryStartup(config: { - agentTaskRecovery?: { enabled?: boolean }; -}): void { - if (config.agentTaskRecovery?.enabled !== true) return; - console.warn("⚠️ Experimental encrypted V2 task recovery is enabled."); - console.warn(" A scoped cache miss may send an additional authenticated request to ChatGPT and may consume quota or add latency; concurrent misses can share one request."); - console.warn(" Recovered plaintext assignment data is retained only in a bounded, process-local in-memory cache; exact fidelity is not guaranteed and the path depends on undocumented backend behavior."); -} - -export function warnPlaintextV2AgentMessagesStartup(config: { plaintextV2AgentMessages?: boolean }): void { - if (config.plaintextV2AgentMessages !== true) return; - console.warn("⚠️ Experimental plaintext V2 agent messages are enabled."); - console.warn(" Eligible ChatGPT collaboration calls may carry plaintext message arguments. HTTPS remains encrypted, but task text may be retained in Codex history, selected providers, and local response/debug state."); - console.warn(" This depends on undocumented ChatGPT and Codex behavior; it does not decrypt existing tasks."); -} +import { createServeOptions, type ServerIngress } from "./index/serve-options"; +import { + inspectStartupOwnership, + warnAgentTaskRecoveryStartup, + warnPlaintextV2AgentMessagesStartup, + type StartServerDeps, +} from "./index/startup-warnings"; export function startServer(port?: number, deps: StartServerDeps = {}): Server { const localAttestationSecret = deps.localAttestationSecret ?? createLocalAttestationSecret(); @@ -1443,7 +651,6 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server): ServerIngress { if (requestServer === loopbackServer) return "unauthenticated-loopback"; if (requestServer === managementIngressServer) return "hub-management"; @@ -1478,1746 +685,32 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server): Promise { - const ingress = ingressForServer(requestServer); - // The unauthenticated loopback listener (#1102) serves a fixed allowlist and nothing - // else. Rejecting here, before any handler runs, is what keeps the surface from growing - // silently when a route is added below. - if (ingress === "unauthenticated-loopback" && !loopbackRouteAllowed(codexCompatibleUrl(req.url), req)) { - return withCors( - formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${new URL(req.url).pathname}`), - req, - loopbackPolicy(), - ); - } - // Tailscale Serve terminates only on this separately bound loopback socket. Reject before - // dispatch so no data, readiness, health, WebSocket, or unknown-static handler can run. - if (ingress === "hub-management" && !managementIngressRouteAllowed(codexCompatibleUrl(req.url), req)) { - return withCors( - formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${new URL(req.url).pathname}`), - req, - config, - ); - } - // Auth and CORS decisions below read `policy`, not `config`. For the public listener the - // two are the same object, so its behaviour is unchanged; for the loopback listener the - // view substitutes 127.0.0.1 as the bind address, which is what routes it through the - // same code path a plain loopback bind has always taken — Host-header check included. - // Routing, provider selection and response bodies keep using `config`. - const policy: RequestPolicyView = ingress === "unauthenticated-loopback" ? loopbackPolicy() : config; - const url = codexCompatibleUrl(req.url); - markActivity(`${req.method} ${url.pathname}`); - - // Readiness is exact-GET on the literal /readyz path. Compare the DECODED - // pathname so an encoded variant like /readyz%2F (which decodes to - // /readyz/) cannot bypass the exact-path rejection and reach the GUI - // fallback (serveGuiFile decodes the pathname and would serve index.html - // with 200). Malformed percent-sequences fall back to the raw pathname, - // which still cannot match the exact literal below. - let readyzPath: string | undefined; - try { - const decoded = decodeURIComponent(url.pathname); - if (decoded === "/readyz" || decoded === "/readyz/") readyzPath = decoded; - } catch { /* malformed encoding — not a readiness path */ } - - const packageTreeStatus = packageTreeIntegrity.status(); - if (!packageTreeStatus.ok && ( - url.pathname === "/healthz" - || readyzPath !== undefined - || url.pathname.startsWith("/v1/") - )) { - const message = "OpenCodex package files changed while this proxy was running; restart OpenCodex before retrying."; - const response = url.pathname === "/healthz" || readyzPath !== undefined - ? jsonResponse({ - status: "restart_required", - service: "opencodex", - version: VERSION, - uptime: process.uptime(), - pid: process.pid, - port: boundPort ?? requestServer.port ?? listenPort, - error: { code: "package_tree_changed", message }, - }, 503, req, policy) - : packageTreeChangedResponse(req, policy, message); - const headers = new Headers(response.headers); - headers.set("Retry-After", "5"); - return new Response(response.body, { status: 503, headers }); - } - - if (req.method === "OPTIONS") { - // /readyz is exact-GET only; OPTIONS (like POST and the trailing-slash - // path) must answer the deterministic JSON 404, never the generic 204 - // preflight response that the SPA fallback would otherwise allow. - if (readyzPath !== undefined) { - return withCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, policy); - } - const managementPreflight = url.pathname.startsWith("/api/"); - const allowed = managementPreflight - ? isAllowedManagementOrigin(req, config) - : isAllowedRequestOrigin(req, policy); - if (!allowed) { - return new Response(null, { status: 403, headers: corsHeaders() }); - } - return new Response(null, { - status: 204, - headers: managementPreflight ? managementCorsHeaders(req, config) : corsHeaders(req, policy), - }); - } - - // An OCX-only executor exchanges one short-lived pairing code for a device-scoped - // token. This is intentionally outside /api: management auth belongs to the browser - // that created the grant, while the new device owns only that one-time code. - if (url.pathname === "/remote-workspace/pair" && req.method === "POST") { - if (!remoteWorkspaceEnabled(config)) { - return Response.json({ error: "Remote Workspace is not enabled on this OpenCodex instance." }, { status: 404 }); - } - // Browser JavaScript must use the authenticated dashboard route. Refusing Origin-bearing - // requests leaves this exchange to an explicit OCX device process and avoids turning a - // copied pairing code into a cross-site enrollment action. - if (req.headers.get("origin") !== null) { - return Response.json({ error: "Remote Workspace device pairing does not accept browser-origin requests." }, { - status: 403, - headers: { "cache-control": "no-store" }, - }); - } - const [{ remoteWorkspaceHubForConfig }, { RemoteWorkspacePairingRateLimitError }] = await Promise.all([ - loadRemoteWorkspaceRuntime(), - import("../remote-control/workspace-hub"), - ]); - if (remoteWorkspaceStopping) return Response.json({ error: "Remote Workspace is stopping." }, { status: 503 }); - const hub = deps.managementApi?.remoteWorkspaceHub ?? remoteWorkspaceHubForConfig(config); - // A loopback socket alone cannot prove that Tailscale Serve supplied its identity header: - // another local process can connect directly and forge it. Pairing therefore uses only the - // kernel-observed peer on every listener; proxied management users intentionally share the - // loopback bucket rather than gaining a header-rotation bypass. - const peer = requestServer.requestIP(req)?.address ?? "unknown"; - const pairingSource = `${ingress}:${peer}`; - const rateLimitResponse = (error: unknown): Response | null => { - if (!(error instanceof RemoteWorkspacePairingRateLimitError)) return null; - return Response.json({ error: "Remote Workspace pairing is temporarily rate limited." }, { - status: 429, - headers: { - "cache-control": "no-store", - "retry-after": String(error.retryAfterSeconds), - }, - }); - }; - try { - // Check the existing source block before reading or parsing an attacker-controlled body. - // pairDevice checks again after the await and records only code-shaped authentication - // failures, so malformed JSON cannot allocate one limiter entry per request. - hub.assertPairingSourceAllowed(pairingSource); - } catch (error) { - const limited = rateLimitResponse(error); - if (limited) return limited; - throw error; - } - const declaredLength = Number(req.headers.get("content-length") ?? "0"); - if (!Number.isFinite(declaredLength) || declaredLength > REMOTE_WORKSPACE_PAIRING_BODY_LIMIT) { - return Response.json({ error: "Remote Workspace pairing body is too large." }, { status: 413 }); - } - const text = await readBoundedRequestText(req, REMOTE_WORKSPACE_PAIRING_BODY_LIMIT); - if (text === null) return Response.json({ error: "Remote Workspace pairing body is too large." }, { status: 413 }); - if (remoteWorkspaceStopping) return Response.json({ error: "Remote Workspace is stopping." }, { status: 503 }); - let body: unknown; - try { body = JSON.parse(text); } - catch { return Response.json({ error: "Invalid Remote Workspace pairing request." }, { status: 400 }); } - if (!body || typeof body !== "object" || Array.isArray(body)) { - return Response.json({ error: "Invalid Remote Workspace pairing request." }, { status: 400 }); - } - const record = body as Record; - const required = ["code", "name", "platform", "publicKey", "roots"]; - const allowed = new Set([...required, "capabilities"]); - if (required.some(key => !Object.hasOwn(record, key)) - || Object.keys(record).some(key => !allowed.has(key))) { - return Response.json({ error: "Invalid Remote Workspace pairing request." }, { status: 400 }); - } - try { - const paired = hub.pairDevice(record, pairingSource); - return Response.json(paired, { status: 201, headers: { "cache-control": "no-store" } }); - } catch (error) { - const limited = rateLimitResponse(error); - if (limited) return limited; - const message = error instanceof Error ? error.message : "Remote Workspace pairing failed."; - const conflict = /already in use|limit reached/i.test(message); - return Response.json({ error: message }, { - status: conflict ? 409 : 401, - headers: { "cache-control": "no-store" }, - }); - } - } - - // Each executor holds one device-scoped bearer and opens one outbound WSS. The token is - // authenticated only at upgrade and never enters ws.data; subsequent frames are bound to - // the device identity and per-session signed E2EE handshake. - if (url.pathname === "/remote-workspace/agent" && req.headers.get("upgrade")?.toLowerCase() === "websocket") { - if (!remoteWorkspaceEnabled(config) || req.headers.get("origin") !== null) { - return Response.json({ error: "Remote Workspace agent upgrade refused." }, { status: 403 }); - } - const authorization = req.headers.get("authorization") ?? ""; - const match = /^Bearer (ocxrw_[A-Za-z0-9_-]{43})$/.exec(authorization); - if (!match) return Response.json({ error: "Remote Workspace device authentication required." }, { status: 401 }); - const { remoteWorkspaceHubForConfig } = await loadRemoteWorkspaceRuntime(); - const { RemoteWorkspaceHubAgentConnection } = await import("../remote-control/workspace-agent-connection"); - if (remoteWorkspaceStopping) return Response.json({ error: "Remote Workspace is stopping." }, { status: 503 }); - const hub = deps.managementApi?.remoteWorkspaceHub ?? remoteWorkspaceHubForConfig(config); - const device = hub.authenticateDeviceToken(match[1]!); - if (!device) return Response.json({ error: "Remote Workspace device authentication failed." }, { status: 401 }); - const upgraded = requestServer.upgrade(req, { - data: { - kind: "remote-workspace-agent", - remoteWorkspaceOpen: socket => { - const connection = new RemoteWorkspaceHubAgentConnection({ - deviceId: device.id, - devicePublicKey: device.publicKey, - hubIdentity: hub.identity(), - capabilities: device.capabilities, - onCapabilities: capabilities => hub.updateDeviceCapabilities(device.id, capabilities), - socket: { - send: value => { - if (socket.send(value) === 0) throw new Error("remote workspace socket send dropped"); - }, - close: (code, reason) => socket.close(code, reason), - }, - }); - hub.attachConnection(device.id, connection); - socket.data.remoteWorkspaceClose = () => hub.detachConnection(device.id, connection); - return connection; - }, - } satisfies WsData, - }); - return upgraded - ? undefined as unknown as Response - : Response.json({ error: "Remote Workspace WebSocket upgrade failed." }, { status: 426 }); - } - - // Responses WebSocket (phase 120.2). Codex upgrades the same /v1/responses path; auth is - // handshake-time only, so capture inbound headers and thread them into the pipeline. - if (url.pathname === "/v1/responses" && req.headers.get("upgrade")?.toLowerCase() === "websocket") { - if (isDraining()) { - return drainingResponse(req, policy); - } - const admission = resolveResponsesApiAuth(req, policy); - if (!admission) { - return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy); - } - if (!isAllowedRequestOrigin(req, policy)) { - return withCors(formatErrorResponse(403, "origin_rejected", "WebSocket upgrade blocked: non-local Origin"), req, policy); - } - // WS transport gate: Codex's built-in `openai` provider hardcodes supports_websockets=true, - // so under Design B it always tries the WS transport first. When the feature is off, reject - // the upgrade with 426 — codex-rs maps a connect-time UPGRADE_REQUIRED to a clean - // session-scoped HTTP fallback (client.rs WebsocketStreamOutcome::FallbackToHttp) instead of - // surfacing broken-pipe errors from sockets a "disabled" feature would otherwise accept. - if (!websocketsEnabled(config)) { - return withCors(formatErrorResponse(426, "upgrade_required", "Responses WebSocket transport is disabled; use HTTP"), req, policy); - } - const websocketLease = tryReserveCodexWebSocket(); - if (!websocketLease) return serverBusyResponse(req, "Codex WebSockets", policy); - // Upgrade on the server that RECEIVED this request, not the captured `server` - // binding. They are the same object for the public listener, but the - // unauthenticated loopback listener (#1102) is a second Bun.serve, and handing its - // request to the public server's upgrade would fail or cross sockets. - if (requestServer.upgrade(req, { - data: buildResponsesWsData( - selectForwardHeaders(req.headers), - admission, - websocketLease, - sessionLaneIdFromRequest(req.headers), - ), - })) return undefined as unknown as Response; - websocketLease.release(); - return withCors(formatErrorResponse(426, "upgrade_required", "WebSocket upgrade failed"), req, policy); - } - - if (url.pathname === "/healthz" && req.method === "GET") { - // service/pid/port let CLI liveness reject foreign 200s and verify pid identity. - const healthPort = server.port ?? listenPort; - const response = jsonResponse({ - status: "ok", - service: "opencodex", - version: VERSION, - uptime: process.uptime(), - pid: process.pid, - port: healthPort, - restartCapability: SYSTEM_RESTART_CAPABILITY_VERSION, - providerReloadCapability: LOCAL_PROVIDER_RELOAD_CAPABILITY_VERSION, - guiPairCapability: GUI_PAIR_CAPABILITY_VERSION, - }, 200, req, policy); - const challenge = req.headers.get(LOCAL_ATTESTATION_CHALLENGE_HEADER); - if (challenge) { - const proof = createLocalAttestationProof(localAttestationSecret, challenge, process.pid, healthPort); - if (proof) response.headers.set(LOCAL_ATTESTATION_PROOF_HEADER, proof); - } - return response; - } - - // Readiness: like /healthz this is exact GET and unauthenticated (so a client can - // back off BEFORE knowing the admission token), but stricter than liveness. The - // body carries only sanitized identity + the fixed status enum; the sync message, - // warning text, catalog path, provider output, and account data are never exposed. - // POST or "/readyz/" must NOT match (exact pathname + GET method): answer them - // with a JSON 404 here so they can never be silently accepted by the GUI SPA - // fallback (which would serve index.html with HTTP 200 once gui/dist exists). - if (readyzPath !== undefined) { - if (readyzPath !== "/readyz" || req.method !== "GET") { - return withCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, policy); - } - // A draining proxy must never advertise ready: every data-plane branch - // answers drainingResponse while isDraining() is set, but the one-shot - // readiness gate is not mutated on shutdown (it is owned by the startup - // sync). Report pending so `ocx ready --wait` and external supervisors - // keep polling instead of promoting a proxy that is draining. - const status = isDraining() ? "pending" : readinessGate.getStatus(); - const body = { - service: "opencodex", - version: VERSION, - uptime: process.uptime(), - pid: process.pid, - port: boundPort ?? listenPort, - status, - ...readyProtocolMetadata(config, req), - }; - if (status === "ready") { - return jsonResponse(body, 200, req, policy); - } - // Pending/failed: 503 with a conservative Retry-After so well-behaved clients - // (and `ocx ready --wait`) back off instead of hot-looping. - const resp = jsonResponse(body, 503, req, policy); - const headers = new Headers(resp.headers); - headers.set("Retry-After", "1"); - return new Response(resp.body, { status: 503, headers }); - } - - if (url.pathname.startsWith("/api/")) { - const localManagementAuth = { - attestationSecret: localAttestationSecret, - pid: process.pid, - port: boundPort ?? requestServer.port ?? listenPort, - }; - const apiAuthError = requireManagementAuth(req, managementAuth, config, localManagementAuth); - if (apiAuthError) return withManagementCors(apiAuthError, req, config); - // Which credential passed the gate, resolved from the same session table the - // gate used. Consent-bearing routes need this: request headers are forgeable - // by anything holding the admin token, the credential is not. - const principal = managementPrincipal(req, managementAuth, config, localManagementAuth) ?? undefined; - if (url.pathname === GUI_PAIR_PATH) { - if (req.method !== "POST" || principal !== "gui-pair-capability" || !managementAuth.available) { - return withManagementCors(Response.json({ error: "GUI pairing capability required" }, { status: 403 }), req, config); - } - try { - const grant = createGuiPairingGrant( - req.headers.get(GUI_PAIR_BROWSER_ORIGIN_HEADER) ?? "", - config, - managementAuth, - ); - return withManagementCors(Response.json(grant, { - status: 201, - headers: { "Cache-Control": "no-store" }, - }), req, config); - } catch (error) { - const status = error instanceof GuiPairingGrantRateLimitError ? 429 : 403; - return withManagementCors(Response.json({ error: "GUI pairing grant refused" }, { - status, - ...(status === 429 ? { headers: { "Retry-After": "60" } } : {}), - }), req, config); - } - } - const mgmtResponse = await handleManagementAPI(req, url, config, managementApiDeps, principal, managementSessionControl); - if (mgmtResponse) return withManagementCors(mgmtResponse, req, config); - return withManagementCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, config); - } - - if (url.pathname === "/v1/catalog" && (req.method === "GET" || req.method === "HEAD")) { - // #809: remote Codex clients need the model catalog, and the only prior source was - // GET /api/catalog behind management auth — so operators had to hand out an admin - // token to read a list of models. This route fixes that on the data plane instead of - // widening /api/*, which stays exactly as restricted as before. - // - // resolveApiAuth (not resolveResponsesApiAuth) for the same reason /v1/models uses - // it: nothing here forwards a caller credential upstream, so accepting the dedicated - // header, a recognized bearer, or x-api-key is safe — and rejecting x-api-key would - // 401 Anthropic-SDK clients holding a perfectly valid data credential. - const admission = resolveApiAuth(req, policy); - if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy); - if (!isAllowedRequestOrigin(req, policy)) { - return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy); - } - const { serializePersistedCatalog, persistedCodexVersion, MAX_REMOTE_CATALOG_BYTES } = await import("./catalog-download"); - const serialized = await serializePersistedCatalog(); - if (serialized.body === null) { - // Built directly rather than through formatErrorResponse: that helper derives - // `code` from the status and message via classifyError, and these two need stable, - // specific codes. `catalog_not_found` in particular is what lets a caller — and - // tests/server/api-key-attribution.test.ts — tell "this route exists and has no catalog" - // apart from "this route is gone", which is the difference between admission proof - // and a vacuous pass. - return withCors( - new Response(JSON.stringify({ - error: { type: "invalid_request_error", code: "catalog_not_found", message: "no materialized catalog is available" }, - }), { - status: 404, - headers: { "content-type": "application/json" }, - }), - req, - policy, - ); - } - // Size policy belongs to this route, not the shared serializer: the management route - // must keep its existing behavior for a catalog of any supported size. - if (serialized.bytes !== undefined && serialized.bytes > MAX_REMOTE_CATALOG_BYTES) { - return withCors( - new Response(JSON.stringify({ - error: { type: "server_error", code: "catalog_too_large", message: "catalog exceeds the maximum served size" }, - }), { - status: 507, - headers: { "content-type": "application/json" }, - }), - req, - policy, - ); - } - const headers: Record = { - "content-type": "application/json", - // Identity-varying content behind a credential: never let a shared cache keep it, - // and never hand out a validator it could revalidate with. `no-cache` alone does - // not prevent storage — it forces revalidation, and the revalidation is exactly - // what would cross identities here, because this body varies by key type and key - // id while the ETag would be derived from bytes alone. A store keyed on URL plus - // validator could then serve one credential's representation to another. Proving - // an identity-partitioned cache key across every intermediary in the path is a - // much larger commitment than the bandwidth a 304 saves on this payload, so this - // route declines the trade: no-store, no ETag, no 304. - // - // GET /api/catalog keeps its validator. That route is management-authenticated - // and loopback-scoped, and its representation does not vary by data-key identity. - "cache-control": "no-store", - }; - const version = await persistedCodexVersion(); - if (version) headers["x-opencodex-codex-version"] = version; - // No conditional handling: with no validator emitted, an If-None-Match on this route - // can only have been guessed or copied from elsewhere, and honoring it would - // reintroduce the cross-identity path above. Every request gets the full body. - if (serialized.bytes !== undefined) headers["content-length"] = String(serialized.bytes); - // HEAD returns identical status and headers with no body. - return withRemoteCatalogKeyId( - withCors( - new Response(req.method === "HEAD" ? null : serialized.body, { status: 200, headers }), - req, - policy, - ), - admission, - ); - } - - if (url.pathname === "/v1/usage" && req.method === "GET") { - const { handleHubUsage } = await import("./hub-usage"); - return handleHubUsage(req, config, policy); - } - - if (url.pathname === "/v1/hub-state" && (req.method === "GET" || req.method === "HEAD")) { - // #4236: a connected client had no way to learn which providers this hub can actually - // serve, so `ocx status` on the client reported the CLIENT's empty credential store as - // if it were the truth — "xai ✗ not logged in" on a machine whose hub has xAI logged - // in. The fix is one least-privilege data-plane read, in the /v1/catalog (#809) - // tradition: same admission resolver, same origin check, no parameters, no caller - // credential forwarded upstream, and a body of booleans plus model ids. Widening - // `/api/*` or handing the client an admin token to read `GET /api/providers` would - // have traded a reporting defect for a credential one. - // - // What it discloses beyond /v1/catalog and /v1/models, exactly: `hasCredential`, - // `loggedIn`, `authMode`, the featured roster, and the NAME and adapter of an ENABLED - // provider those routes omit for want of a usable credential — which is the point of - // the route. A `disabled` provider is NOT exported (`buildHubState` drops it), because - // the catalog filters it out too and naming it here would be the only place a data key - // learns of it. - // - // Placed between /v1/catalog and /v1/models so all three least-privilege client reads - // stay in sight of each other. - const admission = resolveApiAuth(req, policy); - if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy); - if (!isAllowedRequestOrigin(req, policy)) { - return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy); - } - // Role gate AFTER admission, deliberately: answering an unauthenticated caller would - // turn this into a free "is that machine a hub?" probe. A standalone or client install - // gains no surface at all — the route simply does not exist there. - // - // Built, not formatErrorResponse'd, for the same reason /v1/catalog builds its 404: the - // code has to distinguish "this route exists and this host is not a hub" from "this - // build has no such route", which is the difference between admission proof and a - // vacuous pass in tests/server/api-key-attribution.test.ts. - if (config.runtimeRole !== "hub") { - return withCors( - new Response(JSON.stringify({ - error: { - type: "invalid_request_error", - code: "hub_state_not_a_hub", - message: "hub state is served only by a host whose runtimeRole is hub", - }, - }), { status: 404, headers: { "content-type": "application/json" } }), - req, - policy, - ); - } - const { buildHubState } = await import("./hub-state"); - const { MAX_HUB_STATE_BYTES } = await import("../remote/hub-state"); - const { oauthLoginSummary } = await import("../oauth"); - // `true` masks emails, but the projection drops the field entirely; passing the mask - // anyway means a future refactor that starts copying fields cannot leak a raw address. - const body = JSON.stringify(buildHubState(config, oauthLoginSummary(true), VERSION)); - const bytes = Buffer.byteLength(body); - if (bytes > MAX_HUB_STATE_BYTES) { - return withCors( - new Response(JSON.stringify({ - error: { type: "server_error", code: "hub_state_too_large", message: "hub state exceeds the maximum served size" }, - }), { status: 507, headers: { "content-type": "application/json" } }), - req, - policy, - ); - } - return withCors( - new Response(req.method === "HEAD" ? null : body, { - status: 200, - headers: { - "content-type": "application/json", - // Varies by credential-bearing identity and by live login state: never cached, - // and no validator to revalidate with (same rule as /v1/catalog). - "cache-control": "no-store", - "content-length": String(bytes), - }, - }), - req, - policy, - ); - } - - if (url.pathname === "/v1/models" && req.method === "GET") { - // #809: the catalog read sits immediately before model discovery because it shares - // that route's admission rationale exactly. Keep them adjacent so a future change to - // one is made in sight of the other. - // Model discovery never forwards Authorization upstream, so the broader admission - // set (Authorization / x-api-key / x-opencodex-api-key) is safe here and required by - // remote OpenAI-style bearer clients and Claude gateway discovery (anthropic-version). - const admission = resolveApiAuth(req, policy); - if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy); - if (!isAllowedRequestOrigin(req, policy)) { - return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy); - } - const wantsDesktopConfig = url.searchParams.get("format") === "desktop-config"; - if (wantsDesktopConfig && (url.searchParams.get("ids") === "cli" || url.searchParams.has("client_version"))) { - return jsonResponse({ error: "Desktop config format cannot use CLI or client-version selectors" }, 400, req, policy); - } - // The Integrations page reports whether a Cursor client has reached this proxy; the - // recorder keeps only a bounded User-Agent value and a timestamp, in memory. - recordCursorSeen(req.headers); - let goModels; - let modelEntitlements; - try { - [goModels, modelEntitlements] = await Promise.all([ - fetchAllModels(config), - // Codex sends its own client_version on this request, and upstream filters the - // entitlement roster by it. Passing it through is what stops an entitled account - // being told it cannot use models a newer client can (#2886). - resolveCodexModelEntitlements(config, { clientVersion: url.searchParams.get("client_version") }), - ]); - } catch (error) { - if (error instanceof CatalogGatherBusyError) { - return withCors(new Response(JSON.stringify({ error: { type: "server_error", code: "catalog_busy", message: error.message } }), { - status: 503, - headers: { "content-type": "application/json", "Retry-After": "1" }, - }), req, policy); - } - throw error; - } - const { accountBoundNativeOpenAiSlugsBySelector, applyNativeVisibility, buildCatalogEntries, configuredNativeAliasSlugs, desktopAllowlistSuppressedNativeSlugs, disabledNativeSlugs, exactComboCatalogSlugs, loadCatalogTemplate, NATIVE_OPENAI_MODELS, nativeContextLimits, nativeInputModalities, nativeOpenAiContextWindow, nativeOpenAiMaxOutputTokens, nativeOpenAiContextTier, nativeOpenAiSlugs, nativeReasoningEfforts, nativeDefaultReasoningEffort, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, uniqueCatalogModelsForRawPublicList, visibleCodexAccountSelectors, visibleNativeSlugs, desktopVisibleNativeSlugs } = await import("../codex/catalog"); - const { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } = await import("../codex/catalog/native-models"); - const includeNativeOpenAi = shouldIncludeNativeOpenAi(config); - const includeAccountBoundNativeOpenAi = shouldIncludeAccountBoundNativeOpenAi(config); - const bareEligibleAccountIds = providerCodexAccountMode( - OPENAI_CODEX_PROVIDER_ID, - config.providers[OPENAI_CODEX_PROVIDER_ID], - ) === "direct" ? new Set([MAIN_CODEX_ACCOUNT_ID]) : undefined; - const availableBareGatedNativeSlugs = availableAccountGatedNativeModels( - modelEntitlements, - bareEligibleAccountIds, - ); - const availableAccountGatedNativeSlugs = availableAccountGatedNativeModels(modelEntitlements); - const availableBareNativeSlugs = NATIVE_OPENAI_MODELS.filter(slug => ( - !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || availableBareGatedNativeSlugs.has(slug) - )); - const availableAccountNativeSlugs = NATIVE_OPENAI_MODELS.filter(slug => ( - !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || availableAccountGatedNativeSlugs.has(slug) - )); - const nativeSlugs = includeNativeOpenAi - ? nativeOpenAiSlugs().filter(slug => ( - !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || availableBareGatedNativeSlugs.has(slug) - )) - : []; - const disabledNatives = disabledNativeSlugs(config); - const disabledModels = new Set(config.disabledModels ?? []); - const exactComboSlugs = exactComboCatalogSlugs(config); - const shadowedNativeSlugs = configuredNativeAliasSlugs(config); - const suppressedBareNativeSlugs = new Set([ - ...desktopAllowlistSuppressedNativeSlugs(config), - ...[...ACCOUNT_GATED_NATIVE_OPENAI_MODELS].filter(slug => !availableBareGatedNativeSlugs.has(slug)), - ]); - const accountSelectors = includeAccountBoundNativeOpenAi - ? visibleCodexAccountSelectors(config) - : []; - const accountTargets = new Map(codexAccountNamespaceEntries(config)); - const accountNativeSlugsBySelector = includeAccountBoundNativeOpenAi - ? new Map([...accountBoundNativeOpenAiSlugsBySelector(config)].map(([selector, slugs]) => { - const target = accountTargets.get(selector); - const accountId = target && isMainCodexAccountTarget(target) ? MAIN_CODEX_ACCOUNT_ID : target; - return [selector, slugs.filter(slug => ( - !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) - || (accountId !== undefined - && codexModelEntitlementStateForAccount(modelEntitlements, accountId, slug) === "granted") - ))] as const; - })) - : new Map(); - const accountNativeSlugs = [...new Set( - [...accountNativeSlugsBySelector.values()].flatMap(slugs => [...slugs]), - )]; - const desktopInputs = buildDesktopDiscoveryInputs({ - config, models: goModels, modelEntitlements, - desktopNativeCandidates: desktopVisibleNativeSlugs(config), - }); - const desktopNativeSlugs = desktopInputs.nativeSlugs; - const goOrdered = desktopInputs.routedModels; - // Claude Code / Claude Desktop gateway model discovery (GET /v1/models with - // Anthropic-style headers; 003 G1-G8 + devlog 131). Entries use the official - // ModelInfo shape incl. capabilities (effort ladder / thinking) — Desktop 3P can - // only learn capabilities through discovery, and Claude Code 2.1.207 strips the - // extra fields (backward-safe). Ids are the claude-opus-4-8-{code} Desktop - // aliases; legacy claude-ocx-* ids keep decoding via resolveAlias. Detection: - // anthropic-version header (Claude Code sends it) or explicit ?flavor=anthropic. - // Codex catalog (client_version) and the OpenAI list shape below stay byte-identical. - const wantsAnthropicList = wantsDesktopConfig || req.headers.get("anthropic-version") !== null - || url.searchParams.get("flavor") === "anthropic"; - /** - * Whether a NATIVE slug may carry a Fast sibling. - * - * Both halves are required. Upstream asserts the tier per model — the same - * `additional_speed_tiers` the Codex picker's own toggle is built from — but an - * operator capability override or the final wire resolution can still make the - * route ineligible, and `decideTier` would then drop the tier the row advertised. - * - * Declared here, above the Claude discovery call, because that call reads it while - * the raw OpenAI mapper further down does too; defining it there would leave this - * use in its temporal dead zone. - */ - const nativeFastEligible = (metadataId: string): boolean => - catalogFastRowEligible(config, { provider: OPENAI_CODEX_PROVIDER_ID, id: metadataId, native: true }); - - /** - * Whether a routed catalog row may carry a Fast sibling. - * - * A combo is its own namespace with no `config.providers` entry — declaring a - * provider named `combo` is rejected (combos/types.ts:191) — so provider lookup - * cannot classify it. Its aggregated `supportsServiceTier` is already true only - * when EVERY member supports the tier (aggregation.ts:201), which is the right - * rule for a row that fans out to all of them. - * - * Declared beside nativeFastEligible, above the Claude discovery call that reads - * both; defining it near the raw OpenAI mapper below would leave that use in its - * temporal dead zone. - */ - const catalogRowFastEligible = (m: { provider: string; id: string; supportsServiceTier?: boolean }): boolean => - catalogFastRowEligible(config, m); - - if (wantsAnthropicList && !url.searchParams.has("client_version")) { - if (wantsDesktopConfig) { - const models = config.claudeCode?.enabled === false ? [] : generateDesktop3pModels( - desktopInputs.nativeSlugs, desktopInputs.routedModels, - config.claudeCode?.desktopProfile, desktopInputs.nativeContextCap, - ); - const response = jsonResponse({ version: 1, models }, 200, req, policy); - response.headers.set("Cache-Control", "no-store"); - return response; - } - if (config.claudeCode?.enabled === false) return jsonResponse({ data: [] }, 200, req, policy); - // Build Desktop 3P registry so inbound alias resolution works for subsequent requests. - buildDesktop3pRegistry( - desktopNativeSlugs, - desktopInputs.routedModels, - config.claudeCode?.desktopProfile, - desktopInputs.nativeContextCap, - ); - const { buildAnthropicModelInfos } = await import("../claude/model-info"); - const { resolveAutoContext } = await import("../claude/context-windows"); - const { activeDesktop3pAlias } = await import("../claude/desktop-3p"); - // Per-surface id family (devlog 050): explicit ?ids= wins; otherwise the - // Claude Code CLI discovery UA (`claude-code/`, binary n_()) gets - // readable claude-ocx ids and every other client (Desktop 3P) keeps the - // hashed family its config was written with. Unknown UA -> hashed (safe). - const idsParam = url.searchParams.get("ids"); - const idStyle = idsParam === "cli" - ? "readable" as const - : idsParam === "desktop" - ? "desktop3p" as const - : (/^claude-code\//i.test(req.headers.get("user-agent") ?? "") ? "readable" as const : "desktop3p" as const); - const data = buildAnthropicModelInfos( - desktopNativeSlugs, - goOrdered, - resolveAutoContext(config.claudeCode), - idStyle, - activeDesktop3pAlias, - desktopInputs.nativeContextCap, - config.fastMode, - // Explicit opt-out omits the Fast predicate. - config.fastRows !== false - ? (model: { provider: string; id: string; supportsServiceTier?: boolean }) => - model.provider === "native" - ? nativeFastEligible(model.id) - : catalogRowFastEligible(model) - : undefined, - { modelPickerOrder: config.modelPickerOrder, featured: config.subagentModels }, - ); - return jsonResponse({ data }, 200, req, policy); - } - if (url.searchParams.has("client_version")) { - // Codex client → Codex catalog shape: native gpt + namespaced routed models, - // cloned from a native template so required fields (base_instructions, etc.) are present. - // Pass the subagent picks so featured models lead by priority (matches the on-disk file). - // Disabled natives stay in the catalog shape with visibility "hide" (mirrors the - // on-disk sync; codex-rs keeps them out of the picker itself). - const maMode = config.multiAgentMode === "v1" || config.multiAgentMode === "v2" ? config.multiAgentMode : "default"; - // Account rows use the same hidden-inclusive supported set as on-disk sync. This lets a - // newly re-enabled native reappear under each selector before the next sync, while the - // no-selector path keeps nativeOpenAiSlugs()'s existing visibility-sensitive behavior. - const catalogNativeSlugs = accountSelectors.length > 0 - ? [...new Set([ - ...availableAccountNativeSlugs, - ...accountNativeSlugs, - ])] - : nativeSlugs; - const entries = buildCatalogEntries( - loadCatalogTemplate(), - catalogNativeSlugs, - goOrdered, - config.subagentModels, - websocketsEnabled(config), - maMode as "v1" | "default" | "v2", - exactComboSlugs, - accountSelectors, - suppressedBareNativeSlugs, - new Set(), - nativeContextLimits(config), - accountNativeSlugs, - accountNativeSlugsBySelector, - config.keepNativeChatGptOnV1 === true, - config.modelPickerOrder, - ); - return jsonResponse({ - models: applyNativeVisibility( - entries, - disabledModels, - accountSelectors.length > 0, - new Set(accountNativeSlugs), - ), - }, 200, req, policy); - } - // OpenAI list shape: native gpt bare + routed models namespaced "/" - // (pure availability list — disabled natives are omitted entirely). - // Grok Build discovers models through this endpoint too, and its model picker only - // enables /effort for entries that advertise the reasoning ladder in the Grok model - // catalog shape (supports_reasoning_effort + reasoning_efforts[]). The Codex catalog - // branch above already carries the same ladders, so mirror them here — native rows - // from the upstream snapshot, routed rows from the configured provider tiers. The - // default uses the same canonical fallback as the Codex catalog resolver - // (configured default, then medium, then high, then the first tier). Extra fields - // are ignored by plain OpenAI clients. - const grokEffortOption = (value: string, isDefault: boolean) => ({ - value, - label: `${value[0].toUpperCase()}${value.slice(1)} Effort`, - ...(isDefault ? { default: true } : {}), - }); - const grokEffortFields = (efforts: string[], configuredDefault?: string) => { - const defaultEffort = grokDefaultReasoningEffort(efforts, configuredDefault); - if (defaultEffort === undefined) return {}; - return { - supports_reasoning_effort: true, - reasoning_effort: defaultEffort, - reasoning_efforts: efforts.map(effort => grokEffortOption(effort, effort === defaultEffort)), - }; - }; - // Cursor's local-agent runtime (Private Inference build) reads api_types + capabilities - // to enable its effort control; every other consumer ignores them. See - // src/server/models-capabilities.ts. - const nativeLimits = nativeContextLimits(config); - const nativeContextInput = (metadataId: string) => { - const tier = nativeOpenAiContextTier(metadataId, nativeLimits); - return tier - ? { contextWindow: tier.defaultWindow, longContextWindow: tier.longWindow } - : { contextWindow: nativeOpenAiContextWindow(metadataId, nativeLimits) }; - }; - const nativeModelRow = (id: string, metadataId = id) => ({ - id, - object: "model", - created: 0, - owned_by: "openai", - ...grokEffortFields( - nativeReasoningEfforts(metadataId), - nativeDefaultReasoningEffort(metadataId), - ), - ...modelCapabilityFields({ - reasoningEfforts: nativeReasoningEfforts(metadataId), - // Cursor "Max Mode": advertise the family's default/long pair (272k/922k for - // GPT-5.6) so the client can pick per request; without a tier, the effective - // window is the only value. - ...nativeContextInput(metadataId), - maxOutputTokens: nativeOpenAiMaxOutputTokens(metadataId), - inputModalities: nativeInputModalities(metadataId), - }), - }); - // Resolved once per request, not per model: the global fast switch offers the fast - // identity to clients that have no Fast toggle of their own. Null when the switch is - // off, so the row mapper does no work and loads no adapter module. - const cursorFastIdForListing = config.fastMode === true - ? await (async () => { - const { cursorFastIdFor } = await import("../adapters/cursor/catalog"); - return (modelId: string, provider = "cursor") => provider === "cursor" ? cursorFastIdFor(modelId) : undefined; - })() - : null; - // Selector-active discovery follows the same complete supported set as the Codex catalog - // for both bare and qualified rows. Without selectors, the live catalog continues to own - // bare availability. - const selectorNativeSlugs = accountSelectors.length > 0 - ? availableBareNativeSlugs.filter(slug => !disabledNatives.has(slug)) - : []; - const bareSelectorNativeSlugs = accountSelectors.length > 0 - ? selectorNativeSlugs - : []; - const visibleNatives = includeNativeOpenAi - ? accountSelectors.length > 0 - ? bareSelectorNativeSlugs.filter(slug => !shadowedNativeSlugs.has(slug)) - : visibleNativeSlugs(config) - : []; - const visibleAccountNatives = accountSelectors.flatMap(selector => - (accountNativeSlugsBySelector.get(selector) ?? []).filter(metadataId => !disabledNatives.has(metadataId)).flatMap(metadataId => { - const id = `${selector}/${metadataId}`; - return disabledModels.has(id) ? [] : [{ id, metadataId }]; - }) - ); - // The projection is opt-in. Keep the default path free of Cursor install detection, - // and resolve the bundle table once for the whole list rather than once per row. - const effortRowsEnabled = config.cursorEffortRows === true; - // Explicit opt-out skips policy resolution and additional rows. - const fastRowsEnabled = config.fastRows !== false; - // One inventory serves both grammars; building it twice would double the work on a - // hot path for no benefit. - const effortRowKnownIds = effortRowsEnabled || fastRowsEnabled - ? knownEffortRowIds(config) - : undefined; - const privateInference = effortRowsEnabled - ? detectCursorInstalls().find(install => install.build === "private-inference") - : undefined; - const cursorEffortTable = effortRowsEnabled - ? (deps.managementApi?.loadCursorEffortTable ?? loadCursorEffortTable)(privateInference) - : null; - const expandedNativeModelRow = (id: string, metadataId = id) => { - const reasoningEfforts = nativeReasoningEfforts(metadataId); - return expandCursorEffortRow(nativeModelRow(id, metadataId), reasoningEfforts, config, { - knownIds: effortRowKnownIds, - table: cursorEffortTable, - supportsReasoning: reasoningEfforts.length > 0, - }).flatMap(row => expandFastRow( - row, - // Only the BASE row earns a fast sibling. An effort row already spent the - // grammar, and the parser requires the stripped base to be routable, so - // `----fast` would publish a row no ingress can resolve. - row.id === id && nativeFastEligible(metadataId), - config, - effortRowKnownIds, - )); - }; - const routedRows = await Promise.all(uniqueCatalogModelsForRawPublicList(goOrdered).map(async m => { - // Same rule as the anthropic branch: with the global fast switch on, a client - // that has no Fast toggle is offered the fast identity directly. An operator - // alias is an explicit decision and still wins. - const fastModelId = cursorFastIdForListing?.(m.id, m.provider); - const publicId = m.alias ?? `${m.provider}/${fastModelId ?? m.id}`; - const isCombo = m.provider === "combo" && exactComboSlugs.has(publicId); - const provider = config.providers[m.provider]; - const effective = provider - ? (await import("../providers/default-aliases")).effectiveModelAliases( - config, - provider, - knownModelIdsForProvider(m.provider, provider, config), - ).get(m.id) - : undefined; - const row = { - id: publicId, - object: "model", - created: 0, - // This endpoint is an OpenAI-compatible inbound contract. Some clients use - // owned_by as an adapter selector, so a virtual combo must name that wire - // adapter rather than the internal catalog authority marker. - owned_by: isCombo ? "openai" : (m.owned_by ?? m.provider), - ...(isCombo ? { is_combo: true } : {}), - ...(effective ? { alias_of: `${provider?.alias || m.provider}/${effective.alias}` } : {}), - ...grokEffortFields(m.reasoningEfforts ?? [], m.defaultReasoningEffort), - ...modelCapabilityFields({ - reasoningEfforts: m.reasoningEfforts, - // contextWindow is already the post-cap effective value; contextCap is the raw - // operator knob and over-reports models whose real window sits below it. - contextWindow: m.contextWindow, - maxOutputTokens: m.maxOutputTokens, - inputModalities: m.inputModalities, - }), - }; - return expandCursorEffortRow(row, m.reasoningEfforts, config, { - knownIds: effortRowKnownIds, - table: cursorEffortTable, - supportsReasoning: (m.reasoningEfforts ?? []).length > 0, - }).flatMap(expanded => expandFastRow( - expanded, - expanded.id === row.id && catalogRowFastEligible(m), - config, - effortRowKnownIds, - )); - })); - const data = [ - ...visibleNatives.flatMap(id => expandedNativeModelRow(id)), - ...visibleAccountNatives.flatMap(({ id, metadataId }) => expandedNativeModelRow(id, metadataId)), - ...routedRows.flat(), - ]; - return jsonResponse({ object: "list", data }, 200, req, policy); - } - - // Remote compaction v1 (codex-rs with Feature::RemoteCompactionV2 off — the default). - // Must be matched BEFORE the /v1/responses POST branch never sees it (distinct path) and - // before the /v1/* 404 guard below. - if (url.pathname === "/v1/responses/compact" && req.method === "POST") { - if (isDraining()) { - return drainingResponse(req, policy); - } - const admission = resolveResponsesApiAuth(req, policy); - if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy); - if (!isAllowedRequestOrigin(req, policy)) { - return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy); - } - const start = Date.now(); - const requestId = nextRequestLogId(start); - const logCtx: RequestLogContext = { - model: "unknown", - provider: "unknown", - ...admissionFields(admission), - inboundProtocol: "responses", - }; - return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => { - let response: Response; - try { - response = await handleResponsesCompact(req, config, logCtx, turnAdmissionLease, admission, { - onRequestBodyRead: () => disableResponsesRequestTimeout(req, requestServer), - }); - } catch { - response = formatErrorResponse(500, "server_error", "Unexpected compact request failure"); - } - addFinalRequestLog(requestId, start, logCtx, response.status, - response.status === 499 ? { closeReason: "client_cancel" } : undefined); - return withCors(response, req, policy); - }, { requestId, start, logCtx }); - } - - if ( - req.method === "POST" - && (url.pathname === "/v1/images/generations" || url.pathname === "/v1/images/edits") - ) { - disableResponsesRequestTimeout(req, requestServer); - if (isDraining()) { - return drainingResponse(req, policy); - } - const admission = resolveApiAuth(req, policy); - if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy); - if (!isAllowedRequestOrigin(req, policy)) { - return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy); - } - const start = Date.now(); - const requestId = nextRequestLogId(start); - const logCtx: RequestLogContext = { - model: "image_gen", - provider: "unknown", - ...admissionFields(admission), - }; - const endpoint = url.pathname.endsWith("/edits") ? "edits" as const : "generations" as const; - return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => { - const response = await handleImages(req, config, endpoint, logCtx, turnAdmissionLease); - addFinalRequestLog(requestId, start, logCtx, response.status, response.status === 499 ? { closeReason: "client_cancel" } : undefined); - return withCors(response, req, policy); - }, { requestId, start, logCtx }); - } - - if (req.method === "GET" && url.pathname.startsWith("/v1/opencodex/artifacts/")) { - const admission = resolveApiAuth(req, policy); - if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy); - if (!isAllowedRequestOrigin(req, policy)) { - return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy); - } - const id = decodeURIComponent(url.pathname.slice("/v1/opencodex/artifacts/".length)); - const { resolveArtifactPath } = await import("../images/artifacts"); - const artifactPath = resolveArtifactPath(id); - if (!artifactPath) { - return withCors(formatErrorResponse(404, "not_found", "artifact not found"), req, policy); - } - const file = Bun.file(artifactPath); - const ext = artifactPath.split(".").pop()?.toLowerCase(); - const contentType = - ext === "png" ? "image/png" - : ext === "jpg" || ext === "jpeg" ? "image/jpeg" - : ext === "webp" ? "image/webp" - : ext === "gif" ? "image/gif" - : "application/octet-stream"; - return withCors(new Response(file, { - status: 200, - headers: { - "content-type": contentType, - "cache-control": "private, max-age=3600", - "x-content-type-options": "nosniff", - }, - }), req, policy); - } - - if (contextEndpoint(url.pathname) !== undefined && req.method === "POST" && contextRelayActivated()) { - // No timeout disable here. The relay is a bounded JSON round trip that owns one deadline - // from entry; removing the idle timeout first would let an unfinished body hold an - // admitted turn slot indefinitely, before that deadline ever starts. - if (isDraining()) { - return drainingResponse(req, policy); - } - const admission = resolveApiAuth(req, policy); - if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy); - if (!isAllowedRequestOrigin(req, policy)) { - return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy); - } - const start = Date.now(); - const requestId = nextRequestLogId(start); - const logCtx: RequestLogContext = { - model: "context_history", - provider: "unknown", - ...admissionFields(admission), - }; - return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => { - const response = await handleContextHistory(req, config, logCtx, contextEndpoint(url.pathname)!, - turnAdmissionLease, admission, () => resolveApiAuth(req, policy)); - addFinalRequestLog(requestId, start, logCtx, response.status, - response.status === 499 ? { closeReason: "client_cancel" } : undefined); - return withCors(response, req, policy); - }, { requestId, start, logCtx }); - } - - if (url.pathname === "/v1/alpha/search" && req.method === "POST") { - disableResponsesRequestTimeout(req, requestServer); - if (isDraining()) { - return drainingResponse(req, policy); - } - const admission = resolveApiAuth(req, policy); - if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy); - if (!isAllowedRequestOrigin(req, policy)) { - return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy); - } - const start = Date.now(); - const requestId = nextRequestLogId(start); - const logCtx: RequestLogContext = { - model: "web_search", - provider: "unknown", - ...admissionFields(admission), - }; - return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => { - const response = await handleSearch(req, config, logCtx, turnAdmissionLease, admission); - addFinalRequestLog(requestId, start, logCtx, response.status, - response.status === 499 ? { closeReason: "client_cancel" } : undefined); - return withCors(response, req, policy); - }, { requestId, start, logCtx }); - } - - if (url.pathname === "/v1/responses" && req.method === "POST") { - if (isDraining()) { - return drainingResponse(req, policy); - } - const admission = resolveResponsesApiAuth(req, policy); - if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy); - if (!isAllowedRequestOrigin(req, policy)) { - return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy); - } - const start = Date.now(); - const requestId = nextRequestLogId(start); - const logCtx: RequestLogContext = { - model: "unknown", - provider: "unknown", - ...admissionFields(admission), - inboundProtocol: "responses", - }; - if (req.headers.get("x-opencodex-grok") === "1") logCtx.surface = "grok"; - let logged = false; - const finalizeNativePassthroughLog = ( - status: number, - meta: { terminalStatus?: ResponsesTerminalStatus; closeReason: "terminal" | "client_cancel" }, - ) => { - if (logged) return; - logged = true; - addFinalRequestLog(requestId, start, logCtx, status, meta); - }; - return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => { - const response = await handleResponses(req, config, logCtx, { - turnAdmissionLease, - admission, - onRequestBodyRead: () => disableResponsesRequestTimeout(req, requestServer), - abortSignal: req.signal, - onFirstOutput: () => recordFirstOutput(logCtx, start), - onNativePassthroughTerminal: status => { - finalizeNativePassthroughLog(httpStatusForRequestLogTerminal(status, logCtx), { - terminalStatus: status, - closeReason: "terminal", - }); - }, - onNativePassthroughCancel: () => { - finalizeNativePassthroughLog(499, { closeReason: "client_cancel" }); - }, - }); - return withRequestLogId( - withCors(responseWithDeferredRequestLog(response, requestId, start, logCtx), req, policy), - requestId, - ); - }, { requestId, start, logCtx }); - } - - // Anthropic Messages inbound (Claude Code). count_tokens FIRST (longer path). - // Claude Code posts `/v1/messages?beta=true` — pathname match ignores the query (003 G9). - if (url.pathname === "/v1/messages/count_tokens" && req.method === "POST") { - if (isDraining()) { - return drainingResponse(req, policy); - } - const admission = resolveApiAuth(req, policy); - if (!admission) { - return withCors(anthropicErrorResponse(401, "opencodex API key required", "authentication_error"), req, policy); - } - if (!isAllowedRequestOrigin(req, policy)) { - return withCors(anthropicErrorResponse(403, "cross-origin data-plane request blocked", "permission_error"), req, policy); - } - return runAdmittedHttpTurn(req, policy, async () => withCors( - await handleClaudeCountTokens(req, config, policy), - req, - policy, - )); - } - - if (url.pathname === "/v1/messages" && req.method === "POST") { - disableResponsesRequestTimeout(req, requestServer); - if (isDraining()) { - return drainingResponse(req, policy); - } - const admission = resolveApiAuth(req, policy); - if (!admission) { - return withCors(anthropicErrorResponse(401, "opencodex API key required", "authentication_error"), req, policy); - } - if (!isAllowedRequestOrigin(req, policy)) { - return withCors(anthropicErrorResponse(403, "cross-origin data-plane request blocked", "permission_error"), req, policy); - } - const start = Date.now(); - const requestId = nextRequestLogId(start); - const logCtx: RequestLogContext = { - model: "unknown", - provider: "unknown", - ...admissionFields(admission), - inboundProtocol: "messages", - }; - // Logging is finalized inside handleClaudeMessages (Responses-vocab tap on the - // pre-translation stream + native passthrough callbacks) — do not re-wrap the - // translated Anthropic stream here. - return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => withCors( - await handleClaudeMessages(req, config, logCtx, { requestId, start, turnAdmissionLease, admission }, policy), - req, - policy, - ), { requestId, start, logCtx }); - } - - - // OpenAI Chat Completions inbound (GitHub Copilot App / OpenAI-compatible clients). - if (url.pathname === "/v1/chat/completions" && req.method === "POST") { - disableResponsesRequestTimeout(req, requestServer); - if (isDraining()) { - return drainingResponse(req, policy); - } - const admission = resolveResponsesApiAuth(req, policy); - if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy); - if (!isAllowedRequestOrigin(req, policy)) { - return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy); - } - const start = Date.now(); - const requestId = nextRequestLogId(start); - const logCtx: RequestLogContext = { - model: "unknown", - provider: "unknown", - ...admissionFields(admission), - inboundProtocol: "chat", - }; - // `policy`, not `config`: this route is now served on the unauthenticated loopback - // listener too (#4236), and only the receiving listener's view produces CORS headers - // that match the admission decision made above. - return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => withCors( - await handleChatCompletions(req, config, logCtx, { requestId, start, turnAdmissionLease, admission }), - req, - policy, - ), { requestId, start, logCtx }); - } - - if (url.pathname === "/v1/audio/transcriptions" && req.method === "POST") { - disableResponsesRequestTimeout(req, requestServer); - if (isDraining()) return drainingResponse(req, policy); - const admission = resolveAudioAdmission(req.headers, config); - if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy); - if (!isAllowedRequestOrigin(req, policy)) { - return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin audio request blocked"), req, policy); - } - const start = Date.now(); - const requestId = nextRequestLogId(start); - const logCtx: RequestLogContext = { model: TRANSCRIPTION_MODEL, provider: "unknown", ...admissionFields(admission) }; - return runAdmittedHttpTurn(req, policy, async lease => { - const response = await handleAudioTranscriptions(req, config, logCtx, admission, lease); - addFinalRequestLog(requestId, start, logCtx, response.status); - return withCors(response, req, policy); - }, { requestId, start, logCtx }); - } - - // ChatGPT / Codex App voice (GPT‑Live / Frameless Bidi) + OpenAI Realtime call-create. - // Clients hit either /v1/live (Frameless App) or /v1/realtime/calls (codex RealtimeCallClient / - // public Realtime API). Sideband WS joins are handled just below. - if ( - req.method === "POST" - && (url.pathname === "/v1/live" || url.pathname === "/v1/realtime/calls") - ) { - disableResponsesRequestTimeout(req, requestServer); - if (isDraining()) { - return drainingResponse(req, policy); - } - const audioClient = resolveAudioClient(req, config); - if (audioClient instanceof Response) return withCors(audioClient, req, policy); - const admission = audioClient?.admission ?? resolveApiAuth(req, policy); - if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy); - if (!isAllowedRequestOrigin(req, policy)) { - return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy); - } - const start = Date.now(); - const requestId = nextRequestLogId(start); - const logCtx: RequestLogContext = { - model: "gpt-live", - provider: "unknown", - ...admissionFields(admission), - }; - return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => { - const response = audioClient - ? await handleExternalLive(req, config, logCtx, { client: audioClient, lease: turnAdmissionLease, bindings: liveCallBindings }) - : await handleLive(req, config, logCtx, turnAdmissionLease); - addFinalRequestLog( - requestId, - start, - logCtx, - response.status, - response.status === 499 ? { closeReason: "client_cancel" } : undefined, - ); - return withCors(response, req, policy); - }, { requestId, start, logCtx }); - } - - // Voice / Realtime WebSocket relay. Sideband joins: Frameless /v1/live/{callId}; - // Realtime v1 /v1/realtime?call_id= (or /v1/realtime/calls/{callId}). Standalone - // sessions (codex-rs thread/realtime/start, WebSocket transport — the desktop voice - // path): /v1/realtime?intent=quicksilver&model= and /v1/live?model=. - // Transparent bidirectional relay. - const liveSidebandTarget = req.headers.get("upgrade")?.toLowerCase() === "websocket" - ? parseLiveSidebandTarget(url.pathname, url.searchParams, url.search.replace(/^\?/, "")) - : null; - const dictationSocket = url.pathname === "/v1/audio/transcriptions/stream" - && req.headers.get("upgrade")?.toLowerCase() === "websocket"; - if (liveSidebandTarget || dictationSocket) { - if (isDraining()) { - return drainingResponse(req, policy); - } - const audioClient = resolveAudioClient(req, config, dictationSocket); - if (audioClient instanceof Response) return withCors(audioClient, req, policy); - if (!audioClient && liveSidebandTarget && "callId" in liveSidebandTarget - && liveSidebandTarget.callId.startsWith(EXTERNAL_CALL_PREFIX)) { - return withCors(formatErrorResponse(401, "authentication_error", "Live call requires its creator API key"), req, policy); - } - const admission = audioClient?.admission ?? resolveApiAuth(req, policy); - if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy); - if (!isAllowedRequestOrigin(req, policy)) { - return withCors(formatErrorResponse(403, "origin_rejected", "WebSocket upgrade blocked: non-local Origin"), req, policy); - } - const start = Date.now(); - const requestId = nextRequestLogId(start); - const logCtx: RequestLogContext = { - model: "gpt-live", - provider: "unknown", - ...admissionFields(admission), - }; - const turnAdmissionLease = tryAdmitTurn(sessionLaneIdFromRequest(req.headers)); - if (!turnAdmissionLease) return serverBusyResponse(req, "active turns", policy); - const audioController = audioClient ? new AbortController() : undefined; - if (audioController) registerTurn(audioController, turnAdmissionLease); - const acquisition = audioController - ? clearableDeadline(120_000, AbortSignal.any([req.signal, audioController.signal])) : undefined; - const releaseAcquisition = () => { - acquisition?.clear(); - if (audioController) unregisterTurn(audioController); - else turnAdmissionLease.release(); - }; - let resolved; - try { - resolved = dictationSocket && audioClient - ? await resolveDictationSocket(audioClient, config, logCtx, turnAdmissionLease, acquisition?.signal) - : liveSidebandTarget && audioClient - ? await resolveExternalLiveSocket(audioClient, config, logCtx, liveSidebandTarget, { lease: turnAdmissionLease, bindings: liveCallBindings, signal: acquisition?.signal }) - : liveSidebandTarget - ? await resolveLiveSidebandUpgrade(req, config, logCtx, liveSidebandTarget, turnAdmissionLease) - : formatErrorResponse(401, "authentication_error", "opencodex API key required"); - } catch (error) { - releaseAcquisition(); - throw error; - } - if (acquisition?.signal.aborted) { - try { if (!(resolved instanceof Response) && "finish" in resolved) resolved.finish(); } - finally { releaseAcquisition(); } - return withCors(formatErrorResponse(req.signal.aborted ? 499 : acquisition.didExpire() ? 504 : 503, - "upstream_error", acquisition.didExpire() ? "Audio connection timed out" : "Audio connection canceled"), req, policy); - } - if (resolved instanceof Response) { - releaseAcquisition(); - addFinalRequestLog(requestId, start, logCtx, resolved.status); - return withCors(resolved, req, policy); - } - const audio = "finish" in resolved ? resolved : undefined; - const finish = audio ? (outcome?: number | "timeout" | "connect_error") => { - try { audio.finish(outcome); } - finally { releaseAcquisition(); } - } : undefined; - const discardUpgrade = () => { - if (finish) finish(); - else releaseAcquisition(); - }; - if (req.signal.aborted) { - discardUpgrade(); - return withCors(formatErrorResponse(499, "client_closed_request", "Audio connection canceled"), req, policy); - } - const upstreamHandshake = await openLiveSidebandUpstream( - resolved.upstreamWsUrl, - resolved.headers, - (url, headers) => (deps.liveSidebandWebSocketFactory ?? ((socketUrl, socketHeaders, protocols) => ( - new WebSocket(socketUrl, { headers: socketHeaders, protocols } as unknown as string[]) - )))(url, headers, audio?.protocols), - LIVE_SIDEBAND_UPSTREAM_OPEN_TIMEOUT_MS, - req.signal, - ); - if (!upstreamHandshake.ok) { - if (upstreamHandshake.socket) { - closeLiveSidebandBeforeUpgrade(upstreamHandshake.socket, () => discardUpgrade()); - } else { - discardUpgrade(); - } - addFinalRequestLog(requestId, start, logCtx, upstreamHandshake.status); - console.error("[live] sideband upstream handshake failed: " + upstreamHandshake.message); - return withCors( - formatErrorResponse(upstreamHandshake.status, upstreamHandshake.code, upstreamHandshake.message), - req, - policy, - ); - } - const handoffFailure = upstreamHandshake.handoff.failure(); - if (handoffFailure || upstreamHandshake.socket.readyState !== WebSocket.OPEN) { - closeLiveSidebandBeforeUpgrade(upstreamHandshake.socket, () => discardUpgrade()); - const failure = handoffFailure ?? { - status: 502, - code: "upstream_error", - message: "voice upstream closed before client upgrade", - }; - addFinalRequestLog(requestId, start, logCtx, failure.status); - return withCors(formatErrorResponse(failure.status, failure.code, failure.message), req, policy); - } - let upgraded = false; - try { - upgraded = requestServer.upgrade(req, { - ...(audioClient?.protocol ? { headers: { "sec-websocket-protocol": audioClient.protocol } } : {}), - data: { - kind: "live-sideband", - liveUpstream: upstreamHandshake.socket, - liveUpstreamUrl: resolved.upstreamWsUrl, - liveUpstreamHeaders: resolved.headers, - liveUpstreamHandoff: upstreamHandshake.handoff, - admission, - liveUpstreamProtocols: audio?.protocols, - liveValidateFrame: audio?.validateFrame, - liveMaxSessionMs: audio?.maxSessionMs, - liveFinish: finish, - liveAbortSignal: audioController?.signal, - livePending: [], - livePendingBytes: 0, - liveOpened: true, - liveTurnAdmissionLease: turnAdmissionLease, - } satisfies WsData, - }); - } catch { - try { - upstreamHandshake.handoff.take(); - } catch { - /* ignore */ - } - closeLiveSidebandBeforeUpgrade(upstreamHandshake.socket, () => discardUpgrade()); - return withCors(formatErrorResponse(502, "upstream_error", "Audio WebSocket upgrade failed"), req, policy); - } - if (upgraded) { - acquisition?.clear(); - addFinalRequestLog(requestId, start, logCtx, 101); - return undefined as unknown as Response; - } - try { - upstreamHandshake.handoff.take(); - } catch { - /* ignore */ - } - closeLiveSidebandBeforeUpgrade(upstreamHandshake.socket, () => discardUpgrade()); - return withCors(formatErrorResponse(426, "upgrade_required", "WebSocket upgrade failed"), req, policy); - } - - // Data-plane guard: unknown /v1/* paths must fail with JSON 404, never fall through to the - // GUI static handler (extensionless paths would get index.html with HTTP 200 and codex-rs - // endpoint clients — memories/*, realtime/* — would surface confusing - // serde decode errors instead of a clean not-found). - if (url.pathname.startsWith("/v1/")) { - return withCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, policy); - } - - if (url.pathname === "/opencodex-session") { - if (req.method === "GET") { - const session = issueGuiSession(req, config, managementAuth, { - trustedTailscaleIngress: ingress === "hub-management", - }); - return session - ? withManagementCors(serveSessionBootstrap(session), req, config) - : withManagementCors(new Response(null, { status: 401, headers: { "Cache-Control": "no-store" } }), req, config); - } - if (req.method === "POST") { - // This endpoint is reachable WITHOUT a credential — that is the point of a pairing - // exchange — so the body limit has to hold against a caller who controls the - // framing. A declared Content-Length is a claim, not a bound: omit the header and - // `Number(null ?? "0")` is 0, send `Transfer-Encoding: chunked` and there is no - // header at all. Both used to pass the pre-check and land in `req.text()`, which - // buffers whatever arrives. The post-check then measured a string the process had - // already been forced to hold. - // - // So the declared length is only a cheap early reject, and the real bound is - // applied while reading: stop at limit+1 bytes and never accumulate more. - const declaredLength = Number(req.headers.get("content-length") ?? "0"); - if (!Number.isFinite(declaredLength) || declaredLength > GUI_PAIRING_EXCHANGE_BODY_LIMIT) { - return withManagementCors(Response.json({ error: "pairing exchange body too large" }, { status: 413, headers: { "Cache-Control": "no-store" } }), req, config); - } - const bounded = await readBoundedRequestText(req, GUI_PAIRING_EXCHANGE_BODY_LIMIT); - if (bounded === null) { - return withManagementCors(Response.json({ error: "pairing exchange body too large" }, { status: 413, headers: { "Cache-Control": "no-store" } }), req, config); - } - const text = bounded; - let body: unknown; - try { - body = JSON.parse(text); - } catch { - return withManagementCors(Response.json({ error: "invalid pairing exchange body" }, { status: 400, headers: { "Cache-Control": "no-store" } }), req, config); - } - if (!body || typeof body !== "object" || Array.isArray(body) - || Object.keys(body as Record).length !== 1 - || typeof (body as Record).grant !== "string") { - return withManagementCors(Response.json({ error: "invalid pairing exchange body" }, { status: 400, headers: { "Cache-Control": "no-store" } }), req, config); - } - const pairing = managementAuth.available - ? consumeGuiPairingGrant(req, body, config, managementAuth, Date.now(), { - ingress: ingress === "hub-management" ? "hub-management" : "public", - peerAddress: requestServer.requestIP(req)?.address ?? null, - tailscaleUser: ingress === "hub-management" ? req.headers.get("Tailscale-User-Login") : null, - browserOrigin: req.headers.get("Origin") ?? "", - }) - : null; - if (pairing && "allowed" in pairing) { - return withManagementCors(Response.json({ error: "pairing exchange refused" }, { - status: 429, - headers: { "Cache-Control": "no-store", "Retry-After": String(pairing.retryAfterSeconds) }, - }), req, config); - } - return pairing - ? withManagementCors(serveSessionBootstrap(pairing), req, config) - : withManagementCors(new Response(null, { status: 401, headers: { "Cache-Control": "no-store" } }), req, config); - } - return withCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, policy); - } - const guiSessionCandidate = req.method === "GET" && (url.pathname === "/" || !url.pathname.includes(".")) - ? issueGuiSession(req, config, managementAuth, { - trustedTailscaleIngress: ingress === "hub-management", - }) - : null; - const guiFile = serveGuiFile( - url.pathname, - undefined, - guiSessionCandidate ?? undefined, - config.runtimeRole ?? "standalone", - isApiAuthRequired(config), - ); - if (guiFile) return guiFile; - if (url.pathname === "/" && req.method === "GET") { - return jsonResponse(rootFallbackPayload()); - } - - return withCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, config); - }, - websocket: { - maxPayloadLength: MAX_WS_FRAME_BYTES, - idleTimeout: WEBSOCKET_IDLE_TIMEOUT_SECONDS, - // Responses WebSocket data plane (phase 120.2). Re-frames the same SSE pipeline onto the - // socket: parse response.create → run handleResponses unchanged → pump its SSE body as WS - // Text frames. response.processed is a no-op ack. close() aborts the upstream (RC2 parity). - // Live sideband sockets (kind=live-sideband) are a transparent bidirectional relay instead. - open(ws: ServerWebSocket) { - if (ws.data.kind === "remote-workspace-agent") { - const open = ws.data.remoteWorkspaceOpen; - if (!open) { - ws.close(1011, "remote workspace connection unavailable"); - return; - } - try { - ws.data.remoteWorkspaceConnection = open(ws); - } catch { - ws.close(1011, "remote workspace connection failed"); - } - return; - } - if (ws.data.kind === "live-sideband") { - if (!ws.data.liveTurnAdmissionLease) { - closeLiveSideband(ws, 1013, "server busy"); - return; - } - attachLiveSidebandUpstream(ws, deps.liveSidebandWebSocketFactory); - return; - } - if (!ws.data.admissionLease) { - ws.close(1013, "server busy"); - return; - } - ws.data.admissionLease.bind(ws); - registerCodexWebSocket(ws); - }, - message(ws: ServerWebSocket, raw: string | Buffer) { - if (ws.data.kind === "remote-workspace-agent") { - try { - ws.data.remoteWorkspaceConnection?.receive(raw); - } catch { - ws.close(1008, "remote workspace protocol error"); - } - return; - } - if (ws.data.kind === "live-sideband") { - if (ws.data.liveClosing) return; - if (ws.data.liveValidateFrame && !ws.data.liveValidateFrame(raw)) { - closeLiveSideband(ws, 1008, "invalid audio event"); - return; - } - const rawBytes = webSocketFrameBytes(raw); - if (exceedsLiveSidebandFrameByteLimit(rawBytes)) { - closeLiveSideband(ws, 1009, "message too large"); - return; - } - logLiveSidebandFrame("c2u", raw); - const upstream = ws.data.liveUpstream; - if (!upstream || upstream.readyState === WebSocket.CONNECTING || !ws.data.liveOpened) { - const enqueueResult = enqueueLiveSidebandPendingFrame(ws.data, raw, rawBytes); - if (enqueueResult === "too-many-frames") { - closeLiveSideband(ws, 1009, "too many pending frames"); - return; - } - if (enqueueResult === "too-many-bytes") { - closeLiveSideband(ws, 1009, "too many pending bytes"); - return; - } - return; - } - if (upstream.readyState !== WebSocket.OPEN) { - closeLiveSideband(ws, 1011, "upstream not open"); - return; - } - try { - sendUpstreamFrame(upstream, raw); - if (ws.data.liveMaxSessionMs !== undefined && upstream.bufferedAmount > MAX_WS_FRAME_BYTES) { - closeLiveSideband(ws, 1013, "audio upstream backpressure"); - } - } catch { - closeLiveSideband(ws, 1011, "upstream send failed"); - } - return; - } - const rawBytes = typeof raw === "string" ? Buffer.byteLength(raw) : raw.byteLength; - if (rawBytes > MAX_WS_FRAME_BYTES) { - sendJsonFrame(ws, buildWsErrorFrame(413, { - type: "invalid_request_error", - message: "WebSocket response.create frame is too large", - })); - ws.close(1009, "message too large"); - return; - } - let frame: Record; - try { - frame = JSON.parse(typeof raw === "string" ? raw : raw.toString()) as Record; - } catch { - return; // text-only contract; ignore unparseable frames - } - if (frame.type === "response.processed") return; // ack — no-op - if (frame.type !== "response.create") return; - markActivity("ws response.create"); - - ws.data.cancel?.(); - const turnId = (ws.data.turnId ?? 0) + 1; - ws.data.turnId = turnId; - const isCurrent = () => ws.data.turnId === turnId; - const turnAbort = new AbortController(); - const cancelTurn = () => { - turnAbort.abort("websocket turn superseded or closed"); - }; - ws.data.cancel = cancelTurn; - // A socket may carry several response.create frames. Clear the previous - // account before resolving this frame so a failed Multi resolution cannot - // leave stale invalidation ownership behind. - updateCodexWebSocketAuthContext(ws, undefined); - - if (frame.generate === false) { - for (const payload of buildWarmupCompletionFrames(frame)) { - if (!isCurrent()) return; - sendTextFrame(ws, payload); - } - if (ws.data.cancel === cancelTurn) ws.data.cancel = undefined; - return; - } - - const turnAdmissionLease = tryAdmitTurn(ws.data.sessionLaneId); - if (!turnAdmissionLease) { - sendJsonFrame(ws, buildWsErrorFrame(503, { - type: "server_error", - code: "server_busy", - message: "active turns capacity reached", - retryable: true, - }, new Headers({ "Retry-After": "1" }))); - if (ws.data.cancel === cancelTurn) ws.data.cancel = undefined; - return; - } - - const payload: Record = { ...frame }; - delete payload.type; - turnAdmissionLease.bindAbortController(turnAbort); - void (async () => { - const start = Date.now(); - const requestId = nextRequestLogId(start); - // Resolved once at the handshake — a frame has no request headers left - // to re-resolve from. Optional on WsData like every other member, so - // narrow rather than assume: an unattributed frame is preferable to a - // fabricated attribution. - const wsAdmission = ws.data.admission; - const logCtx: RequestLogContext = { - model: "unknown", - provider: "unknown", - ...(wsAdmission ? admissionFields(wsAdmission) : {}), - inboundProtocol: "responses", - }; - let logged = false; - const finalizeLog = ( - status: number, - meta?: Pick, - ) => { - if (logged) return; - logged = true; - addFinalRequestLog(requestId, start, logCtx, status, meta); - }; - const baseHeaders = ws.data.headers ?? new Headers(); - const fwd = new Headers({ "content-type": "application/json" }); - baseHeaders.forEach((value, key) => fwd.set(key, value)); - const req = new Request("http://localhost/v1/responses", { - method: "POST", - headers: fwd, - body: JSON.stringify({ ...payload, stream: true }), - }); - try { - let terminalRecorder: ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined; - const response = await handleResponses(req, config, logCtx, { - ...(wsAdmission ? { admission: wsAdmission } : {}), - forceEmptyResponseId: true, - inboundTransport: "websocket", - abortSignal: turnAbort.signal, - turnAdmissionLease, - onFirstOutput: () => recordFirstOutput(logCtx, start), - onCodexAuthContextResolved: context => updateCodexWebSocketAuthContext(ws, context), - recordTerminalOutcomes: false, - setTerminalOutcomeRecorder: recorder => { - terminalRecorder = recorder; - }, - }); - await sendResponseToWebSocket(ws, response, isCurrent, { - onSsePayload: payload => inspectResponseLogSsePayload(logCtx, payload), - onTerminal: status => { - terminalRecorder?.(status, logCtx.terminalHttpStatus); - finalizeLog(httpStatusForRequestLogTerminal(status, logCtx), { - terminalStatus: status, - closeReason: "terminal", - }); - }, - }); - if (!logged) finalizeLog(turnAbort.signal.aborted ? 499 : response.status); - } catch (err) { - if (!isCurrent()) return; - try { - if (err instanceof CodexAccountCooldownError) { - finalizeLog(429); - // Codex Desktop rides this WS transport, so it must carry the same - // actionable text as HTTP; a frame has no headers, hence message-only. - const accountSelector = typeof payload.model === "string" - ? codexAccountNamespaceForModel(config.codexAccountNamespaces, payload.model) - : undefined; - sendJsonFrame(ws, buildWsErrorFrame(429, { - type: "rate_limit_error", - message: cooldownErrorMessage(err, accountSelector), - })); - return; - } - finalizeLog(502); - sendJsonFrame(ws, buildWsErrorFrame(502, { - type: "proxy_error", - message: err instanceof Error ? err.message : String(err), - })); - } catch { - /* socket already gone or send dropped */ - } - } finally { - turnAdmissionLease.release(); - if (!logged && turnAbort.signal.aborted) finalizeLog(499); - if (ws.data.cancel === cancelTurn) ws.data.cancel = undefined; - } - })(); - }, - close(ws: ServerWebSocket) { - if (ws.data.kind === "remote-workspace-agent") { - ws.data.remoteWorkspaceClose?.(); - return; - } - if (ws.data.kind === "live-sideband") { - closeLiveSideband(ws); - return; - } - unregisterCodexWebSocket(ws); - ws.data.admissionLease?.release(); - ws.data.admissionLease = undefined; - ws.data.cancel?.(); // RC2: abort the upstream when the client disconnects - }, - }, - } as const; + const serveOptions = createServeOptions({ + drainingResponse, + ingressForServer, + loopbackRouteAllowed, + managementIngressRouteAllowed, + packageTreeChangedResponse, + serverBusyResponse, + runAdmittedHttpTurn, + config, + inboundBodyLimitBytes, + listenPort, + liveCallBindings, + loadRemoteWorkspaceRuntime, + localAttestationSecret, + loopbackPolicy, + managementApiDeps, + managementAuth, + managementSessionControl, + packageTreeIntegrity, + readinessGate, + deps, + port, + get server() { return server; }, + get boundPort() { return boundPort; }, + get remoteWorkspaceStopping() { return remoteWorkspaceStopping; }, + }); server = Bun.serve({ ...serveOptions, port: listenPort, hostname: bindHost }); @@ -3398,3 +891,8 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server { + 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. + * + * Scoped to configured keys on purpose: an environment token or a loopback bind has no key + * to name, and emitting one anyway would invent an attribution that does not exist. 200 only + * — this route emits no validator and therefore never answers 304. + * + * An id that fails the header-safe pattern is omitted rather than sanitized, with one warning + * that does NOT repeat the id: logging the offending value is how a malformed id becomes a + * log-injection vector instead of a dropped header. + */ +export function withRemoteCatalogKeyId(response: Response, admission: DataPlaneAdmission): Response { + if (response.status !== 200 || admission.kind !== "configured") return response; + if (!REMOTE_CATALOG_KEY_ID_PATTERN.test(admission.keyId)) { + console.warn("[remote-catalog] configured API key id is not header-safe; omitting x-opencodex-key-id"); + return response; + } + response.headers.set("x-opencodex-key-id", admission.keyId); + return response; +} diff --git a/src/server/index/live-sideband.ts b/src/server/index/live-sideband.ts new file mode 100644 index 0000000000..3320646805 --- /dev/null +++ b/src/server/index/live-sideband.ts @@ -0,0 +1,540 @@ +import { + buildWarmupCompletionFrames, + buildWsErrorFrame, + selectForwardHeaders, + sendJsonFrame, + buildResponsesWsData, + sendResponseToWebSocket, + sendTextFrame, + type LiveSidebandUpstreamFailure, + type LiveSidebandUpstreamHandoff, + type WsData, +} from "../ws-bridge"; +import type { Server, ServerWebSocket } from "bun"; +import { handleLive, logLiveSidebandFrame, parseLiveSidebandTarget, resolveLiveSidebandUpgrade } from "../live"; + +export const MAX_WS_FRAME_BYTES = 50 * 1024 * 1024; +export const WEBSOCKET_IDLE_TIMEOUT_SECONDS = 0; + +const LIVE_SIDEBAND_PENDING_MAX = 32; +const LIVE_SIDEBAND_PENDING_BYTES_MAX = 1024 * 1024; +const LIVE_SIDEBAND_CLOSE_FALLBACK_MS = 1_000; +/** + * Bound the pre-upgrade upstream handshake. A sideband join that cannot reach 101 + * must fail the client upgrade promptly rather than hold it open indefinitely. + */ +export const LIVE_SIDEBAND_UPSTREAM_OPEN_TIMEOUT_MS = 10_000; + +/** + * Outcome of the upstream sideband handshake performed before the client upgrade. + * + * `ok: false` carries the HTTP status the client upgrade must fail with. Only an + * upgrade failure reaches codex-rs as a connect error, and only a connect error + * ends its sideband reconnect loop (`realtime_conversation/sideband.rs`: the `Err` + * arm always breaks). A 101 followed by a close is instead read as `TransportLost` + * and retried forever against the same, permanently dead call id. + */ +export type LiveSidebandUpstreamOpenResult = + | { + ok: true; + socket: WebSocket; + /** Owns capture and terminal events until the downstream relay attaches. */ + handoff: LiveSidebandUpstreamHandoff; + } + | { ok: false; status: number; code: string; message: string; socket?: WebSocket }; + +export function exceedsLiveSidebandFrameByteLimit(frameBytes: number): boolean { + return frameBytes > MAX_WS_FRAME_BYTES; +} + +export function exceedsLiveSidebandPendingByteLimit(pendingBytes: number, incomingBytes: number): boolean { + return incomingBytes > LIVE_SIDEBAND_PENDING_BYTES_MAX - pendingBytes; +} + +export function webSocketFrameBytes(frame: string | ArrayBuffer | ArrayBufferView | Blob | Buffer): number { + if (typeof frame === "string") return Buffer.byteLength(frame); + if (frame instanceof ArrayBuffer || ArrayBuffer.isView(frame)) return frame.byteLength; + return frame.size; +} + +export type LiveSidebandPendingEnqueueResult = "queued" | "too-many-frames" | "too-many-bytes"; + +export function enqueueLiveSidebandPendingFrame( + data: Pick, + frame: string | Buffer, + frameBytes = webSocketFrameBytes(frame), +): LiveSidebandPendingEnqueueResult { + const pending = data.livePending ?? (data.livePending = []); + if (pending.length >= LIVE_SIDEBAND_PENDING_MAX) return "too-many-frames"; + const pendingBytes = data.livePendingBytes ?? 0; + if (exceedsLiveSidebandPendingByteLimit(pendingBytes, frameBytes)) return "too-many-bytes"; + pending.push(frame); + data.livePendingBytes = pendingBytes + frameBytes; + return "queued"; +} + +export type LiveSidebandWebSocketFactory = ( + url: string, + headers: Record, + protocols?: string[], +) => WebSocket; + +function releaseLiveSidebandAdmission(ws: ServerWebSocket): void { + ws.data.liveTurnAdmissionLease?.release(); + ws.data.liveTurnAdmissionLease = undefined; +} + +/** + * Send one live-sideband frame to the upstream socket. + * + * Bun's `WebSocket.send` accepts `string | Blob | BufferSource`, but the DOM-lib + * `Buffer` can be backed by a `SharedArrayBuffer`, which `BufferSource` rejects. + * `Uint8Array.from` copies into a fresh `ArrayBuffer`-backed view, so a frame + * arriving from `node:buffer` still round-trips byte-for-byte. + */ +export function sendUpstreamFrame(upstream: WebSocket, frame: string | Buffer): void { + if (typeof frame === "string") { + upstream.send(frame); + return; + } + upstream.send(Uint8Array.from(frame)); +} + +function finalizeLiveSideband(ws: ServerWebSocket, upstream?: WebSocket): void { + if (upstream && ws.data.liveUpstream !== upstream) return; + if (ws.data.liveCloseFallback !== undefined) { + clearTimeout(ws.data.liveCloseFallback); + ws.data.liveCloseFallback = undefined; + } + ws.data.liveUpstream = undefined; + ws.data.livePending = undefined; + ws.data.livePendingBytes = undefined; + if (ws.data.liveConnectTimer !== undefined) clearTimeout(ws.data.liveConnectTimer); + if (ws.data.liveSessionTimer !== undefined) clearTimeout(ws.data.liveSessionTimer); + ws.data.liveConnectTimer = undefined; + ws.data.liveSessionTimer = undefined; + ws.data.liveUpstreamHeaders = undefined; + ws.data.liveUpstreamProtocols = undefined; + ws.data.liveValidateFrame = undefined; + if (ws.data.liveAbortListener) ws.data.liveAbortSignal?.removeEventListener("abort", ws.data.liveAbortListener); + ws.data.liveAbortSignal = undefined; + ws.data.liveAbortListener = undefined; + ws.data.cancel = undefined; + const finish = ws.data.liveFinish; + ws.data.liveFinish = undefined; + try { finish?.(ws.data.liveOutcome); } + catch { console.warn("[audio] upstream accounting failed during close"); } + finally { releaseLiveSidebandAdmission(ws); } +} + +function armLiveSidebandCloseFallback(ws: ServerWebSocket, upstream: WebSocket): void { + if (ws.data.liveCloseFallback !== undefined) return; + ws.data.liveCloseFallback = setTimeout(() => { + ws.data.liveCloseFallback = undefined; + if (ws.data.liveUpstream !== upstream) return; + if (upstream.readyState === WebSocket.CLOSED) { + finalizeLiveSideband(ws, upstream); + return; + } + // A close frame was already sent below. Retry once, but never surrender + // native-main ownership while the authenticated transport remains live. + try { + upstream.close(1000, "upstream close timeout"); + } catch { + /* upstream is already unusable */ + } + // Some implementations transition synchronously without delivering the + // close event. That is still an observed CLOSED transport and is safe to + // finalize. CONNECTING/CLOSING peers keep the lease so profile switching + // fails at its own bounded drain deadline instead of racing live traffic. + // The earlier CLOSED check narrowed `readyState` to 0|1|2 in the type + // system, but the socket can still transition to CLOSED (3) before this + // fallback fires; the cast keeps the runtime-identical check. + if ((upstream.readyState as number) === 3) finalizeLiveSideband(ws, upstream); + }, LIVE_SIDEBAND_CLOSE_FALLBACK_MS); +} + +export function closeLiveSidebandBeforeUpgrade( + upstream: WebSocket, + release: () => void, + code = 1000, + reason = "", +): void { + // There is no downstream socket to own this transport yet. Mirror + // closeLiveSideband's bounded close contract directly: release only after a + // close event or an observed CLOSED state, never merely after requesting close. + let released = false; + let fallback: ReturnType | undefined; + const releaseOnce = (): void => { + if (released) return; + released = true; + if (fallback !== undefined) clearTimeout(fallback); + release(); + }; + upstream.addEventListener("close", releaseOnce, { once: true }); + if (upstream.readyState === WebSocket.CLOSED) { + releaseOnce(); + return; + } + fallback = setTimeout(() => { + if (upstream.readyState === WebSocket.CLOSED) { + releaseOnce(); + return; + } + try { + upstream.close(1000, "upstream close timeout"); + } catch { + /* retain ownership until CLOSED is observed */ + } + if ((upstream.readyState as number) === 3) releaseOnce(); + }, LIVE_SIDEBAND_CLOSE_FALLBACK_MS); + try { + upstream.close(code, reason); + } catch { + /* the bounded fallback retries without releasing ownership */ + } + if ((upstream.readyState as number) === 3) releaseOnce(); +} + +export function closeLiveSideband(ws: ServerWebSocket, code = 1000, reason = ""): void { + if (ws.data.liveClosing) return; + ws.data.liveClosing = true; + if (ws.data.liveConnectTimer !== undefined) clearTimeout(ws.data.liveConnectTimer); + if (ws.data.liveSessionTimer !== undefined) clearTimeout(ws.data.liveSessionTimer); + ws.data.liveConnectTimer = undefined; + ws.data.liveSessionTimer = undefined; + ws.data.livePending = undefined; + ws.data.livePendingBytes = undefined; + ws.data.cancel = undefined; + const upstream = ws.data.liveUpstream; + // Bun's `WebSocket` type narrows `readyState` to 0|1|2 even though the DOM + // constant CLOSED is 3; the numeric literal is the runtime-identical check. + if (!upstream || upstream.readyState === 3) { + finalizeLiveSideband(ws, upstream); + } else { + // The sideband holds a native-main admission lease. Do not release it just + // because the downstream left: its authenticated upstream remains live + // until the close event arrives or the transport is observed CLOSED. The + // bounded fallback only retries close; it does not release ownership. + armLiveSidebandCloseFallback(ws, upstream); + try { + upstream.close(code, reason); + } catch { + /* the fallback retries close without releasing ownership */ + } + } + try { + if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { + ws.close(code, reason); + } + } catch { + /* client already gone */ + } +} + +/** + * Dial the upstream sideband and report whether its handshake reached 101. + * + * Bun's client WebSocket does not surface the upstream handshake status, so the + * result is "opened" or "failed" and nothing finer. That is sufficient for the + * property this exists to guarantee: the client is never told the relay is live + * when it is not. Frames the upstream sends before the client socket exists are + * captured and handed back by `drain`, because a session preamble such as + * `session.created` arrives immediately after the upstream opens. + */ +export function openLiveSidebandUpstream( + url: string, + headers: Record, + createWebSocket: LiveSidebandWebSocketFactory = (socketUrl, socketHeaders) => ( + new WebSocket(socketUrl, { headers: socketHeaders } as unknown as string[]) + ), + timeoutMs: number = LIVE_SIDEBAND_UPSTREAM_OPEN_TIMEOUT_MS, + signal?: AbortSignal, +): Promise { + return new Promise(resolve => { + let socket: WebSocket; + try { + socket = createWebSocket(url, headers); + } catch { + resolve({ ok: false, status: 502, code: "upstream_error", message: "voice upstream connect failed" }); + return; + } + + const buffered: Array = []; + let bufferedBytes = 0; + let capturing = true; + let settled = false; + let terminalFailure: LiveSidebandUpstreamFailure | undefined; + let removeAbortListener = (): void => {}; + + const finish = (result: LiveSidebandUpstreamOpenResult): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + removeAbortListener(); + resolve(result); + }; + const timer = setTimeout(() => { + const failure = { status: 504, code: "upstream_timeout", message: "voice upstream did not open in time" }; + terminalFailure = failure; + capturing = false; + buffered.length = 0; + bufferedBytes = 0; + finish({ ok: false, ...failure, socket }); + try { + socket.close(); + } catch { + /* ignore */ + } + }, timeoutMs); + + const failCapture = (failure: LiveSidebandUpstreamFailure): void => { + if (!capturing || terminalFailure) return; + terminalFailure = failure; + capturing = false; + buffered.length = 0; + bufferedBytes = 0; + finish({ ok: false, ...failure, socket }); + try { + socket.close(1009, "sideband preamble overflow"); + } catch { + /* the terminal failure is already retained for the downstream handoff */ + } + }; + const handoff: LiveSidebandUpstreamHandoff = { + failure: () => terminalFailure, + take: () => { + capturing = false; + if (terminalFailure) return { ok: false, failure: terminalFailure }; + const frames = buffered.slice(); + buffered.length = 0; + bufferedBytes = 0; + return { ok: true, frames }; + }, + }; + + socket.addEventListener("message", event => { + if (!capturing) return; + const frameBytes = webSocketFrameBytes(event.data); + if (exceedsLiveSidebandFrameByteLimit(frameBytes)) { + failCapture({ status: 502, code: "upstream_overflow", message: "voice upstream preamble frame is too large" }); + return; + } + if (buffered.length >= LIVE_SIDEBAND_PENDING_MAX) { + failCapture({ status: 502, code: "upstream_overflow", message: "voice upstream sent too many preamble frames" }); + return; + } + if (exceedsLiveSidebandPendingByteLimit(bufferedBytes, frameBytes)) { + failCapture({ status: 502, code: "upstream_overflow", message: "voice upstream preamble is too large" }); + return; + } + if (typeof event.data === "string") buffered.push(event.data); + else if (event.data instanceof ArrayBuffer) buffered.push(Buffer.from(new Uint8Array(event.data))); + else if (ArrayBuffer.isView(event.data)) { + buffered.push(Buffer.from(new Uint8Array(event.data.buffer, event.data.byteOffset, event.data.byteLength))); + } else return; + bufferedBytes += frameBytes; + }); + socket.addEventListener("open", () => { + finish({ + ok: true, + socket, + handoff, + }); + }); + socket.addEventListener("error", () => { + const failure = { status: 502, code: "upstream_error", message: "voice upstream rejected the sideband join" }; + terminalFailure ??= failure; + capturing = false; + buffered.length = 0; + bufferedBytes = 0; + finish({ ok: false, ...terminalFailure, socket }); + try { + socket.close(); + } catch { + /* the terminal failure is already retained */ + } + }); + socket.addEventListener("close", event => { + const failure = { + status: 502, + code: "upstream_error", + message: `voice upstream closed before opening (code ${event.code})`, + closeCode: event.code, + closeReason: event.reason, + }; + terminalFailure ??= failure; + capturing = false; + buffered.length = 0; + bufferedBytes = 0; + finish({ ok: false, ...terminalFailure, socket }); + }); + const abortOpen = (): void => { + const failure = { status: 499, code: "request_cancelled", message: "voice sideband join was cancelled" }; + terminalFailure ??= failure; + capturing = false; + buffered.length = 0; + bufferedBytes = 0; + finish({ ok: false, ...terminalFailure, socket }); + try { + socket.close(); + } catch { + /* the cancelled join no longer owns the socket */ + } + }; + if (signal) { + signal.addEventListener("abort", abortOpen, { once: true }); + removeAbortListener = () => signal.removeEventListener("abort", abortOpen); + if (signal.aborted) abortOpen(); + } + }); +} + +export function attachLiveSidebandUpstream( + ws: ServerWebSocket, + createWebSocket: LiveSidebandWebSocketFactory = (url, headers, protocols) => ( + new WebSocket(url, { headers, protocols } as unknown as string[]) + ), +): void { + if (ws.data.liveAbortSignal?.aborted) { + closeLiveSideband(ws, 1000, "audio connection canceled"); + return; + } + const preOpened = ws.data.liveUpstream; + let upstream: WebSocket; + if (preOpened) { + upstream = preOpened; + } else { + const url = ws.data.liveUpstreamUrl; + if (!url) { + closeLiveSideband(ws, 1011, "missing upstream"); + return; + } + try { + // Bun accepts per-handshake headers; the DOM lib types only list protocol arrays. + upstream = createWebSocket(url, ws.data.liveUpstreamHeaders ?? {}, ws.data.liveUpstreamProtocols); + } catch { + closeLiveSideband(ws, 1011, "upstream connect failed"); + return; + } + } + ws.data.liveUpstream = upstream; + ws.data.liveUpstreamHeaders = undefined; + ws.data.liveUpstreamProtocols = undefined; + ws.data.liveClosing = false; + ws.data.cancel = () => closeLiveSideband(ws, 1000, "client closed"); + if (ws.data.liveMaxSessionMs !== undefined) { + ws.data.liveConnectTimer = setTimeout(() => { + ws.data.liveOutcome = "timeout"; + closeLiveSideband(ws, 1011, "audio connection timed out"); + }, 10_000); + ws.data.liveSessionTimer = setTimeout(() => closeLiveSideband(ws, 1000, "audio session expired"), ws.data.liveMaxSessionMs); + } + + upstream.addEventListener("close", (event) => { + if (ws.data.liveUpstream !== upstream) return; + if (ws.data.liveFinish && !ws.data.liveClosing && event.code !== 1000) ws.data.liveOutcome = "connect_error"; + ws.data.liveClosing = true; + finalizeLiveSideband(ws, upstream); + try { + const external = ws.data.liveMaxSessionMs !== undefined; + const validCode = event.code === 1000 || (event.code >= 1001 && event.code <= 1014 && ![1004, 1005, 1006].includes(event.code)) + || (event.code >= 3000 && event.code <= 4999); + ws.close(external && !validCode ? 1011 : event.code || 1000, external ? "audio upstream closed" : event.reason || ""); + } catch { + /* ignore */ + } + }); + upstream.addEventListener("error", () => { + if (ws.data.liveUpstream !== upstream) return; + if (ws.data.liveFinish && !ws.data.liveClosing) ws.data.liveOutcome = "connect_error"; + closeLiveSideband(ws, 1011, "upstream error"); + }); + if (ws.data.liveAbortSignal) { + ws.data.liveAbortListener = () => closeLiveSideband(ws, 1000, "audio connection canceled"); + ws.data.liveAbortSignal.addEventListener("abort", ws.data.liveAbortListener, { once: true }); + if (ws.data.liveAbortSignal.aborted) closeLiveSideband(ws, 1000, "audio connection canceled"); + } + + if (preOpened) { + // The upstream opened before this socket existed, so its `open` event has already + // fired and the listener below will never run. Its early frames were captured for + // us; forward the capture now rather than dropping the session preamble. + const handoff = ws.data.liveUpstreamHandoff; + ws.data.liveUpstreamHandoff = undefined; + const takeover = handoff?.take(); + if (!takeover?.ok || preOpened.readyState !== WebSocket.OPEN) { + const failure = takeover && !takeover.ok ? takeover.failure : undefined; + closeLiveSideband( + ws, + failure?.closeCode ?? 1011, + failure?.closeReason ?? "upstream closed before relay attachment", + ); + return; + } + ws.data.liveOpened = true; + // The upstream opened before this socket existed, so the "open" listener + // below can never fire for it. Disarm the connect watchdog exactly as that + // listener would, or every session with a max lifetime is force-closed ten + // seconds after attach. The session timer stays armed: it bounds the whole + // session, not the connect phase. + if (ws.data.liveConnectTimer !== undefined) clearTimeout(ws.data.liveConnectTimer); + ws.data.liveConnectTimer = undefined; + for (const frame of takeover.frames) { + try { + // Mirror the live message listener exactly: same ceiling, same diagnostic + // record. These frames are upstream-to-client like any other. + if (exceedsLiveSidebandFrameByteLimit(webSocketFrameBytes(frame))) { + closeLiveSideband(ws, 1009, "message too large"); + return; + } + logLiveSidebandFrame("u2c", frame); + ws.send(frame); + } catch { + closeLiveSideband(ws, 1011, "client send failed"); + return; + } + } + } + + upstream.addEventListener("open", () => { + if (ws.data.liveUpstream !== upstream || ws.data.liveClosing) return; + ws.data.liveOpened = true; + if (ws.data.liveConnectTimer !== undefined) clearTimeout(ws.data.liveConnectTimer); + ws.data.liveConnectTimer = undefined; + // An accepted transport alone does not prove inference/quota recovery. + // Keep healthy closes neutral; explicit transport failures are recorded below. + const pending = ws.data.livePending ?? []; + ws.data.livePending = undefined; + ws.data.livePendingBytes = undefined; + for (const frame of pending) { + try { + sendUpstreamFrame(upstream, frame); + } catch { + closeLiveSideband(ws, 1011, "upstream send failed"); + return; + } + } + }); + upstream.addEventListener("message", (event) => { + if (ws.data.liveUpstream !== upstream || ws.data.liveClosing) return; + try { + if (exceedsLiveSidebandFrameByteLimit(webSocketFrameBytes(event.data))) { + closeLiveSideband(ws, 1009, "message too large"); + return; + } + logLiveSidebandFrame("u2c", event.data); + let sent: number; + if (typeof event.data === "string") sent = ws.send(event.data); + else if (event.data instanceof ArrayBuffer) sent = ws.send(event.data); + else if (ArrayBuffer.isView(event.data)) { + sent = ws.send(event.data.buffer.slice(event.data.byteOffset, event.data.byteOffset + event.data.byteLength)); + } else sent = ws.send(event.data as Buffer); + if (ws.data.liveMaxSessionMs !== undefined && (sent === 0 || ws.getBufferedAmount() > MAX_WS_FRAME_BYTES)) { + closeLiveSideband(ws, 1013, "audio client backpressure"); + } + } catch { + closeLiveSideband(ws, 1011, "client send failed"); + } + }); +} diff --git a/src/server/index/serve-options.ts b/src/server/index/serve-options.ts new file mode 100644 index 0000000000..5bcbb60a5e --- /dev/null +++ b/src/server/index/serve-options.ts @@ -0,0 +1,1766 @@ +import type { Server, ServerWebSocket } from "bun"; +import type { StartServerDeps } from "./startup-warnings"; +import { + GUI_PAIRING_EXCHANGE_BODY_LIMIT, + REMOTE_WORKSPACE_PAIRING_BODY_LIMIT, + readBoundedRequestText, + withRemoteCatalogKeyId, +} from "./bounded-request"; +import { + LIVE_SIDEBAND_UPSTREAM_OPEN_TIMEOUT_MS, + MAX_WS_FRAME_BYTES, + WEBSOCKET_IDLE_TIMEOUT_SECONDS, + attachLiveSidebandUpstream, + closeLiveSideband, + closeLiveSidebandBeforeUpgrade, + enqueueLiveSidebandPendingFrame, + exceedsLiveSidebandFrameByteLimit, + openLiveSidebandUpstream, + sendUpstreamFrame, + webSocketFrameBytes, +} from "./live-sideband"; +import { + withRequestLogId, +} from "./startup-warnings"; + +import { remoteWorkspaceEnabled } from "../../remote-control/workspace-activation"; +import { markActivity } from "../../lib/sidecar-tracker"; +import { knownModelIdsForProvider } from "../../router"; +import { + buildWarmupCompletionFrames, + buildWsErrorFrame, + selectForwardHeaders, + sendJsonFrame, + buildResponsesWsData, + sendResponseToWebSocket, + sendTextFrame, + type WsData, +} from "../ws-bridge"; +import { websocketsEnabled } from "../../config"; +import { grokDefaultReasoningEffort } from "../../grok/effort"; +import { OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers"; +import { providerCodexAccountMode } from "../../providers/registry"; +import { + codexAccountNamespaceEntries, + isMainCodexAccountTarget, +} from "../../codex/account-namespaces"; +import { MAIN_CODEX_ACCOUNT_ID } from "../../codex/main-account"; +import { + availableAccountGatedNativeModels, + codexModelEntitlementStateForAccount, + resolveCodexModelEntitlements, +} from "../../codex/model-entitlements"; +import { CatalogGatherBusyError } from "../../codex/catalog/provider-fetch"; +import { + registerCodexWebSocket, + tryReserveCodexWebSocket, + unregisterCodexWebSocket, + updateCodexWebSocketAuthContext, +} from "../../codex/websocket-registry"; +import { + rootFallbackPayload, + serveGuiFile, + serveSessionBootstrap, +} from "../gui-static"; +import { + formatErrorResponse, + type ResponsesTerminalStatus, +} from "../../bridge"; +import { + isDraining, + registerTurn, + tryAdmitTurn, + unregisterTurn, + type ActiveTurnLease, +} from "../lifecycle"; +import { + addFinalRequestLog, + httpStatusForRequestLogTerminal, + inspectResponseLogSsePayload, + nextRequestLogId, + recordFirstOutput, + type RequestLogContext, + type RequestLogEntry, +} from "../request-log"; +import { sessionLaneIdFromRequest } from "../request-log-conversation"; +import { responseWithDeferredRequestLog } from "../relay"; +import { + corsHeaders, + managementCorsHeaders, + isAllowedRequestOrigin, + isAllowedManagementOrigin, + isApiAuthRequired, + jsonResponse, + admissionFields, + resolveApiAuth, + resolveResponsesApiAuth, + type RequestPolicyView, + withCors, + withManagementCors, +} from "../auth-cors"; +import { + disableResponsesRequestTimeout, + handleResponses, + handleResponsesCompact, +} from "../responses"; +import { + handleClaudeCountTokens, + handleClaudeMessages, +} from "../claude-messages"; +import { handleChatCompletions } from "../chat-completions"; +import { anthropicErrorResponse } from "../../claude/outbound"; +import { + buildDesktop3pRegistry, + generateDesktop3pModels, +} from "../../claude/desktop-3p"; +import { buildDesktopDiscoveryInputs } from "../../claude/desktop-discovery-inputs"; +import { handleImages } from "../images"; +import { + handleLive, + logLiveSidebandFrame, + parseLiveSidebandTarget, + resolveLiveSidebandUpgrade, +} from "../live"; +import { handleAudioTranscriptions } from "../audio-transcriptions"; +import { + resolveAudioAdmission, + TRANSCRIPTION_MODEL, +} from "../audio-upstream"; +import { resolveAudioClient } from "../audio-client"; +import { resolveDictationSocket } from "../audio-dictation"; +import { + handleExternalLive, + resolveExternalLiveSocket, +} from "../audio-live"; +import { + EXTERNAL_CALL_PREFIX, + type LiveCallBindings, +} from "../live-call-bindings"; +import { clearableDeadline } from "../../lib/abort"; +import { handleSearch } from "../search"; +import { handleContextHistory } from "../context-history"; +import { + codexCompatibleUrl, + contextEndpoint, + contextRelayActivated, +} from "../../codex/context-compat"; +import { + fetchAllModels, + handleManagementAPI, + VERSION, + type ManagementApiDeps, +} from "../management-api"; +import { + issueGuiSession, + managementPrincipal, + requireManagementAuth, + type ManagementAuthState, + type ManagementSessionControl, +} from "../management-auth"; +import { + LOCAL_ATTESTATION_CHALLENGE_HEADER, + LOCAL_ATTESTATION_PROOF_HEADER, + createLocalAttestationProof, +} 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 { recordCursorSeen } from "../../integrations/cursor-seen"; +import { detectCursorInstalls } from "../../integrations/cursor-detect"; +import { loadCursorEffortTable } from "../../integrations/cursor-effort-table"; +import { + expandCursorEffortRow, + knownEffortRowIds, +} from "../effort-row"; +import { + catalogFastRowEligible, + expandFastRow, +} from "../fast-row"; +import type { OcxConfig } from "../../types"; +import type { PackageTreeIntegrityGuard } from "../../lib/package-tree-integrity"; +import type { ReadinessGate } from "../readiness"; +import type { WorkflowRefusalLog } from "../workflow-refusal"; + +import { readyProtocolMetadata } from "../../remote/protocol"; +import { modelCapabilityFields } from "../models-capabilities"; +import { createWebsocketHandler } from "./websocket-handler"; + +export type ServerIngress = "public" | "unauthenticated-loopback" | "hub-management"; + +export interface ServeOptionsContext { + readonly server: Server; + readonly boundPort: number | null; + readonly remoteWorkspaceStopping: boolean; + + drainingResponse: (req: Request, policy: RequestPolicyView) => Response; + ingressForServer: (requestServer: Server) => ServerIngress; + loopbackRouteAllowed: (url: URL, req: Request) => boolean; + managementIngressRouteAllowed: (url: URL, req: Request) => boolean; + packageTreeChangedResponse: ( + req: Request, + policy: RequestPolicyView, + message: string, + ) => Response; + serverBusyResponse: ( + req: Request, + resource: string, + policy: RequestPolicyView, + ) => Response; + runAdmittedHttpTurn: ( + req: Request, + policy: RequestPolicyView, + work: (lease: ActiveTurnLease) => Promise, + refusalLog?: WorkflowRefusalLog, + ) => Promise; + + config: OcxConfig; + inboundBodyLimitBytes: number; + listenPort: number; + liveCallBindings: LiveCallBindings; + loadRemoteWorkspaceRuntime: () => Promise< + typeof import("../../remote-control/workspace-runtime") + >; + localAttestationSecret: string; + loopbackPolicy: () => RequestPolicyView; + managementApiDeps: ManagementApiDeps; + managementAuth: ManagementAuthState; + managementSessionControl: ManagementSessionControl; + packageTreeIntegrity: PackageTreeIntegrityGuard; + readinessGate: ReadinessGate; + + deps: StartServerDeps; + port: number | undefined; +} + +export function createServeOptions(ctx: ServeOptionsContext) { + const { + drainingResponse, + ingressForServer, + loopbackRouteAllowed, + managementIngressRouteAllowed, + packageTreeChangedResponse, + serverBusyResponse, + runAdmittedHttpTurn, + config, + inboundBodyLimitBytes, + listenPort, + liveCallBindings, + loadRemoteWorkspaceRuntime, + localAttestationSecret, + loopbackPolicy, + managementApiDeps, + managementAuth, + managementSessionControl, + packageTreeIntegrity, + readinessGate, + deps, + port, + } = ctx; + void port; + const serveOptions = { + idleTimeout: 255, + // Bun rejects an oversized body before `fetch` runs, so the listener has to be raised + // with the admission limit or the opt-in would do nothing. Fixed at bind time: a live + // `maxInboundBodyBytes` edit needs a restart, which the config doc states. + maxRequestBodySize: inboundBodyLimitBytes, + async fetch(req: Request, requestServer: Server): Promise { + const ingress = ingressForServer(requestServer); + // The unauthenticated loopback listener (#1102) serves a fixed allowlist and nothing + // else. Rejecting here, before any handler runs, is what keeps the surface from growing + // silently when a route is added below. + if (ingress === "unauthenticated-loopback" && !loopbackRouteAllowed(codexCompatibleUrl(req.url), req)) { + return withCors( + formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${new URL(req.url).pathname}`), + req, + loopbackPolicy(), + ); + } + // Tailscale Serve terminates only on this separately bound loopback socket. Reject before + // dispatch so no data, readiness, health, WebSocket, or unknown-static handler can run. + if (ingress === "hub-management" && !managementIngressRouteAllowed(codexCompatibleUrl(req.url), req)) { + return withCors( + formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${new URL(req.url).pathname}`), + req, + config, + ); + } + // Auth and CORS decisions below read `policy`, not `config`. For the public listener the + // two are the same object, so its behaviour is unchanged; for the loopback listener the + // view substitutes 127.0.0.1 as the bind address, which is what routes it through the + // same code path a plain loopback bind has always taken — Host-header check included. + // Routing, provider selection and response bodies keep using `config`. + const policy: RequestPolicyView = ingress === "unauthenticated-loopback" ? loopbackPolicy() : config; + const url = codexCompatibleUrl(req.url); + markActivity(`${req.method} ${url.pathname}`); + + // Readiness is exact-GET on the literal /readyz path. Compare the DECODED + // pathname so an encoded variant like /readyz%2F (which decodes to + // /readyz/) cannot bypass the exact-path rejection and reach the GUI + // fallback (serveGuiFile decodes the pathname and would serve index.html + // with 200). Malformed percent-sequences fall back to the raw pathname, + // which still cannot match the exact literal below. + let readyzPath: string | undefined; + try { + const decoded = decodeURIComponent(url.pathname); + if (decoded === "/readyz" || decoded === "/readyz/") readyzPath = decoded; + } catch { /* malformed encoding — not a readiness path */ } + + const packageTreeStatus = packageTreeIntegrity.status(); + if (!packageTreeStatus.ok && ( + url.pathname === "/healthz" + || readyzPath !== undefined + || url.pathname.startsWith("/v1/") + )) { + const message = "OpenCodex package files changed while this proxy was running; restart OpenCodex before retrying."; + const response = url.pathname === "/healthz" || readyzPath !== undefined + ? jsonResponse({ + status: "restart_required", + service: "opencodex", + version: VERSION, + uptime: process.uptime(), + pid: process.pid, + port: ctx.boundPort ?? requestServer.port ?? listenPort, + error: { code: "package_tree_changed", message }, + }, 503, req, policy) + : packageTreeChangedResponse(req, policy, message); + const headers = new Headers(response.headers); + headers.set("Retry-After", "5"); + return new Response(response.body, { status: 503, headers }); + } + + if (req.method === "OPTIONS") { + // /readyz is exact-GET only; OPTIONS (like POST and the trailing-slash + // path) must answer the deterministic JSON 404, never the generic 204 + // preflight response that the SPA fallback would otherwise allow. + if (readyzPath !== undefined) { + return withCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, policy); + } + const managementPreflight = url.pathname.startsWith("/api/"); + const allowed = managementPreflight + ? isAllowedManagementOrigin(req, config) + : isAllowedRequestOrigin(req, policy); + if (!allowed) { + return new Response(null, { status: 403, headers: corsHeaders() }); + } + return new Response(null, { + status: 204, + headers: managementPreflight ? managementCorsHeaders(req, config) : corsHeaders(req, policy), + }); + } + + // An OCX-only executor exchanges one short-lived pairing code for a device-scoped + // token. This is intentionally outside /api: management auth belongs to the browser + // that created the grant, while the new device owns only that one-time code. + if (url.pathname === "/remote-workspace/pair" && req.method === "POST") { + if (!remoteWorkspaceEnabled(config)) { + return Response.json({ error: "Remote Workspace is not enabled on this OpenCodex instance." }, { status: 404 }); + } + // Browser JavaScript must use the authenticated dashboard route. Refusing Origin-bearing + // requests leaves this exchange to an explicit OCX device process and avoids turning a + // copied pairing code into a cross-site enrollment action. + if (req.headers.get("origin") !== null) { + return Response.json({ error: "Remote Workspace device pairing does not accept browser-origin requests." }, { + status: 403, + headers: { "cache-control": "no-store" }, + }); + } + const [{ remoteWorkspaceHubForConfig }, { RemoteWorkspacePairingRateLimitError }] = await Promise.all([ + loadRemoteWorkspaceRuntime(), + import("../../remote-control/workspace-hub"), + ]); + if (ctx.remoteWorkspaceStopping) return Response.json({ error: "Remote Workspace is stopping." }, { status: 503 }); + const hub = deps.managementApi?.remoteWorkspaceHub ?? remoteWorkspaceHubForConfig(config); + // A loopback socket alone cannot prove that Tailscale Serve supplied its identity header: + // another local process can connect directly and forge it. Pairing therefore uses only the + // kernel-observed peer on every listener; proxied management users intentionally share the + // loopback bucket rather than gaining a header-rotation bypass. + const peer = requestServer.requestIP(req)?.address ?? "unknown"; + const pairingSource = `${ingress}:${peer}`; + const rateLimitResponse = (error: unknown): Response | null => { + if (!(error instanceof RemoteWorkspacePairingRateLimitError)) return null; + return Response.json({ error: "Remote Workspace pairing is temporarily rate limited." }, { + status: 429, + headers: { + "cache-control": "no-store", + "retry-after": String(error.retryAfterSeconds), + }, + }); + }; + try { + // Check the existing source block before reading or parsing an attacker-controlled body. + // pairDevice checks again after the await and records only code-shaped authentication + // failures, so malformed JSON cannot allocate one limiter entry per request. + hub.assertPairingSourceAllowed(pairingSource); + } catch (error) { + const limited = rateLimitResponse(error); + if (limited) return limited; + throw error; + } + const declaredLength = Number(req.headers.get("content-length") ?? "0"); + if (!Number.isFinite(declaredLength) || declaredLength > REMOTE_WORKSPACE_PAIRING_BODY_LIMIT) { + return Response.json({ error: "Remote Workspace pairing body is too large." }, { status: 413 }); + } + const text = await readBoundedRequestText(req, REMOTE_WORKSPACE_PAIRING_BODY_LIMIT); + if (text === null) return Response.json({ error: "Remote Workspace pairing body is too large." }, { status: 413 }); + if (ctx.remoteWorkspaceStopping) return Response.json({ error: "Remote Workspace is stopping." }, { status: 503 }); + let body: unknown; + try { body = JSON.parse(text); } + catch { return Response.json({ error: "Invalid Remote Workspace pairing request." }, { status: 400 }); } + if (!body || typeof body !== "object" || Array.isArray(body)) { + return Response.json({ error: "Invalid Remote Workspace pairing request." }, { status: 400 }); + } + const record = body as Record; + const required = ["code", "name", "platform", "publicKey", "roots"]; + const allowed = new Set([...required, "capabilities"]); + if (required.some(key => !Object.hasOwn(record, key)) + || Object.keys(record).some(key => !allowed.has(key))) { + return Response.json({ error: "Invalid Remote Workspace pairing request." }, { status: 400 }); + } + try { + const paired = hub.pairDevice(record, pairingSource); + return Response.json(paired, { status: 201, headers: { "cache-control": "no-store" } }); + } catch (error) { + const limited = rateLimitResponse(error); + if (limited) return limited; + const message = error instanceof Error ? error.message : "Remote Workspace pairing failed."; + const conflict = /already in use|limit reached/i.test(message); + return Response.json({ error: message }, { + status: conflict ? 409 : 401, + headers: { "cache-control": "no-store" }, + }); + } + } + + // Each executor holds one device-scoped bearer and opens one outbound WSS. The token is + // authenticated only at upgrade and never enters ws.data; subsequent frames are bound to + // the device identity and per-session signed E2EE handshake. + if (url.pathname === "/remote-workspace/agent" && req.headers.get("upgrade")?.toLowerCase() === "websocket") { + if (!remoteWorkspaceEnabled(config) || req.headers.get("origin") !== null) { + return Response.json({ error: "Remote Workspace agent upgrade refused." }, { status: 403 }); + } + const authorization = req.headers.get("authorization") ?? ""; + const match = /^Bearer (ocxrw_[A-Za-z0-9_-]{43})$/.exec(authorization); + if (!match) return Response.json({ error: "Remote Workspace device authentication required." }, { status: 401 }); + const { remoteWorkspaceHubForConfig } = await loadRemoteWorkspaceRuntime(); + const { RemoteWorkspaceHubAgentConnection } = await import("../../remote-control/workspace-agent-connection"); + if (ctx.remoteWorkspaceStopping) return Response.json({ error: "Remote Workspace is stopping." }, { status: 503 }); + const hub = deps.managementApi?.remoteWorkspaceHub ?? remoteWorkspaceHubForConfig(config); + const device = hub.authenticateDeviceToken(match[1]!); + if (!device) return Response.json({ error: "Remote Workspace device authentication failed." }, { status: 401 }); + const upgraded = requestServer.upgrade(req, { + data: { + kind: "remote-workspace-agent", + remoteWorkspaceOpen: socket => { + const connection = new RemoteWorkspaceHubAgentConnection({ + deviceId: device.id, + devicePublicKey: device.publicKey, + hubIdentity: hub.identity(), + capabilities: device.capabilities, + onCapabilities: capabilities => hub.updateDeviceCapabilities(device.id, capabilities), + socket: { + send: value => { + if (socket.send(value) === 0) throw new Error("remote workspace socket send dropped"); + }, + close: (code, reason) => socket.close(code, reason), + }, + }); + hub.attachConnection(device.id, connection); + socket.data.remoteWorkspaceClose = () => hub.detachConnection(device.id, connection); + return connection; + }, + } satisfies WsData, + }); + return upgraded + ? undefined as unknown as Response + : Response.json({ error: "Remote Workspace WebSocket upgrade failed." }, { status: 426 }); + } + + // Responses WebSocket (phase 120.2). Codex upgrades the same /v1/responses path; auth is + // handshake-time only, so capture inbound headers and thread them into the pipeline. + if (url.pathname === "/v1/responses" && req.headers.get("upgrade")?.toLowerCase() === "websocket") { + if (isDraining()) { + return drainingResponse(req, policy); + } + const admission = resolveResponsesApiAuth(req, policy); + if (!admission) { + return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy); + } + if (!isAllowedRequestOrigin(req, policy)) { + return withCors(formatErrorResponse(403, "origin_rejected", "WebSocket upgrade blocked: non-local Origin"), req, policy); + } + // WS transport gate: Codex's built-in `openai` provider hardcodes supports_websockets=true, + // so under Design B it always tries the WS transport first. When the feature is off, reject + // the upgrade with 426 — codex-rs maps a connect-time UPGRADE_REQUIRED to a clean + // session-scoped HTTP fallback (client.rs WebsocketStreamOutcome::FallbackToHttp) instead of + // surfacing broken-pipe errors from sockets a "disabled" feature would otherwise accept. + if (!websocketsEnabled(config)) { + return withCors(formatErrorResponse(426, "upgrade_required", "Responses WebSocket transport is disabled; use HTTP"), req, policy); + } + const websocketLease = tryReserveCodexWebSocket(); + if (!websocketLease) return serverBusyResponse(req, "Codex WebSockets", policy); + // Upgrade on the server that RECEIVED this request, not the captured `server` + // binding. They are the same object for the public listener, but the + // unauthenticated loopback listener (#1102) is a second Bun.serve, and handing its + // request to the public server's upgrade would fail or cross sockets. + if (requestServer.upgrade(req, { + data: buildResponsesWsData( + selectForwardHeaders(req.headers), + admission, + websocketLease, + sessionLaneIdFromRequest(req.headers), + ), + })) return undefined as unknown as Response; + websocketLease.release(); + return withCors(formatErrorResponse(426, "upgrade_required", "WebSocket upgrade failed"), req, policy); + } + + if (url.pathname === "/healthz" && req.method === "GET") { + // service/pid/port let CLI liveness reject foreign 200s and verify pid identity. + const healthPort = ctx.server.port ?? listenPort; + const response = jsonResponse({ + status: "ok", + service: "opencodex", + version: VERSION, + uptime: process.uptime(), + pid: process.pid, + port: healthPort, + restartCapability: SYSTEM_RESTART_CAPABILITY_VERSION, + providerReloadCapability: LOCAL_PROVIDER_RELOAD_CAPABILITY_VERSION, + guiPairCapability: GUI_PAIR_CAPABILITY_VERSION, + }, 200, req, policy); + const challenge = req.headers.get(LOCAL_ATTESTATION_CHALLENGE_HEADER); + if (challenge) { + const proof = createLocalAttestationProof(localAttestationSecret, challenge, process.pid, healthPort); + if (proof) response.headers.set(LOCAL_ATTESTATION_PROOF_HEADER, proof); + } + return response; + } + + // Readiness: like /healthz this is exact GET and unauthenticated (so a client can + // back off BEFORE knowing the admission token), but stricter than liveness. The + // body carries only sanitized identity + the fixed status enum; the sync message, + // warning text, catalog path, provider output, and account data are never exposed. + // POST or "/readyz/" must NOT match (exact pathname + GET method): answer them + // with a JSON 404 here so they can never be silently accepted by the GUI SPA + // fallback (which would serve index.html with HTTP 200 once gui/dist exists). + if (readyzPath !== undefined) { + if (readyzPath !== "/readyz" || req.method !== "GET") { + return withCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, policy); + } + // A draining proxy must never advertise ready: every data-plane branch + // answers drainingResponse while isDraining() is set, but the one-shot + // readiness gate is not mutated on shutdown (it is owned by the startup + // sync). Report pending so `ocx ready --wait` and external supervisors + // keep polling instead of promoting a proxy that is draining. + const status = isDraining() ? "pending" : readinessGate.getStatus(); + const body = { + service: "opencodex", + version: VERSION, + uptime: process.uptime(), + pid: process.pid, + port: ctx.boundPort ?? listenPort, + status, + ...readyProtocolMetadata(config, req), + }; + if (status === "ready") { + return jsonResponse(body, 200, req, policy); + } + // Pending/failed: 503 with a conservative Retry-After so well-behaved clients + // (and `ocx ready --wait`) back off instead of hot-looping. + const resp = jsonResponse(body, 503, req, policy); + const headers = new Headers(resp.headers); + headers.set("Retry-After", "1"); + return new Response(resp.body, { status: 503, headers }); + } + + if (url.pathname.startsWith("/api/")) { + const localManagementAuth = { + attestationSecret: localAttestationSecret, + pid: process.pid, + port: ctx.boundPort ?? requestServer.port ?? listenPort, + }; + const apiAuthError = requireManagementAuth(req, managementAuth, config, localManagementAuth); + if (apiAuthError) return withManagementCors(apiAuthError, req, config); + // Which credential passed the gate, resolved from the same session table the + // gate used. Consent-bearing routes need this: request headers are forgeable + // by anything holding the admin token, the credential is not. + const principal = managementPrincipal(req, managementAuth, config, localManagementAuth) ?? undefined; + if (url.pathname === GUI_PAIR_PATH) { + if (req.method !== "POST" || principal !== "gui-pair-capability" || !managementAuth.available) { + return withManagementCors(Response.json({ error: "GUI pairing capability required" }, { status: 403 }), req, config); + } + try { + const grant = createGuiPairingGrant( + req.headers.get(GUI_PAIR_BROWSER_ORIGIN_HEADER) ?? "", + config, + managementAuth, + ); + return withManagementCors(Response.json(grant, { + status: 201, + headers: { "Cache-Control": "no-store" }, + }), req, config); + } catch (error) { + const status = error instanceof GuiPairingGrantRateLimitError ? 429 : 403; + return withManagementCors(Response.json({ error: "GUI pairing grant refused" }, { + status, + ...(status === 429 ? { headers: { "Retry-After": "60" } } : {}), + }), req, config); + } + } + const mgmtResponse = await handleManagementAPI(req, url, config, managementApiDeps, principal, managementSessionControl); + if (mgmtResponse) return withManagementCors(mgmtResponse, req, config); + return withManagementCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, config); + } + + if (url.pathname === "/v1/catalog" && (req.method === "GET" || req.method === "HEAD")) { + // #809: remote Codex clients need the model catalog, and the only prior source was + // GET /api/catalog behind management auth — so operators had to hand out an admin + // token to read a list of models. This route fixes that on the data plane instead of + // widening /api/*, which stays exactly as restricted as before. + // + // resolveApiAuth (not resolveResponsesApiAuth) for the same reason /v1/models uses + // it: nothing here forwards a caller credential upstream, so accepting the dedicated + // header, a recognized bearer, or x-api-key is safe — and rejecting x-api-key would + // 401 Anthropic-SDK clients holding a perfectly valid data credential. + const admission = resolveApiAuth(req, policy); + if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy); + if (!isAllowedRequestOrigin(req, policy)) { + return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy); + } + const { serializePersistedCatalog, persistedCodexVersion, MAX_REMOTE_CATALOG_BYTES } = await import("../catalog-download"); + const serialized = await serializePersistedCatalog(); + if (serialized.body === null) { + // Built directly rather than through formatErrorResponse: that helper derives + // `code` from the status and message via classifyError, and these two need stable, + // specific codes. `catalog_not_found` in particular is what lets a caller — and + // tests/server/api-key-attribution.test.ts — tell "this route exists and has no catalog" + // apart from "this route is gone", which is the difference between admission proof + // and a vacuous pass. + return withCors( + new Response(JSON.stringify({ + error: { type: "invalid_request_error", code: "catalog_not_found", message: "no materialized catalog is available" }, + }), { + status: 404, + headers: { "content-type": "application/json" }, + }), + req, + policy, + ); + } + // Size policy belongs to this route, not the shared serializer: the management route + // must keep its existing behavior for a catalog of any supported size. + if (serialized.bytes !== undefined && serialized.bytes > MAX_REMOTE_CATALOG_BYTES) { + return withCors( + new Response(JSON.stringify({ + error: { type: "server_error", code: "catalog_too_large", message: "catalog exceeds the maximum served size" }, + }), { + status: 507, + headers: { "content-type": "application/json" }, + }), + req, + policy, + ); + } + const headers: Record = { + "content-type": "application/json", + // Identity-varying content behind a credential: never let a shared cache keep it, + // and never hand out a validator it could revalidate with. `no-cache` alone does + // not prevent storage — it forces revalidation, and the revalidation is exactly + // what would cross identities here, because this body varies by key type and key + // id while the ETag would be derived from bytes alone. A store keyed on URL plus + // validator could then serve one credential's representation to another. Proving + // an identity-partitioned cache key across every intermediary in the path is a + // much larger commitment than the bandwidth a 304 saves on this payload, so this + // route declines the trade: no-store, no ETag, no 304. + // + // GET /api/catalog keeps its validator. That route is management-authenticated + // and loopback-scoped, and its representation does not vary by data-key identity. + "cache-control": "no-store", + }; + const version = await persistedCodexVersion(); + if (version) headers["x-opencodex-codex-version"] = version; + // No conditional handling: with no validator emitted, an If-None-Match on this route + // can only have been guessed or copied from elsewhere, and honoring it would + // reintroduce the cross-identity path above. Every request gets the full body. + if (serialized.bytes !== undefined) headers["content-length"] = String(serialized.bytes); + // HEAD returns identical status and headers with no body. + return withRemoteCatalogKeyId( + withCors( + new Response(req.method === "HEAD" ? null : serialized.body, { status: 200, headers }), + req, + policy, + ), + admission, + ); + } + + if (url.pathname === "/v1/usage" && req.method === "GET") { + const { handleHubUsage } = await import("../hub-usage"); + return handleHubUsage(req, config, policy); + } + + if (url.pathname === "/v1/hub-state" && (req.method === "GET" || req.method === "HEAD")) { + // #4236: a connected client had no way to learn which providers this hub can actually + // serve, so `ocx status` on the client reported the CLIENT's empty credential store as + // if it were the truth — "xai ✗ not logged in" on a machine whose hub has xAI logged + // in. The fix is one least-privilege data-plane read, in the /v1/catalog (#809) + // tradition: same admission resolver, same origin check, no parameters, no caller + // credential forwarded upstream, and a body of booleans plus model ids. Widening + // `/api/*` or handing the client an admin token to read `GET /api/providers` would + // have traded a reporting defect for a credential one. + // + // What it discloses beyond /v1/catalog and /v1/models, exactly: `hasCredential`, + // `loggedIn`, `authMode`, the featured roster, and the NAME and adapter of an ENABLED + // provider those routes omit for want of a usable credential — which is the point of + // the route. A `disabled` provider is NOT exported (`buildHubState` drops it), because + // the catalog filters it out too and naming it here would be the only place a data key + // learns of it. + // + // Placed between /v1/catalog and /v1/models so all three least-privilege client reads + // stay in sight of each other. + const admission = resolveApiAuth(req, policy); + if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy); + if (!isAllowedRequestOrigin(req, policy)) { + return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy); + } + // Role gate AFTER admission, deliberately: answering an unauthenticated caller would + // turn this into a free "is that machine a hub?" probe. A standalone or client install + // gains no surface at all — the route simply does not exist there. + // + // Built, not formatErrorResponse'd, for the same reason /v1/catalog builds its 404: the + // code has to distinguish "this route exists and this host is not a hub" from "this + // build has no such route", which is the difference between admission proof and a + // vacuous pass in tests/server/api-key-attribution.test.ts. + if (config.runtimeRole !== "hub") { + return withCors( + new Response(JSON.stringify({ + error: { + type: "invalid_request_error", + code: "hub_state_not_a_hub", + message: "hub state is served only by a host whose runtimeRole is hub", + }, + }), { status: 404, headers: { "content-type": "application/json" } }), + req, + policy, + ); + } + const { buildHubState } = await import("../hub-state"); + const { MAX_HUB_STATE_BYTES } = await import("../../remote/hub-state"); + const { oauthLoginSummary } = await import("../../oauth"); + // `true` masks emails, but the projection drops the field entirely; passing the mask + // anyway means a future refactor that starts copying fields cannot leak a raw address. + const body = JSON.stringify(buildHubState(config, oauthLoginSummary(true), VERSION)); + const bytes = Buffer.byteLength(body); + if (bytes > MAX_HUB_STATE_BYTES) { + return withCors( + new Response(JSON.stringify({ + error: { type: "server_error", code: "hub_state_too_large", message: "hub state exceeds the maximum served size" }, + }), { status: 507, headers: { "content-type": "application/json" } }), + req, + policy, + ); + } + return withCors( + new Response(req.method === "HEAD" ? null : body, { + status: 200, + headers: { + "content-type": "application/json", + // Varies by credential-bearing identity and by live login state: never cached, + // and no validator to revalidate with (same rule as /v1/catalog). + "cache-control": "no-store", + "content-length": String(bytes), + }, + }), + req, + policy, + ); + } + + if (url.pathname === "/v1/models" && req.method === "GET") { + // #809: the catalog read sits immediately before model discovery because it shares + // that route's admission rationale exactly. Keep them adjacent so a future change to + // one is made in sight of the other. + // Model discovery never forwards Authorization upstream, so the broader admission + // set (Authorization / x-api-key / x-opencodex-api-key) is safe here and required by + // remote OpenAI-style bearer clients and Claude gateway discovery (anthropic-version). + const admission = resolveApiAuth(req, policy); + if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy); + if (!isAllowedRequestOrigin(req, policy)) { + return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy); + } + const wantsDesktopConfig = url.searchParams.get("format") === "desktop-config"; + if (wantsDesktopConfig && (url.searchParams.get("ids") === "cli" || url.searchParams.has("client_version"))) { + return jsonResponse({ error: "Desktop config format cannot use CLI or client-version selectors" }, 400, req, policy); + } + // The Integrations page reports whether a Cursor client has reached this proxy; the + // recorder keeps only a bounded User-Agent value and a timestamp, in memory. + recordCursorSeen(req.headers); + let goModels; + let modelEntitlements; + try { + [goModels, modelEntitlements] = await Promise.all([ + fetchAllModels(config), + // Codex sends its own client_version on this request, and upstream filters the + // entitlement roster by it. Passing it through is what stops an entitled account + // being told it cannot use models a newer client can (#2886). + resolveCodexModelEntitlements(config, { clientVersion: url.searchParams.get("client_version") }), + ]); + } catch (error) { + if (error instanceof CatalogGatherBusyError) { + return withCors(new Response(JSON.stringify({ error: { type: "server_error", code: "catalog_busy", message: error.message } }), { + status: 503, + headers: { "content-type": "application/json", "Retry-After": "1" }, + }), req, policy); + } + throw error; + } + const { accountBoundNativeOpenAiSlugsBySelector, applyNativeVisibility, buildCatalogEntries, configuredNativeAliasSlugs, desktopAllowlistSuppressedNativeSlugs, disabledNativeSlugs, exactComboCatalogSlugs, loadCatalogTemplate, NATIVE_OPENAI_MODELS, nativeContextLimits, nativeInputModalities, nativeOpenAiContextWindow, nativeOpenAiMaxOutputTokens, nativeOpenAiContextTier, nativeOpenAiSlugs, nativeReasoningEfforts, nativeDefaultReasoningEffort, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, uniqueCatalogModelsForRawPublicList, visibleCodexAccountSelectors, visibleNativeSlugs, desktopVisibleNativeSlugs } = await import("../../codex/catalog"); + const { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } = await import("../../codex/catalog/native-models"); + const includeNativeOpenAi = shouldIncludeNativeOpenAi(config); + const includeAccountBoundNativeOpenAi = shouldIncludeAccountBoundNativeOpenAi(config); + const bareEligibleAccountIds = providerCodexAccountMode( + OPENAI_CODEX_PROVIDER_ID, + config.providers[OPENAI_CODEX_PROVIDER_ID], + ) === "direct" ? new Set([MAIN_CODEX_ACCOUNT_ID]) : undefined; + const availableBareGatedNativeSlugs = availableAccountGatedNativeModels( + modelEntitlements, + bareEligibleAccountIds, + ); + const availableAccountGatedNativeSlugs = availableAccountGatedNativeModels(modelEntitlements); + const availableBareNativeSlugs = NATIVE_OPENAI_MODELS.filter(slug => ( + !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || availableBareGatedNativeSlugs.has(slug) + )); + const availableAccountNativeSlugs = NATIVE_OPENAI_MODELS.filter(slug => ( + !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || availableAccountGatedNativeSlugs.has(slug) + )); + const nativeSlugs = includeNativeOpenAi + ? nativeOpenAiSlugs().filter(slug => ( + !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || availableBareGatedNativeSlugs.has(slug) + )) + : []; + const disabledNatives = disabledNativeSlugs(config); + const disabledModels = new Set(config.disabledModels ?? []); + const exactComboSlugs = exactComboCatalogSlugs(config); + const shadowedNativeSlugs = configuredNativeAliasSlugs(config); + const suppressedBareNativeSlugs = new Set([ + ...desktopAllowlistSuppressedNativeSlugs(config), + ...[...ACCOUNT_GATED_NATIVE_OPENAI_MODELS].filter(slug => !availableBareGatedNativeSlugs.has(slug)), + ]); + const accountSelectors = includeAccountBoundNativeOpenAi + ? visibleCodexAccountSelectors(config) + : []; + const accountTargets = new Map(codexAccountNamespaceEntries(config)); + const accountNativeSlugsBySelector = includeAccountBoundNativeOpenAi + ? new Map([...accountBoundNativeOpenAiSlugsBySelector(config)].map(([selector, slugs]) => { + const target = accountTargets.get(selector); + const accountId = target && isMainCodexAccountTarget(target) ? MAIN_CODEX_ACCOUNT_ID : target; + return [selector, slugs.filter(slug => ( + !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) + || (accountId !== undefined + && codexModelEntitlementStateForAccount(modelEntitlements, accountId, slug) === "granted") + ))] as const; + })) + : new Map(); + const accountNativeSlugs = [...new Set( + [...accountNativeSlugsBySelector.values()].flatMap(slugs => [...slugs]), + )]; + const desktopInputs = buildDesktopDiscoveryInputs({ + config, models: goModels, modelEntitlements, + desktopNativeCandidates: desktopVisibleNativeSlugs(config), + }); + const desktopNativeSlugs = desktopInputs.nativeSlugs; + const goOrdered = desktopInputs.routedModels; + // Claude Code / Claude Desktop gateway model discovery (GET /v1/models with + // Anthropic-style headers; 003 G1-G8 + devlog 131). Entries use the official + // ModelInfo shape incl. capabilities (effort ladder / thinking) — Desktop 3P can + // only learn capabilities through discovery, and Claude Code 2.1.207 strips the + // extra fields (backward-safe). Ids are the claude-opus-4-8-{code} Desktop + // aliases; legacy claude-ocx-* ids keep decoding via resolveAlias. Detection: + // anthropic-version header (Claude Code sends it) or explicit ?flavor=anthropic. + // Codex catalog (client_version) and the OpenAI list shape below stay byte-identical. + const wantsAnthropicList = wantsDesktopConfig || req.headers.get("anthropic-version") !== null + || url.searchParams.get("flavor") === "anthropic"; + /** + * Whether a NATIVE slug may carry a Fast sibling. + * + * Both halves are required. Upstream asserts the tier per model — the same + * `additional_speed_tiers` the Codex picker's own toggle is built from — but an + * operator capability override or the final wire resolution can still make the + * route ineligible, and `decideTier` would then drop the tier the row advertised. + * + * Declared here, above the Claude discovery call, because that call reads it while + * the raw OpenAI mapper further down does too; defining it there would leave this + * use in its temporal dead zone. + */ + const nativeFastEligible = (metadataId: string): boolean => + catalogFastRowEligible(config, { provider: OPENAI_CODEX_PROVIDER_ID, id: metadataId, native: true }); + + /** + * Whether a routed catalog row may carry a Fast sibling. + * + * A combo is its own namespace with no `config.providers` entry — declaring a + * provider named `combo` is rejected (combos/types.ts:191) — so provider lookup + * cannot classify it. Its aggregated `supportsServiceTier` is already true only + * when EVERY member supports the tier (aggregation.ts:201), which is the right + * rule for a row that fans out to all of them. + * + * Declared beside nativeFastEligible, above the Claude discovery call that reads + * both; defining it near the raw OpenAI mapper below would leave that use in its + * temporal dead zone. + */ + const catalogRowFastEligible = (m: { provider: string; id: string; supportsServiceTier?: boolean }): boolean => + catalogFastRowEligible(config, m); + + if (wantsAnthropicList && !url.searchParams.has("client_version")) { + if (wantsDesktopConfig) { + const models = config.claudeCode?.enabled === false ? [] : generateDesktop3pModels( + desktopInputs.nativeSlugs, desktopInputs.routedModels, + config.claudeCode?.desktopProfile, desktopInputs.nativeContextCap, + ); + const response = jsonResponse({ version: 1, models }, 200, req, policy); + response.headers.set("Cache-Control", "no-store"); + return response; + } + if (config.claudeCode?.enabled === false) return jsonResponse({ data: [] }, 200, req, policy); + // Build Desktop 3P registry so inbound alias resolution works for subsequent requests. + buildDesktop3pRegistry( + desktopNativeSlugs, + desktopInputs.routedModels, + config.claudeCode?.desktopProfile, + desktopInputs.nativeContextCap, + ); + const { buildAnthropicModelInfos } = await import("../../claude/model-info"); + const { resolveAutoContext } = await import("../../claude/context-windows"); + const { activeDesktop3pAlias } = await import("../../claude/desktop-3p"); + // Per-surface id family (devlog 050): explicit ?ids= wins; otherwise the + // Claude Code CLI discovery UA (`claude-code/`, binary n_()) gets + // readable claude-ocx ids and every other client (Desktop 3P) keeps the + // hashed family its config was written with. Unknown UA -> hashed (safe). + const idsParam = url.searchParams.get("ids"); + const idStyle = idsParam === "cli" + ? "readable" as const + : idsParam === "desktop" + ? "desktop3p" as const + : (/^claude-code\//i.test(req.headers.get("user-agent") ?? "") ? "readable" as const : "desktop3p" as const); + const data = buildAnthropicModelInfos( + desktopNativeSlugs, + goOrdered, + resolveAutoContext(config.claudeCode), + idStyle, + activeDesktop3pAlias, + desktopInputs.nativeContextCap, + config.fastMode, + // Explicit opt-out omits the Fast predicate. + config.fastRows !== false + ? (model: { provider: string; id: string; supportsServiceTier?: boolean }) => + model.provider === "native" + ? nativeFastEligible(model.id) + : catalogRowFastEligible(model) + : undefined, + { modelPickerOrder: config.modelPickerOrder, featured: config.subagentModels }, + ); + return jsonResponse({ data }, 200, req, policy); + } + if (url.searchParams.has("client_version")) { + // Codex client → Codex catalog shape: native gpt + namespaced routed models, + // cloned from a native template so required fields (base_instructions, etc.) are present. + // Pass the subagent picks so featured models lead by priority (matches the on-disk file). + // Disabled natives stay in the catalog shape with visibility "hide" (mirrors the + // on-disk sync; codex-rs keeps them out of the picker itself). + const maMode = config.multiAgentMode === "v1" || config.multiAgentMode === "v2" ? config.multiAgentMode : "default"; + // Account rows use the same hidden-inclusive supported set as on-disk sync. This lets a + // newly re-enabled native reappear under each selector before the next sync, while the + // no-selector path keeps nativeOpenAiSlugs()'s existing visibility-sensitive behavior. + const catalogNativeSlugs = accountSelectors.length > 0 + ? [...new Set([ + ...availableAccountNativeSlugs, + ...accountNativeSlugs, + ])] + : nativeSlugs; + const entries = buildCatalogEntries( + loadCatalogTemplate(), + catalogNativeSlugs, + goOrdered, + config.subagentModels, + websocketsEnabled(config), + maMode as "v1" | "default" | "v2", + exactComboSlugs, + accountSelectors, + suppressedBareNativeSlugs, + new Set(), + nativeContextLimits(config), + accountNativeSlugs, + accountNativeSlugsBySelector, + config.keepNativeChatGptOnV1 === true, + config.modelPickerOrder, + ); + return jsonResponse({ + models: applyNativeVisibility( + entries, + disabledModels, + accountSelectors.length > 0, + new Set(accountNativeSlugs), + ), + }, 200, req, policy); + } + // OpenAI list shape: native gpt bare + routed models namespaced "/" + // (pure availability list — disabled natives are omitted entirely). + // Grok Build discovers models through this endpoint too, and its model picker only + // enables /effort for entries that advertise the reasoning ladder in the Grok model + // catalog shape (supports_reasoning_effort + reasoning_efforts[]). The Codex catalog + // branch above already carries the same ladders, so mirror them here — native rows + // from the upstream snapshot, routed rows from the configured provider tiers. The + // default uses the same canonical fallback as the Codex catalog resolver + // (configured default, then medium, then high, then the first tier). Extra fields + // are ignored by plain OpenAI clients. + const grokEffortOption = (value: string, isDefault: boolean) => ({ + value, + label: `${value[0].toUpperCase()}${value.slice(1)} Effort`, + ...(isDefault ? { default: true } : {}), + }); + const grokEffortFields = (efforts: string[], configuredDefault?: string) => { + const defaultEffort = grokDefaultReasoningEffort(efforts, configuredDefault); + if (defaultEffort === undefined) return {}; + return { + supports_reasoning_effort: true, + reasoning_effort: defaultEffort, + reasoning_efforts: efforts.map(effort => grokEffortOption(effort, effort === defaultEffort)), + }; + }; + // Cursor's local-agent runtime (Private Inference build) reads api_types + capabilities + // to enable its effort control; every other consumer ignores them. See + // src/server/models-capabilities.ts. + const nativeLimits = nativeContextLimits(config); + const nativeContextInput = (metadataId: string) => { + const tier = nativeOpenAiContextTier(metadataId, nativeLimits); + return tier + ? { contextWindow: tier.defaultWindow, longContextWindow: tier.longWindow } + : { contextWindow: nativeOpenAiContextWindow(metadataId, nativeLimits) }; + }; + const nativeModelRow = (id: string, metadataId = id) => ({ + id, + object: "model", + created: 0, + owned_by: "openai", + ...grokEffortFields( + nativeReasoningEfforts(metadataId), + nativeDefaultReasoningEffort(metadataId), + ), + ...modelCapabilityFields({ + reasoningEfforts: nativeReasoningEfforts(metadataId), + // Cursor "Max Mode": advertise the family's default/long pair (272k/922k for + // GPT-5.6) so the client can pick per request; without a tier, the effective + // window is the only value. + ...nativeContextInput(metadataId), + maxOutputTokens: nativeOpenAiMaxOutputTokens(metadataId), + inputModalities: nativeInputModalities(metadataId), + }), + }); + // Resolved once per request, not per model: the global fast switch offers the fast + // identity to clients that have no Fast toggle of their own. Null when the switch is + // off, so the row mapper does no work and loads no adapter module. + const cursorFastIdForListing = config.fastMode === true + ? await (async () => { + const { cursorFastIdFor } = await import("../../adapters/cursor/catalog"); + return (modelId: string, provider = "cursor") => provider === "cursor" ? cursorFastIdFor(modelId) : undefined; + })() + : null; + // Selector-active discovery follows the same complete supported set as the Codex catalog + // for both bare and qualified rows. Without selectors, the live catalog continues to own + // bare availability. + const selectorNativeSlugs = accountSelectors.length > 0 + ? availableBareNativeSlugs.filter(slug => !disabledNatives.has(slug)) + : []; + const bareSelectorNativeSlugs = accountSelectors.length > 0 + ? selectorNativeSlugs + : []; + const visibleNatives = includeNativeOpenAi + ? accountSelectors.length > 0 + ? bareSelectorNativeSlugs.filter(slug => !shadowedNativeSlugs.has(slug)) + : visibleNativeSlugs(config) + : []; + const visibleAccountNatives = accountSelectors.flatMap(selector => + (accountNativeSlugsBySelector.get(selector) ?? []).filter(metadataId => !disabledNatives.has(metadataId)).flatMap(metadataId => { + const id = `${selector}/${metadataId}`; + return disabledModels.has(id) ? [] : [{ id, metadataId }]; + }) + ); + // The projection is opt-in. Keep the default path free of Cursor install detection, + // and resolve the bundle table once for the whole list rather than once per row. + const effortRowsEnabled = config.cursorEffortRows === true; + // Explicit opt-out skips policy resolution and additional rows. + const fastRowsEnabled = config.fastRows !== false; + // One inventory serves both grammars; building it twice would double the work on a + // hot path for no benefit. + const effortRowKnownIds = effortRowsEnabled || fastRowsEnabled + ? knownEffortRowIds(config) + : undefined; + const privateInference = effortRowsEnabled + ? detectCursorInstalls().find(install => install.build === "private-inference") + : undefined; + const cursorEffortTable = effortRowsEnabled + ? (deps.managementApi?.loadCursorEffortTable ?? loadCursorEffortTable)(privateInference) + : null; + const expandedNativeModelRow = (id: string, metadataId = id) => { + const reasoningEfforts = nativeReasoningEfforts(metadataId); + return expandCursorEffortRow(nativeModelRow(id, metadataId), reasoningEfforts, config, { + knownIds: effortRowKnownIds, + table: cursorEffortTable, + supportsReasoning: reasoningEfforts.length > 0, + }).flatMap(row => expandFastRow( + row, + // Only the BASE row earns a fast sibling. An effort row already spent the + // grammar, and the parser requires the stripped base to be routable, so + // `----fast` would publish a row no ingress can resolve. + row.id === id && nativeFastEligible(metadataId), + config, + effortRowKnownIds, + )); + }; + const routedRows = await Promise.all(uniqueCatalogModelsForRawPublicList(goOrdered).map(async m => { + // Same rule as the anthropic branch: with the global fast switch on, a client + // that has no Fast toggle is offered the fast identity directly. An operator + // alias is an explicit decision and still wins. + const fastModelId = cursorFastIdForListing?.(m.id, m.provider); + const publicId = m.alias ?? `${m.provider}/${fastModelId ?? m.id}`; + const isCombo = m.provider === "combo" && exactComboSlugs.has(publicId); + const provider = config.providers[m.provider]; + const effective = provider + ? (await import("../../providers/default-aliases")).effectiveModelAliases( + config, + provider, + knownModelIdsForProvider(m.provider, provider, config), + ).get(m.id) + : undefined; + const row = { + id: publicId, + object: "model", + created: 0, + // This endpoint is an OpenAI-compatible inbound contract. Some clients use + // owned_by as an adapter selector, so a virtual combo must name that wire + // adapter rather than the internal catalog authority marker. + owned_by: isCombo ? "openai" : (m.owned_by ?? m.provider), + ...(isCombo ? { is_combo: true } : {}), + ...(effective ? { alias_of: `${provider?.alias || m.provider}/${effective.alias}` } : {}), + ...grokEffortFields(m.reasoningEfforts ?? [], m.defaultReasoningEffort), + ...modelCapabilityFields({ + reasoningEfforts: m.reasoningEfforts, + // contextWindow is already the post-cap effective value; contextCap is the raw + // operator knob and over-reports models whose real window sits below it. + contextWindow: m.contextWindow, + maxOutputTokens: m.maxOutputTokens, + inputModalities: m.inputModalities, + }), + }; + return expandCursorEffortRow(row, m.reasoningEfforts, config, { + knownIds: effortRowKnownIds, + table: cursorEffortTable, + supportsReasoning: (m.reasoningEfforts ?? []).length > 0, + }).flatMap(expanded => expandFastRow( + expanded, + expanded.id === row.id && catalogRowFastEligible(m), + config, + effortRowKnownIds, + )); + })); + const data = [ + ...visibleNatives.flatMap(id => expandedNativeModelRow(id)), + ...visibleAccountNatives.flatMap(({ id, metadataId }) => expandedNativeModelRow(id, metadataId)), + ...routedRows.flat(), + ]; + return jsonResponse({ object: "list", data }, 200, req, policy); + } + + // Remote compaction v1 (codex-rs with Feature::RemoteCompactionV2 off — the default). + // Must be matched BEFORE the /v1/responses POST branch never sees it (distinct path) and + // before the /v1/* 404 guard below. + if (url.pathname === "/v1/responses/compact" && req.method === "POST") { + if (isDraining()) { + return drainingResponse(req, policy); + } + const admission = resolveResponsesApiAuth(req, policy); + if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy); + if (!isAllowedRequestOrigin(req, policy)) { + return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy); + } + const start = Date.now(); + const requestId = nextRequestLogId(start); + const logCtx: RequestLogContext = { + model: "unknown", + provider: "unknown", + ...admissionFields(admission), + inboundProtocol: "responses", + }; + return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => { + let response: Response; + try { + response = await handleResponsesCompact(req, config, logCtx, turnAdmissionLease, admission, { + onRequestBodyRead: () => disableResponsesRequestTimeout(req, requestServer), + }); + } catch { + response = formatErrorResponse(500, "server_error", "Unexpected compact request failure"); + } + addFinalRequestLog(requestId, start, logCtx, response.status, + response.status === 499 ? { closeReason: "client_cancel" } : undefined); + return withCors(response, req, policy); + }, { requestId, start, logCtx }); + } + + if ( + req.method === "POST" + && (url.pathname === "/v1/images/generations" || url.pathname === "/v1/images/edits") + ) { + disableResponsesRequestTimeout(req, requestServer); + if (isDraining()) { + return drainingResponse(req, policy); + } + const admission = resolveApiAuth(req, policy); + if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy); + if (!isAllowedRequestOrigin(req, policy)) { + return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy); + } + const start = Date.now(); + const requestId = nextRequestLogId(start); + const logCtx: RequestLogContext = { + model: "image_gen", + provider: "unknown", + ...admissionFields(admission), + }; + const endpoint = url.pathname.endsWith("/edits") ? "edits" as const : "generations" as const; + return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => { + const response = await handleImages(req, config, endpoint, logCtx, turnAdmissionLease); + addFinalRequestLog(requestId, start, logCtx, response.status, response.status === 499 ? { closeReason: "client_cancel" } : undefined); + return withCors(response, req, policy); + }, { requestId, start, logCtx }); + } + + if (req.method === "GET" && url.pathname.startsWith("/v1/opencodex/artifacts/")) { + const admission = resolveApiAuth(req, policy); + if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy); + if (!isAllowedRequestOrigin(req, policy)) { + return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy); + } + const id = decodeURIComponent(url.pathname.slice("/v1/opencodex/artifacts/".length)); + const { resolveArtifactPath } = await import("../../images/artifacts"); + const artifactPath = resolveArtifactPath(id); + if (!artifactPath) { + return withCors(formatErrorResponse(404, "not_found", "artifact not found"), req, policy); + } + const file = Bun.file(artifactPath); + const ext = artifactPath.split(".").pop()?.toLowerCase(); + const contentType = + ext === "png" ? "image/png" + : ext === "jpg" || ext === "jpeg" ? "image/jpeg" + : ext === "webp" ? "image/webp" + : ext === "gif" ? "image/gif" + : "application/octet-stream"; + return withCors(new Response(file, { + status: 200, + headers: { + "content-type": contentType, + "cache-control": "private, max-age=3600", + "x-content-type-options": "nosniff", + }, + }), req, policy); + } + + if (contextEndpoint(url.pathname) !== undefined && req.method === "POST" && contextRelayActivated()) { + // No timeout disable here. The relay is a bounded JSON round trip that owns one deadline + // from entry; removing the idle timeout first would let an unfinished body hold an + // admitted turn slot indefinitely, before that deadline ever starts. + if (isDraining()) { + return drainingResponse(req, policy); + } + const admission = resolveApiAuth(req, policy); + if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy); + if (!isAllowedRequestOrigin(req, policy)) { + return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy); + } + const start = Date.now(); + const requestId = nextRequestLogId(start); + const logCtx: RequestLogContext = { + model: "context_history", + provider: "unknown", + ...admissionFields(admission), + }; + return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => { + const response = await handleContextHistory(req, config, logCtx, contextEndpoint(url.pathname)!, + turnAdmissionLease, admission, () => resolveApiAuth(req, policy)); + addFinalRequestLog(requestId, start, logCtx, response.status, + response.status === 499 ? { closeReason: "client_cancel" } : undefined); + return withCors(response, req, policy); + }, { requestId, start, logCtx }); + } + + if (url.pathname === "/v1/alpha/search" && req.method === "POST") { + disableResponsesRequestTimeout(req, requestServer); + if (isDraining()) { + return drainingResponse(req, policy); + } + const admission = resolveApiAuth(req, policy); + if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy); + if (!isAllowedRequestOrigin(req, policy)) { + return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy); + } + const start = Date.now(); + const requestId = nextRequestLogId(start); + const logCtx: RequestLogContext = { + model: "web_search", + provider: "unknown", + ...admissionFields(admission), + }; + return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => { + const response = await handleSearch(req, config, logCtx, turnAdmissionLease, admission); + addFinalRequestLog(requestId, start, logCtx, response.status, + response.status === 499 ? { closeReason: "client_cancel" } : undefined); + return withCors(response, req, policy); + }, { requestId, start, logCtx }); + } + + if (url.pathname === "/v1/responses" && req.method === "POST") { + if (isDraining()) { + return drainingResponse(req, policy); + } + const admission = resolveResponsesApiAuth(req, policy); + if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy); + if (!isAllowedRequestOrigin(req, policy)) { + return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy); + } + const start = Date.now(); + const requestId = nextRequestLogId(start); + const logCtx: RequestLogContext = { + model: "unknown", + provider: "unknown", + ...admissionFields(admission), + inboundProtocol: "responses", + }; + if (req.headers.get("x-opencodex-grok") === "1") logCtx.surface = "grok"; + let logged = false; + const finalizeNativePassthroughLog = ( + status: number, + meta: { terminalStatus?: ResponsesTerminalStatus; closeReason: "terminal" | "client_cancel" }, + ) => { + if (logged) return; + logged = true; + addFinalRequestLog(requestId, start, logCtx, status, meta); + }; + return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => { + const response = await handleResponses(req, config, logCtx, { + turnAdmissionLease, + admission, + onRequestBodyRead: () => disableResponsesRequestTimeout(req, requestServer), + abortSignal: req.signal, + onFirstOutput: () => recordFirstOutput(logCtx, start), + onNativePassthroughTerminal: status => { + finalizeNativePassthroughLog(httpStatusForRequestLogTerminal(status, logCtx), { + terminalStatus: status, + closeReason: "terminal", + }); + }, + onNativePassthroughCancel: () => { + finalizeNativePassthroughLog(499, { closeReason: "client_cancel" }); + }, + }); + return withRequestLogId( + withCors(responseWithDeferredRequestLog(response, requestId, start, logCtx), req, policy), + requestId, + ); + }, { requestId, start, logCtx }); + } + + // Anthropic Messages inbound (Claude Code). count_tokens FIRST (longer path). + // Claude Code posts `/v1/messages?beta=true` — pathname match ignores the query (003 G9). + if (url.pathname === "/v1/messages/count_tokens" && req.method === "POST") { + if (isDraining()) { + return drainingResponse(req, policy); + } + const admission = resolveApiAuth(req, policy); + if (!admission) { + return withCors(anthropicErrorResponse(401, "opencodex API key required", "authentication_error"), req, policy); + } + if (!isAllowedRequestOrigin(req, policy)) { + return withCors(anthropicErrorResponse(403, "cross-origin data-plane request blocked", "permission_error"), req, policy); + } + return runAdmittedHttpTurn(req, policy, async () => withCors( + await handleClaudeCountTokens(req, config, policy), + req, + policy, + )); + } + + if (url.pathname === "/v1/messages" && req.method === "POST") { + disableResponsesRequestTimeout(req, requestServer); + if (isDraining()) { + return drainingResponse(req, policy); + } + const admission = resolveApiAuth(req, policy); + if (!admission) { + return withCors(anthropicErrorResponse(401, "opencodex API key required", "authentication_error"), req, policy); + } + if (!isAllowedRequestOrigin(req, policy)) { + return withCors(anthropicErrorResponse(403, "cross-origin data-plane request blocked", "permission_error"), req, policy); + } + const start = Date.now(); + const requestId = nextRequestLogId(start); + const logCtx: RequestLogContext = { + model: "unknown", + provider: "unknown", + ...admissionFields(admission), + inboundProtocol: "messages", + }; + // Logging is finalized inside handleClaudeMessages (Responses-vocab tap on the + // pre-translation stream + native passthrough callbacks) — do not re-wrap the + // translated Anthropic stream here. + return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => withCors( + await handleClaudeMessages(req, config, logCtx, { requestId, start, turnAdmissionLease, admission }, policy), + req, + policy, + ), { requestId, start, logCtx }); + } + + // OpenAI Chat Completions inbound (GitHub Copilot App / OpenAI-compatible clients). + if (url.pathname === "/v1/chat/completions" && req.method === "POST") { + disableResponsesRequestTimeout(req, requestServer); + if (isDraining()) { + return drainingResponse(req, policy); + } + const admission = resolveResponsesApiAuth(req, policy); + if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy); + if (!isAllowedRequestOrigin(req, policy)) { + return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy); + } + const start = Date.now(); + const requestId = nextRequestLogId(start); + const logCtx: RequestLogContext = { + model: "unknown", + provider: "unknown", + ...admissionFields(admission), + inboundProtocol: "chat", + }; + // `policy`, not `config`: this route is now served on the unauthenticated loopback + // listener too (#4236), and only the receiving listener's view produces CORS headers + // that match the admission decision made above. + return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => withCors( + await handleChatCompletions(req, config, logCtx, { requestId, start, turnAdmissionLease, admission }), + req, + policy, + ), { requestId, start, logCtx }); + } + + if (url.pathname === "/v1/audio/transcriptions" && req.method === "POST") { + disableResponsesRequestTimeout(req, requestServer); + if (isDraining()) return drainingResponse(req, policy); + const admission = resolveAudioAdmission(req.headers, config); + if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy); + if (!isAllowedRequestOrigin(req, policy)) { + return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin audio request blocked"), req, policy); + } + const start = Date.now(); + const requestId = nextRequestLogId(start); + const logCtx: RequestLogContext = { model: TRANSCRIPTION_MODEL, provider: "unknown", ...admissionFields(admission) }; + return runAdmittedHttpTurn(req, policy, async lease => { + const response = await handleAudioTranscriptions(req, config, logCtx, admission, lease); + addFinalRequestLog(requestId, start, logCtx, response.status); + return withCors(response, req, policy); + }, { requestId, start, logCtx }); + } + + // ChatGPT / Codex App voice (GPT‑Live / Frameless Bidi) + OpenAI Realtime call-create. + // Clients hit either /v1/live (Frameless App) or /v1/realtime/calls (codex RealtimeCallClient / + // public Realtime API). Sideband WS joins are handled just below. + if ( + req.method === "POST" + && (url.pathname === "/v1/live" || url.pathname === "/v1/realtime/calls") + ) { + disableResponsesRequestTimeout(req, requestServer); + if (isDraining()) { + return drainingResponse(req, policy); + } + const audioClient = resolveAudioClient(req, config); + if (audioClient instanceof Response) return withCors(audioClient, req, policy); + const admission = audioClient?.admission ?? resolveApiAuth(req, policy); + if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy); + if (!isAllowedRequestOrigin(req, policy)) { + return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy); + } + const start = Date.now(); + const requestId = nextRequestLogId(start); + const logCtx: RequestLogContext = { + model: "gpt-live", + provider: "unknown", + ...admissionFields(admission), + }; + return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => { + const response = audioClient + ? await handleExternalLive(req, config, logCtx, { client: audioClient, lease: turnAdmissionLease, bindings: liveCallBindings }) + : await handleLive(req, config, logCtx, turnAdmissionLease); + addFinalRequestLog( + requestId, + start, + logCtx, + response.status, + response.status === 499 ? { closeReason: "client_cancel" } : undefined, + ); + return withCors(response, req, policy); + }, { requestId, start, logCtx }); + } + + // Voice / Realtime WebSocket relay. Sideband joins: Frameless /v1/live/{callId}; + // Realtime v1 /v1/realtime?call_id= (or /v1/realtime/calls/{callId}). Standalone + // sessions (codex-rs thread/realtime/start, WebSocket transport — the desktop voice + // path): /v1/realtime?intent=quicksilver&model= and /v1/live?model=. + // Transparent bidirectional relay. + const liveSidebandTarget = req.headers.get("upgrade")?.toLowerCase() === "websocket" + ? parseLiveSidebandTarget(url.pathname, url.searchParams, url.search.replace(/^\?/, "")) + : null; + const dictationSocket = url.pathname === "/v1/audio/transcriptions/stream" + && req.headers.get("upgrade")?.toLowerCase() === "websocket"; + if (liveSidebandTarget || dictationSocket) { + if (isDraining()) { + return drainingResponse(req, policy); + } + const audioClient = resolveAudioClient(req, config, dictationSocket); + if (audioClient instanceof Response) return withCors(audioClient, req, policy); + if (!audioClient && liveSidebandTarget && "callId" in liveSidebandTarget + && liveSidebandTarget.callId.startsWith(EXTERNAL_CALL_PREFIX)) { + return withCors(formatErrorResponse(401, "authentication_error", "Live call requires its creator API key"), req, policy); + } + const admission = audioClient?.admission ?? resolveApiAuth(req, policy); + if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy); + if (!isAllowedRequestOrigin(req, policy)) { + return withCors(formatErrorResponse(403, "origin_rejected", "WebSocket upgrade blocked: non-local Origin"), req, policy); + } + const start = Date.now(); + const requestId = nextRequestLogId(start); + const logCtx: RequestLogContext = { + model: "gpt-live", + provider: "unknown", + ...admissionFields(admission), + }; + const turnAdmissionLease = tryAdmitTurn(sessionLaneIdFromRequest(req.headers)); + if (!turnAdmissionLease) return serverBusyResponse(req, "active turns", policy); + const audioController = audioClient ? new AbortController() : undefined; + if (audioController) registerTurn(audioController, turnAdmissionLease); + const acquisition = audioController + ? clearableDeadline(120_000, AbortSignal.any([req.signal, audioController.signal])) : undefined; + const releaseAcquisition = () => { + acquisition?.clear(); + if (audioController) unregisterTurn(audioController); + else turnAdmissionLease.release(); + }; + let resolved; + try { + resolved = dictationSocket && audioClient + ? await resolveDictationSocket(audioClient, config, logCtx, turnAdmissionLease, acquisition?.signal) + : liveSidebandTarget && audioClient + ? await resolveExternalLiveSocket(audioClient, config, logCtx, liveSidebandTarget, { lease: turnAdmissionLease, bindings: liveCallBindings, signal: acquisition?.signal }) + : liveSidebandTarget + ? await resolveLiveSidebandUpgrade(req, config, logCtx, liveSidebandTarget, turnAdmissionLease) + : formatErrorResponse(401, "authentication_error", "opencodex API key required"); + } catch (error) { + releaseAcquisition(); + throw error; + } + if (acquisition?.signal.aborted) { + try { if (!(resolved instanceof Response) && "finish" in resolved) resolved.finish(); } + finally { releaseAcquisition(); } + return withCors(formatErrorResponse(req.signal.aborted ? 499 : acquisition.didExpire() ? 504 : 503, + "upstream_error", acquisition.didExpire() ? "Audio connection timed out" : "Audio connection canceled"), req, policy); + } + if (resolved instanceof Response) { + releaseAcquisition(); + addFinalRequestLog(requestId, start, logCtx, resolved.status); + return withCors(resolved, req, policy); + } + const audio = "finish" in resolved ? resolved : undefined; + const finish = audio ? (outcome?: number | "timeout" | "connect_error") => { + try { audio.finish(outcome); } + finally { releaseAcquisition(); } + } : undefined; + const discardUpgrade = () => { + if (finish) finish(); + else releaseAcquisition(); + }; + if (req.signal.aborted) { + discardUpgrade(); + return withCors(formatErrorResponse(499, "client_closed_request", "Audio connection canceled"), req, policy); + } + const upstreamHandshake = await openLiveSidebandUpstream( + resolved.upstreamWsUrl, + resolved.headers, + (url, headers) => (deps.liveSidebandWebSocketFactory ?? ((socketUrl, socketHeaders, protocols) => ( + new WebSocket(socketUrl, { headers: socketHeaders, protocols } as unknown as string[]) + )))(url, headers, audio?.protocols), + LIVE_SIDEBAND_UPSTREAM_OPEN_TIMEOUT_MS, + req.signal, + ); + if (!upstreamHandshake.ok) { + if (upstreamHandshake.socket) { + closeLiveSidebandBeforeUpgrade(upstreamHandshake.socket, () => discardUpgrade()); + } else { + discardUpgrade(); + } + addFinalRequestLog(requestId, start, logCtx, upstreamHandshake.status); + console.error("[live] sideband upstream handshake failed: " + upstreamHandshake.message); + return withCors( + formatErrorResponse(upstreamHandshake.status, upstreamHandshake.code, upstreamHandshake.message), + req, + policy, + ); + } + const handoffFailure = upstreamHandshake.handoff.failure(); + if (handoffFailure || upstreamHandshake.socket.readyState !== WebSocket.OPEN) { + closeLiveSidebandBeforeUpgrade(upstreamHandshake.socket, () => discardUpgrade()); + const failure = handoffFailure ?? { + status: 502, + code: "upstream_error", + message: "voice upstream closed before client upgrade", + }; + addFinalRequestLog(requestId, start, logCtx, failure.status); + return withCors(formatErrorResponse(failure.status, failure.code, failure.message), req, policy); + } + let upgraded = false; + try { + upgraded = requestServer.upgrade(req, { + ...(audioClient?.protocol ? { headers: { "sec-websocket-protocol": audioClient.protocol } } : {}), + data: { + kind: "live-sideband", + liveUpstream: upstreamHandshake.socket, + liveUpstreamUrl: resolved.upstreamWsUrl, + liveUpstreamHeaders: resolved.headers, + liveUpstreamHandoff: upstreamHandshake.handoff, + admission, + liveUpstreamProtocols: audio?.protocols, + liveValidateFrame: audio?.validateFrame, + liveMaxSessionMs: audio?.maxSessionMs, + liveFinish: finish, + liveAbortSignal: audioController?.signal, + livePending: [], + livePendingBytes: 0, + liveOpened: true, + liveTurnAdmissionLease: turnAdmissionLease, + } satisfies WsData, + }); + } catch { + try { + upstreamHandshake.handoff.take(); + } catch { + /* ignore */ + } + closeLiveSidebandBeforeUpgrade(upstreamHandshake.socket, () => discardUpgrade()); + return withCors(formatErrorResponse(502, "upstream_error", "Audio WebSocket upgrade failed"), req, policy); + } + if (upgraded) { + acquisition?.clear(); + addFinalRequestLog(requestId, start, logCtx, 101); + return undefined as unknown as Response; + } + try { + upstreamHandshake.handoff.take(); + } catch { + /* ignore */ + } + closeLiveSidebandBeforeUpgrade(upstreamHandshake.socket, () => discardUpgrade()); + return withCors(formatErrorResponse(426, "upgrade_required", "WebSocket upgrade failed"), req, policy); + } + + // Data-plane guard: unknown /v1/* paths must fail with JSON 404, never fall through to the + // GUI static handler (extensionless paths would get index.html with HTTP 200 and codex-rs + // endpoint clients — memories/*, realtime/* — would surface confusing + // serde decode errors instead of a clean not-found). + if (url.pathname.startsWith("/v1/")) { + return withCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, policy); + } + + if (url.pathname === "/opencodex-session") { + if (req.method === "GET") { + const session = issueGuiSession(req, config, managementAuth, { + trustedTailscaleIngress: ingress === "hub-management", + }); + return session + ? withManagementCors(serveSessionBootstrap(session), req, config) + : withManagementCors(new Response(null, { status: 401, headers: { "Cache-Control": "no-store" } }), req, config); + } + if (req.method === "POST") { + // This endpoint is reachable WITHOUT a credential — that is the point of a pairing + // exchange — so the body limit has to hold against a caller who controls the + // framing. A declared Content-Length is a claim, not a bound: omit the header and + // `Number(null ?? "0")` is 0, send `Transfer-Encoding: chunked` and there is no + // header at all. Both used to pass the pre-check and land in `req.text()`, which + // buffers whatever arrives. The post-check then measured a string the process had + // already been forced to hold. + // + // So the declared length is only a cheap early reject, and the real bound is + // applied while reading: stop at limit+1 bytes and never accumulate more. + const declaredLength = Number(req.headers.get("content-length") ?? "0"); + if (!Number.isFinite(declaredLength) || declaredLength > GUI_PAIRING_EXCHANGE_BODY_LIMIT) { + return withManagementCors(Response.json({ error: "pairing exchange body too large" }, { status: 413, headers: { "Cache-Control": "no-store" } }), req, config); + } + const bounded = await readBoundedRequestText(req, GUI_PAIRING_EXCHANGE_BODY_LIMIT); + if (bounded === null) { + return withManagementCors(Response.json({ error: "pairing exchange body too large" }, { status: 413, headers: { "Cache-Control": "no-store" } }), req, config); + } + const text = bounded; + let body: unknown; + try { + body = JSON.parse(text); + } catch { + return withManagementCors(Response.json({ error: "invalid pairing exchange body" }, { status: 400, headers: { "Cache-Control": "no-store" } }), req, config); + } + if (!body || typeof body !== "object" || Array.isArray(body) + || Object.keys(body as Record).length !== 1 + || typeof (body as Record).grant !== "string") { + return withManagementCors(Response.json({ error: "invalid pairing exchange body" }, { status: 400, headers: { "Cache-Control": "no-store" } }), req, config); + } + const pairing = managementAuth.available + ? consumeGuiPairingGrant(req, body, config, managementAuth, Date.now(), { + ingress: ingress === "hub-management" ? "hub-management" : "public", + peerAddress: requestServer.requestIP(req)?.address ?? null, + tailscaleUser: ingress === "hub-management" ? req.headers.get("Tailscale-User-Login") : null, + browserOrigin: req.headers.get("Origin") ?? "", + }) + : null; + if (pairing && "allowed" in pairing) { + return withManagementCors(Response.json({ error: "pairing exchange refused" }, { + status: 429, + headers: { "Cache-Control": "no-store", "Retry-After": String(pairing.retryAfterSeconds) }, + }), req, config); + } + return pairing + ? withManagementCors(serveSessionBootstrap(pairing), req, config) + : withManagementCors(new Response(null, { status: 401, headers: { "Cache-Control": "no-store" } }), req, config); + } + return withCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, policy); + } + const guiSessionCandidate = req.method === "GET" && (url.pathname === "/" || !url.pathname.includes(".")) + ? issueGuiSession(req, config, managementAuth, { + trustedTailscaleIngress: ingress === "hub-management", + }) + : null; + const guiFile = serveGuiFile( + url.pathname, + undefined, + guiSessionCandidate ?? undefined, + config.runtimeRole ?? "standalone", + isApiAuthRequired(config), + ); + if (guiFile) return guiFile; + if (url.pathname === "/" && req.method === "GET") { + return jsonResponse(rootFallbackPayload()); + } + + return withCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, config); + }, + websocket: createWebsocketHandler(ctx), + } as const; + return serveOptions; +} diff --git a/src/server/index/startup-warnings.ts b/src/server/index/startup-warnings.ts new file mode 100644 index 0000000000..1925d9c3ed --- /dev/null +++ b/src/server/index/startup-warnings.ts @@ -0,0 +1,204 @@ +import { currentServiceHomes, serviceStatePathsForOpenCodexHome } from "../../service"; +import { + createWindowsTaskListingCache, + inspectNativeCodexOwnership, + type NativeCodexOwnership, + type OwnershipInspection, +} from "../../integrations/native/ownership-preflight"; +import { registerCodexQuotaAutoRefreshWorker } from "../../codex/quota-auto-refresh"; +import { + consumeForInspection, + relaySseWithHeartbeat, + relayWithAbort, + responseWithDeferredRequestLog, + sanitizePassthroughHeaders, +} from "../relay"; +import { + assertServerAuthConfig, + corsHeaders, + managementCorsHeaders, + isAllowedRequestOrigin, + isAllowedManagementOrigin, + isApiAuthRequired, + isLoopbackHostname, + jsonResponse, + admissionFields, + resolveApiAuth, + resolveResponsesApiAuth, + requestPolicyView, + type DataPlaneAdmission, + type RequestPolicyView, + safeConfigDTO, + setCorsOrigin, + withCors, + withManagementCors, +} from "../auth-cors"; +import { + bindNativeMainStartupLifecycle, + blockNativeMainStartupForUnownedServiceHome, + prepareNativeMainStartupLifecycle, + releaseNativeMainStartupLifecycle, + type NativeMainStartupGateDeps, + type NativeMainStartupLifecycle, +} from "../../codex/native-profile-startup"; +import { fetchAllModels, handleManagementAPI, VERSION, type ManagementApiDeps } from "../management-api"; +import { + createManagementSessionControl, + initializeManagementAuthState, + issueGuiSession, + managementPrincipal, + requireManagementAuth, + type ManagementAuthState, +} from "../management-auth"; +import { createReadinessGate, type ReadinessGate } from "../readiness"; +import { + createRuntimePackageTreeIntegrityGuard, + type PackageTreeIntegrityGuard, +} from "../../lib/package-tree-integrity"; +import type { LiveSidebandWebSocketFactory } from "./live-sideband"; +import { startServer } from "../index"; + +// GUI static serving extracted to ./server/gui-static. Re-exported below to keep the +// "../src/server" import surface stable for tests/callers. + +// Adapter resolution + wire-protocol override extracted to ./server/adapter-resolve. + +// Source invariant for tests/responses/passthrough-abort.test.ts after the pure module split: +// if (isEventStream && upstreamResponse.body) { +// const repairConfig = route.provider.responsesItemIdRepair; +// const needsClientRewrite = imageGenCallAliases.size > 0 +// #314 gated shape: win32 always uses the terminal-aware eager relay so a keep-alive +// upstream cannot hold Codex open after response.completed; darwin no-rewrite traffic +// requires explicit config-eager opt-in (`auto` always stays tee on darwin). +// selectEagerPath(process.platform, needsClientRewrite, config.streamMode ?? "auto") +// Codex upstream WS runtime gating and the forced bounded single-reader branch +// are owned by responses/ws-upstream.ts and responses/core.ts respectively. +// relaySseEagerBounded(upstreamResponse.body, turnAc, +// new Response(eagerBody, +// Default shape (tee + background inspection): +// upstreamResponse.body.tee() +// const repairedBody = hasResponsesItemIdRepair(repairConfig) +// relaySseWithFailedTail(repairedBody, upstream) +// new Response(clientBody +// markNativePassthroughSseResponse +// const body = relayWithAbort(upstreamResponse.body, upstream); +// function responseWithDeferredRequestLog +// isNativePassthroughSseResponse(response) +// trackSseForRequestLog( +// export function relaySseWithHeartbeat + +const REQUEST_LOG_ID_RESPONSE_HEADER = "x-opencodex-request-id"; + +export function withRequestLogId(response: Response, requestId: string): Response { + const headers = new Headers(response.headers); + headers.set(REQUEST_LOG_ID_RESPONSE_HEADER, requestId); + // A custom `x-` header is not CORS-safelisted, so cross-origin JavaScript gets null from + // `response.headers.get()` even though the header is on the wire. Naming it here is what + // makes the id readable by a browser client — the only caller that needs a correlation id + // it did not send itself. + // + // Appending to whatever `withCors` already set, rather than overwriting, keeps this + // independent of the CORS layer: if the data plane later exposes another header, both + // survive. Duplicate names are harmless, and the header stays absent from responses that + // never reach this wrapper, so no management or rejected-origin response is widened. + const exposed = headers.get("Access-Control-Expose-Headers"); + const already = (exposed ?? "") + .split(",") + .some(name => name.trim().toLowerCase() === REQUEST_LOG_ID_RESPONSE_HEADER); + if (!already) { + headers.set( + "Access-Control-Expose-Headers", + exposed ? `${exposed}, ${REQUEST_LOG_ID_RESPONSE_HEADER}` : REQUEST_LOG_ID_RESPONSE_HEADER, + ); + } + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +} + +export interface StartServerDeps { + /** Test-only seam; production always initializes its own management credential state. */ + managementAuthState?: ManagementAuthState; + /** Test-only route dependencies, forwarded only after management admission succeeds. */ + managementApi?: ManagementApiDeps; + /** Test-only native-main recovery dependencies; production constructs the normal manager. */ + nativeMainStartup?: NativeMainStartupGateDeps; + /** Test-only ownership evidence; production inspects the installed service state. */ + inspectNativeCodexOwnership?: typeof inspectNativeCodexOwnership; + /** Test-only service-home resolver; production resolves the current homes directly. */ + resolveServiceHomes?: typeof currentServiceHomes; + /** Test-only seam for an upstream that cannot complete its WebSocket close handshake. */ + liveSidebandWebSocketFactory?: LiveSidebandWebSocketFactory; + /** Test-only seam; production derives a fresh local-attestation secret per process. */ + localAttestationSecret?: string; + /** Optional readiness gate; a fresh pending gate is created when omitted. */ + readinessGate?: ReadinessGate; + /** Test-only package-tree observation; production captures package.json identity at boot. */ + packageTreeIntegrity?: PackageTreeIntegrityGuard; + /** Test-only seam for observing quota-worker registration ownership. */ + registerCodexQuotaAutoRefreshWorker?: typeof registerCodexQuotaAutoRefreshWorker; +} + +export function inspectStartupOwnership( + deps: StartServerDeps, + currentHomes: ReturnType | null, + statePaths: readonly string[] | null, + windowsTaskListingCache?: ReturnType, +): OwnershipInspection { + try { + if (currentHomes === null || statePaths === null) { + return { + ownership: "unknown", + reason: "startup service-home resolution failed", + }; + } + if (deps.inspectNativeCodexOwnership) { + return deps.inspectNativeCodexOwnership({ currentHomes, statePaths, windowsTaskListingCache }); + } + return inspectNativeCodexOwnership({ currentHomes, statePaths, windowsTaskListingCache }); + } catch { + return { + ownership: "unknown", + reason: "service-home ownership inspection failed", + }; + } +} + +/* + * #1046. `startServer` rewrites the Codex models cache during boot, and an + * app-server that started earlier keeps its own in-memory model list. The stale + * warning is not emitted here: `handleStart` runs a catalog sync moments later, + * so warning now would read an mtime that write is about to move, and both sites + * calling the helper independently would warn twice. This records the fact; the + * CLI start path owns the single decision. + * + * A caller that starts a server without `handleStart` (tests, embedded use) + * deliberately gets no warning — lifecycle diagnostics belong to whoever owns + * the lifecycle. + */ +export let startupCacheInvalidationWrote = false; + +/** #1046: did this process's startup cache invalidation actually write? */ +export function consumeStartupCacheInvalidationWrite(): boolean { + const wrote = startupCacheInvalidationWrote; + startupCacheInvalidationWrote = false; + return wrote; +} + +export function warnAgentTaskRecoveryStartup(config: { + agentTaskRecovery?: { enabled?: boolean }; +}): void { + if (config.agentTaskRecovery?.enabled !== true) return; + console.warn("⚠️ Experimental encrypted V2 task recovery is enabled."); + console.warn(" A scoped cache miss may send an additional authenticated request to ChatGPT and may consume quota or add latency; concurrent misses can share one request."); + console.warn(" Recovered plaintext assignment data is retained only in a bounded, process-local in-memory cache; exact fidelity is not guaranteed and the path depends on undocumented backend behavior."); +} + +export function warnPlaintextV2AgentMessagesStartup(config: { plaintextV2AgentMessages?: boolean }): void { + if (config.plaintextV2AgentMessages !== true) return; + console.warn("⚠️ Experimental plaintext V2 agent messages are enabled."); + console.warn(" Eligible ChatGPT collaboration calls may carry plaintext message arguments. HTTPS remains encrypted, but task text may be retained in Codex history, selected providers, and local response/debug state."); + console.warn(" This depends on undocumented ChatGPT and Codex behavior; it does not decrypt existing tasks."); +} diff --git a/src/server/index/websocket-handler.ts b/src/server/index/websocket-handler.ts new file mode 100644 index 0000000000..f94ca32e9c --- /dev/null +++ b/src/server/index/websocket-handler.ts @@ -0,0 +1,335 @@ +import type { Server, ServerWebSocket } from "bun"; +import { + LIVE_SIDEBAND_UPSTREAM_OPEN_TIMEOUT_MS, + MAX_WS_FRAME_BYTES, + WEBSOCKET_IDLE_TIMEOUT_SECONDS, + attachLiveSidebandUpstream, + closeLiveSideband, + closeLiveSidebandBeforeUpgrade, + enqueueLiveSidebandPendingFrame, + exceedsLiveSidebandFrameByteLimit, + openLiveSidebandUpstream, + sendUpstreamFrame, + webSocketFrameBytes, +} from "./live-sideband"; +import { markActivity } from "../../lib/sidecar-tracker"; +import { + buildWarmupCompletionFrames, + buildWsErrorFrame, + selectForwardHeaders, + sendJsonFrame, + buildResponsesWsData, + sendResponseToWebSocket, + sendTextFrame, + type WsData, +} from "../ws-bridge"; +import { + CodexAccountCooldownError, + cooldownErrorMessage, +} from "../../codex/auth-context"; +import { codexAccountNamespaceForModel } from "../../codex/account-namespace-match"; +import { + registerCodexWebSocket, + tryReserveCodexWebSocket, + unregisterCodexWebSocket, + updateCodexWebSocketAuthContext, +} from "../../codex/websocket-registry"; +import { + formatErrorResponse, + type ResponsesTerminalStatus, +} from "../../bridge"; +import { + isDraining, + registerTurn, + tryAdmitTurn, + unregisterTurn, + type ActiveTurnLease, +} from "../lifecycle"; +import { + addFinalRequestLog, + httpStatusForRequestLogTerminal, + inspectResponseLogSsePayload, + nextRequestLogId, + recordFirstOutput, + type RequestLogContext, + type RequestLogEntry, +} from "../request-log"; +import { + corsHeaders, + managementCorsHeaders, + isAllowedRequestOrigin, + isAllowedManagementOrigin, + isApiAuthRequired, + jsonResponse, + admissionFields, + resolveApiAuth, + resolveResponsesApiAuth, + type RequestPolicyView, + withCors, + withManagementCors, +} from "../auth-cors"; +import { + disableResponsesRequestTimeout, + handleResponses, + handleResponsesCompact, +} from "../responses"; +import { + handleLive, + logLiveSidebandFrame, + parseLiveSidebandTarget, + resolveLiveSidebandUpgrade, +} from "../live"; +import type { ServeOptionsContext } from "./serve-options"; + +/** + * The WebSocket half of the Bun.serve options, split out of serve-options.ts to keep that file + * under the 2,000-line ratchet threshold. The body is the original handler verbatim; it reads the + * same startServer context the HTTP half does, so it takes the same context object. + */ +export function createWebsocketHandler(ctx: ServeOptionsContext) { + const { config, deps } = ctx; + return { + maxPayloadLength: MAX_WS_FRAME_BYTES, + idleTimeout: WEBSOCKET_IDLE_TIMEOUT_SECONDS, + // Responses WebSocket data plane (phase 120.2). Re-frames the same SSE pipeline onto the + // socket: parse response.create → run handleResponses unchanged → pump its SSE body as WS + // Text frames. response.processed is a no-op ack. close() aborts the upstream (RC2 parity). + // Live sideband sockets (kind=live-sideband) are a transparent bidirectional relay instead. + open(ws: ServerWebSocket) { + if (ws.data.kind === "remote-workspace-agent") { + const open = ws.data.remoteWorkspaceOpen; + if (!open) { + ws.close(1011, "remote workspace connection unavailable"); + return; + } + try { + ws.data.remoteWorkspaceConnection = open(ws); + } catch { + ws.close(1011, "remote workspace connection failed"); + } + return; + } + if (ws.data.kind === "live-sideband") { + if (!ws.data.liveTurnAdmissionLease) { + closeLiveSideband(ws, 1013, "server busy"); + return; + } + attachLiveSidebandUpstream(ws, deps.liveSidebandWebSocketFactory); + return; + } + if (!ws.data.admissionLease) { + ws.close(1013, "server busy"); + return; + } + ws.data.admissionLease.bind(ws); + registerCodexWebSocket(ws); + }, + message(ws: ServerWebSocket, raw: string | Buffer) { + if (ws.data.kind === "remote-workspace-agent") { + try { + ws.data.remoteWorkspaceConnection?.receive(raw); + } catch { + ws.close(1008, "remote workspace protocol error"); + } + return; + } + if (ws.data.kind === "live-sideband") { + if (ws.data.liveClosing) return; + if (ws.data.liveValidateFrame && !ws.data.liveValidateFrame(raw)) { + closeLiveSideband(ws, 1008, "invalid audio event"); + return; + } + const rawBytes = webSocketFrameBytes(raw); + if (exceedsLiveSidebandFrameByteLimit(rawBytes)) { + closeLiveSideband(ws, 1009, "message too large"); + return; + } + logLiveSidebandFrame("c2u", raw); + const upstream = ws.data.liveUpstream; + if (!upstream || upstream.readyState === WebSocket.CONNECTING || !ws.data.liveOpened) { + const enqueueResult = enqueueLiveSidebandPendingFrame(ws.data, raw, rawBytes); + if (enqueueResult === "too-many-frames") { + closeLiveSideband(ws, 1009, "too many pending frames"); + return; + } + if (enqueueResult === "too-many-bytes") { + closeLiveSideband(ws, 1009, "too many pending bytes"); + return; + } + return; + } + if (upstream.readyState !== WebSocket.OPEN) { + closeLiveSideband(ws, 1011, "upstream not open"); + return; + } + try { + sendUpstreamFrame(upstream, raw); + if (ws.data.liveMaxSessionMs !== undefined && upstream.bufferedAmount > MAX_WS_FRAME_BYTES) { + closeLiveSideband(ws, 1013, "audio upstream backpressure"); + } + } catch { + closeLiveSideband(ws, 1011, "upstream send failed"); + } + return; + } + const rawBytes = typeof raw === "string" ? Buffer.byteLength(raw) : raw.byteLength; + if (rawBytes > MAX_WS_FRAME_BYTES) { + sendJsonFrame(ws, buildWsErrorFrame(413, { + type: "invalid_request_error", + message: "WebSocket response.create frame is too large", + })); + ws.close(1009, "message too large"); + return; + } + let frame: Record; + try { + frame = JSON.parse(typeof raw === "string" ? raw : raw.toString()) as Record; + } catch { + return; // text-only contract; ignore unparseable frames + } + if (frame.type === "response.processed") return; // ack — no-op + if (frame.type !== "response.create") return; + markActivity("ws response.create"); + + ws.data.cancel?.(); + const turnId = (ws.data.turnId ?? 0) + 1; + ws.data.turnId = turnId; + const isCurrent = () => ws.data.turnId === turnId; + const turnAbort = new AbortController(); + const cancelTurn = () => { + turnAbort.abort("websocket turn superseded or closed"); + }; + ws.data.cancel = cancelTurn; + // A socket may carry several response.create frames. Clear the previous + // account before resolving this frame so a failed Multi resolution cannot + // leave stale invalidation ownership behind. + updateCodexWebSocketAuthContext(ws, undefined); + + if (frame.generate === false) { + for (const payload of buildWarmupCompletionFrames(frame)) { + if (!isCurrent()) return; + sendTextFrame(ws, payload); + } + if (ws.data.cancel === cancelTurn) ws.data.cancel = undefined; + return; + } + + const turnAdmissionLease = tryAdmitTurn(ws.data.sessionLaneId); + if (!turnAdmissionLease) { + sendJsonFrame(ws, buildWsErrorFrame(503, { + type: "server_error", + code: "server_busy", + message: "active turns capacity reached", + retryable: true, + }, new Headers({ "Retry-After": "1" }))); + if (ws.data.cancel === cancelTurn) ws.data.cancel = undefined; + return; + } + + const payload: Record = { ...frame }; + delete payload.type; + turnAdmissionLease.bindAbortController(turnAbort); + void (async () => { + const start = Date.now(); + const requestId = nextRequestLogId(start); + // Resolved once at the handshake — a frame has no request headers left + // to re-resolve from. Optional on WsData like every other member, so + // narrow rather than assume: an unattributed frame is preferable to a + // fabricated attribution. + const wsAdmission = ws.data.admission; + const logCtx: RequestLogContext = { + model: "unknown", + provider: "unknown", + ...(wsAdmission ? admissionFields(wsAdmission) : {}), + inboundProtocol: "responses", + }; + let logged = false; + const finalizeLog = ( + status: number, + meta?: Pick, + ) => { + if (logged) return; + logged = true; + addFinalRequestLog(requestId, start, logCtx, status, meta); + }; + const baseHeaders = ws.data.headers ?? new Headers(); + const fwd = new Headers({ "content-type": "application/json" }); + baseHeaders.forEach((value, key) => fwd.set(key, value)); + const req = new Request("http://localhost/v1/responses", { + method: "POST", + headers: fwd, + body: JSON.stringify({ ...payload, stream: true }), + }); + try { + let terminalRecorder: ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined; + const response = await handleResponses(req, config, logCtx, { + ...(wsAdmission ? { admission: wsAdmission } : {}), + forceEmptyResponseId: true, + inboundTransport: "websocket", + abortSignal: turnAbort.signal, + turnAdmissionLease, + onFirstOutput: () => recordFirstOutput(logCtx, start), + onCodexAuthContextResolved: context => updateCodexWebSocketAuthContext(ws, context), + recordTerminalOutcomes: false, + setTerminalOutcomeRecorder: recorder => { + terminalRecorder = recorder; + }, + }); + await sendResponseToWebSocket(ws, response, isCurrent, { + onSsePayload: payload => inspectResponseLogSsePayload(logCtx, payload), + onTerminal: status => { + terminalRecorder?.(status, logCtx.terminalHttpStatus); + finalizeLog(httpStatusForRequestLogTerminal(status, logCtx), { + terminalStatus: status, + closeReason: "terminal", + }); + }, + }); + if (!logged) finalizeLog(turnAbort.signal.aborted ? 499 : response.status); + } catch (err) { + if (!isCurrent()) return; + try { + if (err instanceof CodexAccountCooldownError) { + finalizeLog(429); + // Codex Desktop rides this WS transport, so it must carry the same + // actionable text as HTTP; a frame has no headers, hence message-only. + const accountSelector = typeof payload.model === "string" + ? codexAccountNamespaceForModel(config.codexAccountNamespaces, payload.model) + : undefined; + sendJsonFrame(ws, buildWsErrorFrame(429, { + type: "rate_limit_error", + message: cooldownErrorMessage(err, accountSelector), + })); + return; + } + finalizeLog(502); + sendJsonFrame(ws, buildWsErrorFrame(502, { + type: "proxy_error", + message: err instanceof Error ? err.message : String(err), + })); + } catch { + /* socket already gone or send dropped */ + } + } finally { + turnAdmissionLease.release(); + if (!logged && turnAbort.signal.aborted) finalizeLog(499); + if (ws.data.cancel === cancelTurn) ws.data.cancel = undefined; + } + })(); + }, + close(ws: ServerWebSocket) { + if (ws.data.kind === "remote-workspace-agent") { + ws.data.remoteWorkspaceClose?.(); + return; + } + if (ws.data.kind === "live-sideband") { + closeLiveSideband(ws); + return; + } + unregisterCodexWebSocket(ws); + ws.data.admissionLease?.release(); + ws.data.admissionLease = undefined; + ws.data.cancel?.(); // RC2: abort the upstream when the client disconnects + }, + } as const; +} diff --git a/tests/codex-integration/model-visibility-management-api.test.ts b/tests/codex-integration/model-visibility-management-api.test.ts index 9fef0adefe..e730b144bc 100644 --- a/tests/codex-integration/model-visibility-management-api.test.ts +++ b/tests/codex-integration/model-visibility-management-api.test.ts @@ -69,7 +69,9 @@ async function put(body: unknown): Promise { describe("atomic model visibility management", () => { test("catalog busy maps management and v1 models to 503 startup to warn-skip and system-env to skip", async () => { const management = await Bun.file(new URL("../../src/server/management-api.ts", import.meta.url)).text(); - const server = await Bun.file(new URL("../../src/server/index.ts", import.meta.url)).text(); + // The catalog-busy mapping moved into the serve-options leaf when src/server/index.ts + // became a facade. + const server = await Bun.file(new URL("../../src/server/index/serve-options.ts", import.meta.url)).text(); const prewarm = await Bun.file(new URL("../../src/cli/catalog-prewarm.ts", import.meta.url)).text(); const systemEnv = await Bun.file(new URL("../../src/server/system-env.ts", import.meta.url)).text(); for (const source of [management, server]) { diff --git a/tests/fixtures/file-size-baseline.json b/tests/fixtures/file-size-baseline.json index 3cdd09cc96..74142babf4 100644 --- a/tests/fixtures/file-size-baseline.json +++ b/tests/fixtures/file-size-baseline.json @@ -24,8 +24,8 @@ "src/codex/catalog/provider-fetch.ts": 54, "src/config.ts": 460, "src/providers/registry.ts": 232, - "src/server/index.ts": 3400, - "src/server/responses/core.ts": 9387, + "src/server/index.ts": 898, + "src/server/responses/core.ts": 9386, "tests/ci-workflows/ci-workflows.test.ts": 5628, "tests/cli/cli-account.test.ts": 2313, "tests/codex-integration/codex-auth-api.test.ts": 6549, diff --git a/tests/lib/workflow-budget.test.ts b/tests/lib/workflow-budget.test.ts index 196b125d49..ca8b2ebad1 100644 --- a/tests/lib/workflow-budget.test.ts +++ b/tests/lib/workflow-budget.test.ts @@ -471,7 +471,14 @@ describe("a refusal an operator can read, name and clear (#4546)", () => { // at all, and the fix first reached only one of nine. Exposing the header was likewise // pointless until the refusal was CORS-wrapped, because without an allow-origin a browser // cannot read an exposed header either. - const source = await Bun.file(repoPath("src/server/index.ts")).text(); + // src/server/index.ts is a facade now. The runAdmittedHttpTurn call sites live in the + // serve-options leaf while withCors(workflowRefusalResponse( stayed in the composition + // root, so read both. Reading the facade alone would find no call site and the + // "more than one surface" assertion would pass on an empty match array. + const source = [ + await Bun.file(repoPath("src/server/index.ts")).text(), + await Bun.file(repoPath("src/server/index/serve-options.ts")).text(), + ].join("\n"); const callSites = source.match(/return runAdmittedHttpTurn\(/g) ?? []; const threaded = source.match(/, \{ requestId, start, logCtx \}\);/g) ?? []; expect(callSites.length).toBeGreaterThan(1); diff --git a/tests/responses/ws-endpoint.test.ts b/tests/responses/ws-endpoint.test.ts index 9770530e26..46de26fdd7 100644 --- a/tests/responses/ws-endpoint.test.ts +++ b/tests/responses/ws-endpoint.test.ts @@ -37,9 +37,20 @@ function sseStream(frames: string[], onCancel?: () => void): ReadableStream { test("server config declares explicit websocket idle timeout policy", () => { - const source = readFileSync(new URL("../../src/server/index.ts", import.meta.url), "utf8"); + // src/server/index.ts is a facade now. The idle-timeout constant moved to the + // live-sideband leaf, the handler body to the websocket-handler leaf, and the wiring + // stayed in serve-options, so read all four. The one assertion whose SHAPE changed is + // the handler block: it used to be an inline "websocket: {" object and is now a factory + // call, so it is pinned in its new form. The invariant is unchanged -- the serve options + // declare an explicit websocket idle timeout rather than inheriting a default. + const source = [ + "src/server/index.ts", + "src/server/index/live-sideband.ts", + "src/server/index/serve-options.ts", + "src/server/index/websocket-handler.ts", + ].map(rel => readFileSync(new URL("../../" + rel, import.meta.url), "utf8")).join("\n"); expect(source).toContain("const WEBSOCKET_IDLE_TIMEOUT_SECONDS = 0;"); - expect(source).toContain("websocket: {"); + expect(source).toContain("websocket: createWebsocketHandler(ctx),"); expect(source).toContain("idleTimeout: WEBSOCKET_IDLE_TIMEOUT_SECONDS,"); expect(source).toContain("finalizeLog(httpStatusForRequestLogTerminal(status, logCtx), {"); expect(source).toContain("if (!logged) finalizeLog(turnAbort.signal.aborted ? 499 : response.status);"); diff --git a/tests/server/loopback-listener-admission.test.ts b/tests/server/loopback-listener-admission.test.ts index 8065b80bbe..81b0b612a2 100644 --- a/tests/server/loopback-listener-admission.test.ts +++ b/tests/server/loopback-listener-admission.test.ts @@ -61,7 +61,10 @@ describe("loopback listener policy view", () => { }); test("both Anthropic routes finish CORS with the listener-effective policy", () => { - const source = readFileSync(new URL("../../src/server/index.ts", import.meta.url), "utf8"); + // The route branches moved into the serve-options leaf when src/server/index.ts became a + // facade. Reading the facade would leave every indexOf at -1 and the slices empty, so the + // toContain checks below would pass on empty strings. + const source = readFileSync(new URL("../../src/server/index/serve-options.ts", import.meta.url), "utf8"); const countTokensStart = source.indexOf('url.pathname === "/v1/messages/count_tokens"'); const messagesStart = source.indexOf('url.pathname === "/v1/messages"', countTokensStart + 1); const chatStart = source.indexOf('url.pathname === "/v1/chat/completions"', messagesStart); From eabe985f4f3cb76383acaee2db91c8a580c5ddfc Mon Sep 17 00:00:00 2001 From: lidge-jun Date: Tue, 15 Sep 2026 12:02:43 +0900 Subject: [PATCH 2/6] fix(server): break the startup-warnings import cycle and repoint the chat-wire oracle Two defects the first push of this split carried, both found by verification rather than by reading the diff. startup-warnings.ts imported `startServer` back from the facade. Nothing in that leaf uses it: the only occurrence is the word `startServer` inside a JSDoc paragraph. The codemod that generated the leaf headers treated a comment mention as a use, so it emitted the import, and that made the facade and the leaf a value-level cycle. Importing the leaf then pulled a partially initialised server graph, which is why suites with no connection to src/server/index.ts went red. The import is removed; the comment is untouched. tests/server/loopback-listener-admission.test.ts has a third oracle in it, "the chat wire finishes CORS with the receiving listener's policy", that reads the describe-level source and searches for the /v1/chat/completions and /v1/live route branches. Both moved into the serve-options leaf, so indexOf returned -1, the slice was empty, and the CORS assertions would have passed while checking nothing. The describe-level read now concatenates the facade and the leaf, which is what the allowlist tests in the same block and this one respectively need. --- src/server/index/startup-warnings.ts | 1 - tests/server/loopback-listener-admission.test.ts | 10 +++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/server/index/startup-warnings.ts b/src/server/index/startup-warnings.ts index 1925d9c3ed..7323f54140 100644 --- a/src/server/index/startup-warnings.ts +++ b/src/server/index/startup-warnings.ts @@ -56,7 +56,6 @@ import { type PackageTreeIntegrityGuard, } from "../../lib/package-tree-integrity"; import type { LiveSidebandWebSocketFactory } from "./live-sideband"; -import { startServer } from "../index"; // GUI static serving extracted to ./server/gui-static. Re-exported below to keep the // "../src/server" import surface stable for tests/callers. diff --git a/tests/server/loopback-listener-admission.test.ts b/tests/server/loopback-listener-admission.test.ts index 81b0b612a2..48d7a366be 100644 --- a/tests/server/loopback-listener-admission.test.ts +++ b/tests/server/loopback-listener-admission.test.ts @@ -92,7 +92,15 @@ describe("loopback listener policy view", () => { }); describe("local client inference wires on the loopback listener (#4236)", () => { - const source = readFileSync(new URL("../../src/server/index.ts", import.meta.url), "utf8"); + // src/server/index.ts is a facade now. The allowlist closure stayed in the composition root + // while the route branches moved into the serve-options leaf, and the tests below read both: + // the allowlist shape from the root, the chat wire's CORS tail from the leaf. Reading only + // the facade left indexOf at -1 and sliced an empty branch, so the CORS assertions passed + // without checking anything. + const source = [ + readFileSync(new URL("../../src/server/index.ts", import.meta.url), "utf8"), + readFileSync(new URL("../../src/server/index/serve-options.ts", import.meta.url), "utf8"), + ].join("\n"); test("the allowlist admits all three wires as POST and nothing else about them", () => { // The allowlist is a closure inside startServer, so this reads the entry itself. The From e7df217257f9e5ff2197cb38cc81b607c87d79e8 Mon Sep 17 00:00:00 2001 From: lidge-jun Date: Tue, 15 Sep 2026 12:57:16 +0900 Subject: [PATCH 3/6] fix(server): route the startup cache-invalidation flag through a setter CI typecheck caught what the worktree's partial check could not: the facade still assigned `startupCacheInvalidationWrote` at two points, but that flag moved into the startup-warnings leaf with its reader. An ES import binding is read-only, so the assignment no longer compiles across the module boundary. The flag stays next to `consumeStartupCacheInvalidationWrite`, which is the only thing that reads and clears it, and the composition root now calls `setStartupCacheInvalidationWrite`. Keeping the flag and its reader in one module is the point: splitting them would let a future edit reset one without the other. The startup-warnings import collapsed to a single line, matching the re-export lines already in this file, which keeps the facade at 893 lines. The ratchet only lowers caps, so the cap is 893 rather than the 898 recorded a commit ago. --- src/server/index.ts | 11 +++-------- src/server/index/startup-warnings.ts | 12 +++++++++++- tests/fixtures/file-size-baseline.json | 2 +- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/server/index.ts b/src/server/index.ts index 5b38cbc39c..4892f4aaf3 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -204,12 +204,7 @@ import { } from "../lib/package-tree-integrity"; import { detectInstall } from "../update/index"; import { createServeOptions, type ServerIngress } from "./index/serve-options"; -import { - inspectStartupOwnership, - warnAgentTaskRecoveryStartup, - warnPlaintextV2AgentMessagesStartup, - type StartServerDeps, -} from "./index/startup-warnings"; +import { inspectStartupOwnership, setStartupCacheInvalidationWrite, warnAgentTaskRecoveryStartup, warnPlaintextV2AgentMessagesStartup, type StartServerDeps } from "./index/startup-warnings"; export function startServer(port?: number, deps: StartServerDeps = {}): Server { const localAttestationSecret = deps.localAttestationSecret ?? createLocalAttestationSecret(); @@ -252,7 +247,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server | null = null; let startupOwnershipStatePaths: readonly string[] | null = null; @@ -286,7 +281,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server invalidateCodexModelsCacheWithPermit(permit, startupCodexHome)); // A refused permit is not a write; only a completed run that returned true is. - startupCacheInvalidationWrote = outcome.kind === "completed" && outcome.value === true; + setStartupCacheInvalidationWrite(outcome.kind === "completed" && outcome.value === true); } catch { /* no readable Codex home: nothing to invalidate */ } } // Arm the `claudeCode` hand-edit guard (devlog 260726_claude_auth_auto/040 H1) BEFORE diff --git a/src/server/index/startup-warnings.ts b/src/server/index/startup-warnings.ts index 7323f54140..a29d3f6fcb 100644 --- a/src/server/index/startup-warnings.ts +++ b/src/server/index/startup-warnings.ts @@ -177,9 +177,19 @@ export function inspectStartupOwnership( * deliberately gets no warning — lifecycle diagnostics belong to whoever owns * the lifecycle. */ -export let startupCacheInvalidationWrote = false; +let startupCacheInvalidationWrote = false; /** #1046: did this process's startup cache invalidation actually write? */ +/** + * The composition root owns WHEN the startup cache invalidation runs, but the flag lives here + * with its reader. An ES import binding is read-only, so the root cannot assign to it across + * the module boundary the way it did when both sides were one file. This setter is that + * assignment, kept next to the reader so the two cannot drift apart. + */ +export function setStartupCacheInvalidationWrite(wrote: boolean): void { + startupCacheInvalidationWrote = wrote; +} + export function consumeStartupCacheInvalidationWrite(): boolean { const wrote = startupCacheInvalidationWrote; startupCacheInvalidationWrote = false; diff --git a/tests/fixtures/file-size-baseline.json b/tests/fixtures/file-size-baseline.json index 74142babf4..27d6e9073b 100644 --- a/tests/fixtures/file-size-baseline.json +++ b/tests/fixtures/file-size-baseline.json @@ -24,7 +24,7 @@ "src/codex/catalog/provider-fetch.ts": 54, "src/config.ts": 460, "src/providers/registry.ts": 232, - "src/server/index.ts": 898, + "src/server/index.ts": 893, "src/server/responses/core.ts": 9386, "tests/ci-workflows/ci-workflows.test.ts": 5628, "tests/cli/cli-account.test.ts": 2313, From 523bfbf61ebfd493e2fcab46bf61c31dafad4909 Mon Sep 17 00:00:00 2001 From: lidge-jun Date: Tue, 15 Sep 2026 13:02:38 +0900 Subject: [PATCH 4/6] docs(devlog): record the server/index.ts outcome and the three defects verification caught --- .../061_server_index_outcome.md | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 devlog/_plan/260915_godfile_round5/061_server_index_outcome.md diff --git a/devlog/_plan/260915_godfile_round5/061_server_index_outcome.md b/devlog/_plan/260915_godfile_round5/061_server_index_outcome.md new file mode 100644 index 0000000000..a40cd9e589 --- /dev/null +++ b/devlog/_plan/260915_godfile_round5/061_server_index_outcome.md @@ -0,0 +1,48 @@ +# 060 wp5 결과 기록: src/server/index.ts + +## 최종 수치 + +파사드 893줄. 리프 5개: bounded-request 88, startup-warnings 205, websocket-handler 335, +live-sideband 540, serve-options 1,766. + +## 계약서와 달라진 점 + +040_server_index.md 는 리프 4개(bounded-request, live-sideband, startup-warnings, route-guards)와 +serveOptions 추출을 예정했다. 실제로는 route-guards 를 만들지 않았다. serveOptions 를 빼내면 파사드가 +893줄이 되어 route-guards 를 옮길 이유가 사라졌고, 옮기지 않은 쪽이 변경 면적이 작다. + +대신 계약서에 없던 websocket-handler 리프가 생겼다. serveOptions 를 그대로 빼면 리프가 2,010줄이 되어 +ratchet 의 NEW_OVERSIZED 에 걸린다. 임계값은 2,000 이고 `updateBaseline` 은 기준선 파일이 없을 때만 +새 파일에 캡을 심으므로, 2,000 을 넘는 새 리프는 캡을 받지 못하고 그대로 위반이 된다. websocket 핸들러 +244줄을 별도 리프로 빼서 1,766 으로 내렸다. + +## 순수 이동이 아닌 부분 + +캡처 24개 중 21개는 팩토리에서 구조 분해해 본문을 그대로 뒀다. 가변 3개(`server`, `boundPort`, +`remoteWorkspaceStopping`)는 구조 분해하면 생성 시점의 `undefined`/`null`/`false` 로 굳으므로 +파사드가 getter 로 넘기고 본문 7줄을 `ctx.x` 로 바꿨다. 이 7줄이 순수 이동에서 벗어난 전부다. + +`startupCacheInvalidationWrote` 는 추가 조정이 필요했다. 파사드가 두 곳에서 이 값에 대입하는데 +변수는 리프로 갔고 ES import 바인딩은 읽기 전용이라 컴파일되지 않는다. 변수와 그것을 읽고 지우는 +`consumeStartupCacheInvalidationWrite` 를 한 모듈에 유지하고 setter 를 export 했다. + +## 검증이 잡은 결함 3건 + +계약서 초안이 route-guards 범위를 1191-1329 로 적었는데 `runAdmittedHttpTurn` 의 닫는 중괄호는 1330 이다. +괄호 깊이 검증기가 거부했다. 결과적으로 그 리프를 만들지 않았지만, 검증기가 비-vacuous 하다는 증거는 남았다. + +코드모드가 `startup-warnings.ts` 에 `import { startServer } from "../index"` 를 넣었다. 그 이름은 +JSDoc 문단에만 나온다. 주석을 사용처로 오인한 버그다. 그 한 줄이 파사드와 리프를 값 순환으로 만들어 +`server/index.ts` 와 무관한 테스트까지 red 가 됐다. CI 가 잡았다. + +파사드가 `startupCacheInvalidationWrote` 에 대입하는 문제는 로컬 `--ignoreConfig` tsc 가 못 봤고 +CI typecheck 가 잡았다. 이 워크트리에 node_modules 가 없는 한 이 계열은 CI 가 유일한 오라클이다. + +## 오라클 + +`src/server/index.ts` 를 텍스트로 읽는 테스트 8개 중 4개를 재지정했다. 단언 문자열은 하나만 바꿨다 +(ws-endpoint 의 `websocket: {` → `websocket: createWebsocketHandler(ctx),`). 그런데도 같은 파일 안 +세 번째 describe 를 시뮬레이션이 빠뜨려 감사자가 잡았다. 손으로 목록을 만드는 방식의 한계이고, +core.ts 쪽이 쓴 "모듈 목록 상수 + 목록과 import 그래프 일치 단언" 방식이 이 문제를 구조적으로 닫는다. +다음 라운드는 그 방식을 먼저 쓴다. + From de63b99324b179921f21e63ddf65631d056dc9fe Mon Sep 17 00:00:00 2001 From: lidge-jun Date: Tue, 15 Sep 2026 13:15:12 +0900 Subject: [PATCH 5/6] test(server): repoint the loopback-listener seam oracle at the serve-options leaf tests/server/loopback-listener-integration.test.ts has a describe that reads src/server/index.ts as text for three properties with no runtime oracle on this Bun version. Two of them -- the explicit 127.0.0.1 binds for the loopback listener and the hub management ingress -- stayed in the composition root next to Bun.serve. The third, that the WebSocket upgrade uses the receiving server rather than the captured binding, moved with the fetch handler, so `requestServer.upgrade(req,` dropped to zero matches and `.toBe(3)` failed. The read now concatenates the facade and the serve-options leaf, which satisfies all three: 3 upgrade call sites, no `server.upgrade(req,`, and both binds. This is the third oracle this round that a literal path search did not find. It builds its path from `join(process.cwd(), "src", "server", "index.ts")`, so the candidate set my detector generated never reached src/server/index.ts. The three misses had three different shapes, which is the argument for not relying on a static detector: `bun run test:changed` found this one in 40 seconds against 2,249 tests, where the earlier two each cost a full CI round. --- .../061_server_index_outcome.md | 24 +++++++++++++++++++ .../loopback-listener-integration.test.ts | 11 ++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/devlog/_plan/260915_godfile_round5/061_server_index_outcome.md b/devlog/_plan/260915_godfile_round5/061_server_index_outcome.md index a40cd9e589..d3e4b5069d 100644 --- a/devlog/_plan/260915_godfile_round5/061_server_index_outcome.md +++ b/devlog/_plan/260915_godfile_round5/061_server_index_outcome.md @@ -46,3 +46,27 @@ CI typecheck 가 잡았다. 이 워크트리에 node_modules 가 없는 한 이 core.ts 쪽이 쓴 "모듈 목록 상수 + 목록과 import 그래프 일치 단언" 방식이 이 문제를 구조적으로 닫는다. 다음 라운드는 그 방식을 먼저 쓴다. + +## 오라클 누락 세 번째, 그리고 방법을 바꾼 이유 + +`tests/server/loopback-listener-integration.test.ts` 의 "seams the runtime cannot defend" 가 +세 번째 누락이었다. `bun run test:changed` 가 40초에 잡았다. + +이 오라클은 경로를 `join(process.cwd(), "src", "server", "index.ts")` 로 조립한다. 내가 만든 탐지기는 +문자열 리터럴을 뽑아 `src/` 를 붙여 해석해보는 방식이라 후보가 `index.ts`, `src/index.ts` 였고 +`src/server/index.ts` 에 닿지 못했다. bridge 때는 `repoPath("src", ...relative.split("/"))` 에, +server/index 때는 같은 파일 안 다른 describe 에, 여기서는 다중 세그먼트 조립에 걸렸다. + +세 번 다 형태가 다르다. 탐지기를 한 번 더 넓히는 것으로는 닫히지 않는다는 뜻이다. 실제로 닫는 방법은 +두 개뿐이었다. + +하나는 `core.ts` 쪽이 쓴 방식이다. 모듈 목록을 상수로 두고, 그 목록이 실제 import 그래프와 같은지 +테스트가 단언한다. 목록에 없는 리프를 추가하면 그 테스트가 실패하므로 오라클이 조용해질 수 없다. + +다른 하나는 `bun run test:changed` 다. 변경 파일의 import 그래프를 따라 테스트를 고르므로 어떤 형태로 +경로를 조립했든 그 테스트를 실행한다. 이번에 주 체크아웃의 `node_modules` 를 링크해서 처음 돌렸고, +40초에 105파일 2,249개를 돌려 한 건을 찾았다. 앞선 두 번은 CI 한 바퀴(수십 분)를 태워서 알았다. + +다음 라운드의 순서는 이렇게 고정한다. 링크를 먼저 걸고, 분해 직후 `test:changed` 를 돌리고, +그 다음에 오라클 목록을 손으로 본다. 정적 탐지기는 보조 수단이지 1차 방어선이 아니다. + diff --git a/tests/server/loopback-listener-integration.test.ts b/tests/server/loopback-listener-integration.test.ts index eb596cc274..29a10a4413 100644 --- a/tests/server/loopback-listener-integration.test.ts +++ b/tests/server/loopback-listener-integration.test.ts @@ -857,7 +857,16 @@ describe("seams the runtime cannot defend", () => { // Two properties have no runtime oracle on this Bun version, and both would regress // silently. A source assertion is a weak instrument, but a weak instrument aimed at a known // blind spot beats none — the alternative is a comment nobody runs. - const serverSource = readFileSync(join(process.cwd(), "src", "server", "index.ts"), "utf-8"); + // src/server/index.ts is a facade now. The three assertions below split across it and the + // serve-options leaf: the upgrade call sites moved with the fetch handler, while both + // explicit 127.0.0.1 binds stayed in the composition root next to Bun.serve. Read both. + // Reading the facade alone would leave requestServer.upgrade at zero matches, and + // `.toBe(3)` would fail on undefined rather than pass silently -- but the two bind + // assertions would still hold, so only one of the three would have told us anything. + const serverSource = [ + readFileSync(join(process.cwd(), "src", "server", "index.ts"), "utf-8"), + readFileSync(join(process.cwd(), "src", "server", "index", "serve-options.ts"), "utf-8"), + ].join("\n"); test("the WebSocket upgrade uses the receiving server, never the captured binding", () => { // Swapping in `server.upgrade` stays green at runtime here: this Bun accepts an upgrade From d732461e185261a4a686df354b654fcc08c4b9d9 Mon Sep 17 00:00:00 2001 From: lidge-jun Date: Tue, 15 Sep 2026 13:29:33 +0900 Subject: [PATCH 6/6] test(update): repoint the /healthz identity oracle at the serve-options leaf tests/update/update-stop-first.test.ts reads src/server/index.ts as text and pins three fields of the /healthz payload: `service: "opencodex"`, `pid: process.pid` and `port: healthPort`. All three live in the route handler, which moved into the serve-options leaf, so the facade read found none of them. The read now concatenates both; this is the only place in that file that reads server source. This is the fourth oracle this round that neither a literal path search nor `bun run test:changed` found. It builds its path from `join(repoRoot, "src", "server", "index.ts")`, and because it reads the file as data rather than importing it, the changed-import graph never selects it -- exactly the indirect-dependency case AGENTS.md calls out as the reason the full suite is sometimes required. CI's `test 3/4` shard named it directly. The remaining candidates were enumerated and run: the eleven other tests that mention src/server/index.ts do so in comments, through the import graph, or read content that stayed in the facade. 235 pass, 0 fail. --- tests/update/update-stop-first.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/update/update-stop-first.test.ts b/tests/update/update-stop-first.test.ts index 7fa117758b..0c21fce698 100644 --- a/tests/update/update-stop-first.test.ts +++ b/tests/update/update-stop-first.test.ts @@ -258,7 +258,13 @@ function instrumentRecoveryLauncher(source: string, directory: string): string { } const updateSource = readFileSync(join(repoRoot, "src", "update", "index.ts"), "utf8"); const launcherSource = readFileSync(join(repoRoot, "bin", "ocx.mjs"), "utf8"); -const serverSource = readFileSync(join(repoRoot, "src", "server", "index.ts"), "utf8"); +// The three /healthz identity assertions below read the route handler, which moved into the +// serve-options leaf when src/server/index.ts became a facade. Reading the facade alone would +// find none of them. This is the only place in this file that reads server source. +const serverSource = [ + readFileSync(join(repoRoot, "src", "server", "index.ts"), "utf8"), + readFileSync(join(repoRoot, "src", "server", "index", "serve-options.ts"), "utf8"), +].join("\n"); const dispatchSource = readFileSync(join(repoRoot, "src", "cli", "dispatch.ts"), "utf8"); describe("bounded recovery diagnostics", () => {