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..d3e4b5069d --- /dev/null +++ b/devlog/_plan/260915_godfile_round5/061_server_index_outcome.md @@ -0,0 +1,72 @@ +# 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 그래프 일치 단언" 방식이 이 문제를 구조적으로 닫는다. +다음 라운드는 그 방식을 먼저 쓴다. + + +## 오라클 누락 세 번째, 그리고 방법을 바꾼 이유 + +`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/src/server/index.ts b/src/server/index.ts index d26814b0ed..4892f4aaf3 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,14 @@ 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, setStartupCacheInvalidationWrite, warnAgentTaskRecoveryStartup, warnPlaintextV2AgentMessagesStartup, type StartServerDeps } from "./index/startup-warnings"; export function startServer(port?: number, deps: StartServerDeps = {}): Server { const localAttestationSecret = deps.localAttestationSecret ?? createLocalAttestationSecret(); @@ -1044,7 +247,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server | null = null; let startupOwnershipStatePaths: readonly string[] | null = null; @@ -1078,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 @@ -1443,7 +646,6 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server): ServerIngress { if (requestServer === loopbackServer) return "unauthenticated-loopback"; if (requestServer === managementIngressServer) return "hub-management"; @@ -1478,1746 +680,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 +886,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..a29d3f6fcb --- /dev/null +++ b/src/server/index/startup-warnings.ts @@ -0,0 +1,213 @@ +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"; + +// 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. + */ +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; + 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..27d6e9073b 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": 893, + "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..48d7a366be 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); @@ -89,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 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 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", () => {