@bitkyc08/opencodex 2.55.0 → 2.57.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (256) hide show
  1. package/bin/ocx.mjs +10 -0
  2. package/gui/dist/assets/{index-BBOZWGB6.css → index-C5-RdDmD.css} +1 -1
  3. package/gui/dist/assets/{index-VuoiWj9J.js → index-Cz7CLdif.js} +21 -21
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +4 -3
  6. package/src/adapters/base.ts +21 -0
  7. package/src/adapters/codebuddy/adapter.ts +2 -1
  8. package/src/adapters/codebuddy/scaffold-guard.ts +248 -0
  9. package/src/adapters/command-code.ts +1 -1
  10. package/src/adapters/cursor/envelope-echo.ts +8 -2
  11. package/src/adapters/cursor/transport-retry.ts +46 -1
  12. package/src/adapters/cursor.ts +4 -0
  13. package/src/adapters/google.ts +7 -7
  14. package/src/adapters/kiro/adapter.ts +42 -1
  15. package/src/adapters/kiro/payload.ts +17 -3
  16. package/src/adapters/kiro/reasoning.ts +70 -7
  17. package/src/adapters/kiro/stream.ts +8 -2
  18. package/src/adapters/kiro/wire.ts +2 -1
  19. package/src/adapters/kiro-events.ts +21 -13
  20. package/src/adapters/kiro-retry.ts +23 -4
  21. package/src/adapters/openai-chat/errors.ts +116 -0
  22. package/src/adapters/openai-chat/messages.ts +346 -0
  23. package/src/adapters/openai-chat/passthrough.ts +146 -0
  24. package/src/adapters/openai-chat/response-events.ts +117 -0
  25. package/src/adapters/openai-chat/tool-call-validation.ts +200 -0
  26. package/src/adapters/openai-chat/tool-name-registry.ts +166 -0
  27. package/src/adapters/openai-chat/tool-schema.ts +495 -0
  28. package/src/adapters/openai-chat/wire.ts +50 -0
  29. package/src/adapters/openai-chat.ts +40 -1452
  30. package/src/adapters/openai-responses/canonical-forward.ts +202 -0
  31. package/src/adapters/openai-responses/image-gen.ts +406 -0
  32. package/src/adapters/openai-responses/internal.ts +3 -0
  33. package/src/adapters/openai-responses/passthrough.ts +642 -0
  34. package/src/adapters/openai-responses/prompt-cache.ts +83 -0
  35. package/src/adapters/openai-responses/reasoning.ts +220 -0
  36. package/src/adapters/openai-responses/request-strips.ts +185 -0
  37. package/src/adapters/openai-responses/tool-output-recovery.ts +509 -0
  38. package/src/adapters/openai-responses/tool-schema.ts +293 -0
  39. package/src/adapters/openai-responses/web-search.ts +156 -0
  40. package/src/adapters/openai-responses.ts +4 -2625
  41. package/src/bridge/errors.ts +58 -0
  42. package/src/bridge/internal.ts +174 -0
  43. package/src/bridge/response-json.ts +630 -0
  44. package/src/bridge/sse.ts +1462 -0
  45. package/src/bridge.ts +5 -2204
  46. package/src/chat/inbound.ts +12 -1
  47. package/src/claude/desktop-profile.ts +66 -9
  48. package/src/claude/outbound.ts +18 -0
  49. package/src/cli/account-main.ts +1 -1
  50. package/src/cli/capabilities.ts +2 -2
  51. package/src/cli/combo.ts +10 -1
  52. package/src/cli/index.ts +48 -5
  53. package/src/cli/registry.ts +2 -1
  54. package/src/cli/system-command.ts +4 -4
  55. package/src/clients/config-export.ts +7 -3
  56. package/src/codex/account-label.ts +14 -3
  57. package/src/codex/account-lifecycle.ts +3 -0
  58. package/src/codex/account-store.ts +184 -35
  59. package/src/codex/account-usability.ts +21 -0
  60. package/src/codex/auth-api/account-list.ts +507 -0
  61. package/src/codex/auth-api/http.ts +32 -0
  62. package/src/codex/auth-api/login-flow.ts +566 -0
  63. package/src/codex/auth-api/login-state.ts +64 -0
  64. package/src/codex/auth-api/main-account-probe.ts +331 -0
  65. package/src/codex/auth-api/pool-mode-gate.ts +274 -0
  66. package/src/codex/auth-api/pool-quota-probe.ts +512 -0
  67. package/src/codex/auth-api/reset-credit-service.ts +431 -0
  68. package/src/codex/auth-api/routes.ts +425 -0
  69. package/src/codex/auth-api/runtime-config.ts +48 -0
  70. package/src/codex/auth-api.ts +27 -3118
  71. package/src/codex/auth-context.ts +252 -35
  72. package/src/codex/catalog/aggregation.ts +80 -1
  73. package/src/codex/catalog/auto-review.ts +507 -0
  74. package/src/codex/catalog/build-entries.ts +981 -0
  75. package/src/codex/catalog/combo-member.ts +375 -0
  76. package/src/codex/catalog/derive-entry.ts +229 -0
  77. package/src/codex/catalog/effort.ts +0 -1
  78. package/src/codex/catalog/gated-native-warn.ts +63 -0
  79. package/src/codex/catalog/gather-capture.ts +533 -0
  80. package/src/codex/catalog/model-hints.ts +691 -0
  81. package/src/codex/catalog/model-visibility.ts +305 -0
  82. package/src/codex/catalog/provider-fetch.ts +52 -2942
  83. package/src/codex/catalog/provider-models.ts +685 -0
  84. package/src/codex/catalog/remote.ts +30 -0
  85. package/src/codex/catalog/restore.ts +132 -0
  86. package/src/codex/catalog/retained-sync.ts +714 -0
  87. package/src/codex/catalog/routed-gather.ts +895 -0
  88. package/src/codex/catalog/subagent-roster.ts +176 -0
  89. package/src/codex/catalog/sync.ts +52 -2698
  90. package/src/codex/cli-install-provenance.ts +7 -1
  91. package/src/codex/convergence.ts +7 -2
  92. package/src/codex/desktop-app/types.ts +11 -2
  93. package/src/codex/desktop-app/windows.ts +5 -5
  94. package/src/codex/inject/config-toml.ts +563 -0
  95. package/src/codex/inject/remove.ts +192 -0
  96. package/src/codex/inject/restore.ts +567 -0
  97. package/src/codex/inject/routing-classify.ts +109 -0
  98. package/src/codex/inject/routing-target.ts +125 -0
  99. package/src/codex/inject.ts +89 -1444
  100. package/src/codex/lineage.ts +458 -0
  101. package/src/codex/model-entitlements.ts +152 -15
  102. package/src/codex/pool-refresh-backoff.ts +161 -0
  103. package/src/codex/quota-rejection.ts +104 -15
  104. package/src/codex/routing/active-account.ts +194 -0
  105. package/src/codex/routing/cache-affinity.ts +70 -0
  106. package/src/codex/routing/cooldown-math.ts +285 -0
  107. package/src/codex/routing/health-store.ts +402 -0
  108. package/src/codex/routing/probe-lease.ts +358 -0
  109. package/src/codex/routing/selection.ts +780 -0
  110. package/src/codex/routing/thread-affinity.ts +586 -0
  111. package/src/codex/routing/transient-hold-dispatch.ts +141 -0
  112. package/src/codex/routing.ts +370 -2271
  113. package/src/codex/shim-fingerprint.ts +223 -0
  114. package/src/codex/shim-inspect.ts +175 -0
  115. package/src/codex/shim-probe.ts +367 -0
  116. package/src/codex/shim-restore-lock.ts +169 -0
  117. package/src/codex/shim-state-file.ts +151 -0
  118. package/src/codex/shim-templates.ts +265 -0
  119. package/src/codex/shim.ts +48 -1268
  120. package/src/codex/warmup.ts +1 -1
  121. package/src/combos/failover.ts +85 -0
  122. package/src/combos/request.ts +17 -10
  123. package/src/combos/types.ts +23 -2
  124. package/src/config/diagnostics.ts +705 -0
  125. package/src/config/feature-flags.ts +55 -0
  126. package/src/config/live-reconcile.ts +403 -0
  127. package/src/config/load-degrade.ts +880 -0
  128. package/src/config/mutation-lock.ts +244 -0
  129. package/src/config/openai-tier-backup.ts +268 -0
  130. package/src/config/pending-teardown.ts +31 -0
  131. package/src/config/persist-unlocked.ts +92 -0
  132. package/src/config/proxy-env.ts +188 -0
  133. package/src/config/salvage.ts +244 -0
  134. package/src/config/schema/config-schema.ts +640 -0
  135. package/src/config/schema/leaf-validators.ts +855 -0
  136. package/src/config/warn-memo.ts +28 -0
  137. package/src/config.ts +234 -4481
  138. package/src/generated/compatibility-version.json +649 -121
  139. package/src/images/loop.ts +1 -1
  140. package/src/lib/errors.ts +17 -0
  141. package/src/lib/request-execution-budget.ts +198 -23
  142. package/src/lib/spend-reservation-ledger.ts +958 -0
  143. package/src/lib/state-store-registrations.ts +6 -2
  144. package/src/lib/test-home-guard.ts +85 -1
  145. package/src/lib/upstream-retry.ts +132 -21
  146. package/src/lib/windows-elevation.ts +76 -14
  147. package/src/lib/workflow-budget.ts +553 -30
  148. package/src/oauth/index.ts +2 -2
  149. package/src/oauth/key-providers.ts +2 -2
  150. package/src/providers/kiro-models.ts +4 -3
  151. package/src/providers/label.ts +19 -1
  152. package/src/providers/model-discovery.ts +16 -0
  153. package/src/providers/quota/account-cache.ts +441 -0
  154. package/src/providers/quota/antigravity.ts +295 -0
  155. package/src/providers/quota/report-cache.ts +320 -0
  156. package/src/providers/quota/vendor-probes-key.ts +1243 -0
  157. package/src/providers/quota/vendor-probes-oauth.ts +590 -0
  158. package/src/providers/quota.ts +324 -3079
  159. package/src/providers/registry/entries-core.ts +1228 -0
  160. package/src/providers/registry/entries-extended.ts +1213 -0
  161. package/src/providers/registry/model-seeds.ts +912 -0
  162. package/src/providers/registry/types.ts +352 -0
  163. package/src/providers/registry.ts +24 -3536
  164. package/src/responses/continuation-ownership.ts +29 -0
  165. package/src/responses/reasoning-envelope.ts +6 -3
  166. package/src/responses/state/replay-fingerprint.ts +80 -0
  167. package/src/responses/state/snapshot-codec.ts +104 -0
  168. package/src/responses/state/spill-failure.ts +118 -0
  169. package/src/responses/state/spill-queue.ts +665 -0
  170. package/src/responses/state/temp-recovery.ts +257 -0
  171. package/src/responses/state.ts +82 -1143
  172. package/src/routing/identity-domains.ts +456 -0
  173. package/src/routing/probe-lease.ts +613 -0
  174. package/src/server/chat-completions.ts +3 -1
  175. package/src/server/chat-native.ts +37 -9
  176. package/src/server/index/bounded-request.ts +88 -0
  177. package/src/server/index/live-sideband.ts +601 -0
  178. package/src/server/index/serve-options.ts +1766 -0
  179. package/src/server/index/startup-warnings.ts +213 -0
  180. package/src/server/index/websocket-handler.ts +339 -0
  181. package/src/server/index.ts +45 -2552
  182. package/src/server/inspection-tee.ts +107 -0
  183. package/src/server/live.ts +46 -1
  184. package/src/server/management/combo-routes.ts +10 -1
  185. package/src/server/management/route-registry.ts +26 -23
  186. package/src/server/management/shared.ts +8 -5
  187. package/src/server/management/workflow-budget-routes.ts +133 -0
  188. package/src/server/management-api.ts +12 -0
  189. package/src/server/relay-eager.ts +2 -0
  190. package/src/server/relay.ts +14 -19
  191. package/src/server/request-log-conversation.ts +9 -7
  192. package/src/server/request-log.ts +372 -4
  193. package/src/server/response-log-body.ts +153 -0
  194. package/src/server/responses/account-change-state.ts +307 -0
  195. package/src/server/responses/adapter-continuation.ts +540 -0
  196. package/src/server/responses/adapter-delivery.ts +208 -0
  197. package/src/server/responses/adapter-dispatch.ts +1042 -0
  198. package/src/server/responses/codex-ws-wire.ts +5 -0
  199. package/src/server/responses/collaboration.ts +74 -4
  200. package/src/server/responses/combo-session-recall.ts +68 -8
  201. package/src/server/responses/compact.ts +113 -17
  202. package/src/server/responses/completion-policy.ts +33 -0
  203. package/src/server/responses/core-auth.ts +529 -0
  204. package/src/server/responses/core-codex-account.ts +907 -0
  205. package/src/server/responses/core-combo-failure.ts +210 -0
  206. package/src/server/responses/core-combo.ts +787 -0
  207. package/src/server/responses/core-errors.ts +170 -0
  208. package/src/server/responses/core-lifetime.ts +95 -0
  209. package/src/server/responses/core-normalize.ts +350 -0
  210. package/src/server/responses/core-opaque-recovery.ts +380 -0
  211. package/src/server/responses/core-options.ts +159 -0
  212. package/src/server/responses/core-replay.ts +298 -0
  213. package/src/server/responses/core.ts +192 -8893
  214. package/src/server/responses/encrypted-payload.ts +0 -1
  215. package/src/server/responses/input-admission.ts +126 -6
  216. package/src/server/responses/passthrough-delivery.ts +869 -0
  217. package/src/server/responses/passthrough-dispatch.ts +1494 -0
  218. package/src/server/responses/passthrough-error.ts +38 -2
  219. package/src/server/responses/passthrough-execution.ts +54 -0
  220. package/src/server/responses/request-prepare.ts +1080 -0
  221. package/src/server/responses/request-send-budget.ts +259 -0
  222. package/src/server/responses/request-sidecar-auth.ts +149 -0
  223. package/src/server/responses/request-spend.ts +147 -0
  224. package/src/server/responses/request-transport.ts +803 -0
  225. package/src/server/responses/response-effects.ts +157 -0
  226. package/src/server/responses/run-turn-execution.ts +476 -0
  227. package/src/server/responses/sidecar-execution.ts +463 -0
  228. package/src/server/responses/terminal-guard.ts +65 -4
  229. package/src/server/responses-image-gen-repair.ts +1 -1
  230. package/src/server/responses-undeclared-tool-guard.ts +9 -5
  231. package/src/server/workflow-refusal.ts +84 -0
  232. package/src/service/windows-ops.ts +210 -16
  233. package/src/service/windows-scheduler.ts +28 -21
  234. package/src/service.ts +1 -1
  235. package/src/types/config.ts +34 -1
  236. package/src/types/request.ts +8 -5
  237. package/src/types/tools.ts +24 -0
  238. package/src/types.ts +2 -0
  239. package/src/update/index.ts +10 -0
  240. package/src/update/stop-contract.d.mts +1 -0
  241. package/src/update/stop-contract.mjs +19 -0
  242. package/src/update/stop-decision.d.mts +1 -1
  243. package/src/update/stop-decision.mjs +12 -3
  244. package/src/usage/log.ts +147 -1
  245. package/src/usage/summary.ts +171 -21
  246. package/src/vision/anthropic-describe.ts +1 -1
  247. package/src/vision/describe.ts +5 -5
  248. package/src/web-search/anthropic-executor.ts +1 -1
  249. package/src/web-search/exa-executor.ts +1 -1
  250. package/src/web-search/executor.ts +1 -1
  251. package/src/web-search/gemini-executor.ts +1 -1
  252. package/src/web-search/loop.ts +1 -1
  253. package/src/web-search/ollama-executor.ts +1 -1
  254. package/src/web-search/parse.ts +67 -14
  255. package/src/web-search/passthrough-bridge.ts +64 -31
  256. package/src/web-search/xai-executor.ts +1 -1
@@ -1,8 +1,5 @@
1
1
  import { remoteWorkspaceEnabled } from "../remote-control/workspace-activation";
2
-
3
2
  import { AuxiliaryListenerBindError } from "./ports";
4
- import { markActivity } from "../lib/sidecar-tracker";
5
- import { knownModelIdsForProvider } from "../router";
6
3
  import {
7
4
  buildWarmupCompletionFrames,
8
5
  buildWsErrorFrame,
@@ -25,7 +22,6 @@ import {
25
22
  loopbackCompanionBindError,
26
23
  websocketsEnabled,
27
24
  } from "../config";
28
- import { grokDefaultReasoningEffort } from "../grok/effort";
29
25
  import { flushConfigDirHardening } from "../config/paths";
30
26
  import { migrateStartupSubagentModels } from "./subagent-models-startup";
31
27
  import { migrateStartupXaiResponses } from "./xai-responses-startup";
@@ -77,26 +73,12 @@ import {
77
73
  MIN_CONFIGURABLE_INBOUND_BODY_BYTES,
78
74
  resolveInboundBodyLimitBytes,
79
75
  } from "./request-decompress";
80
- import {
81
- CodexAccountCooldownError,
82
- cooldownErrorMessage,
83
- } from "../codex/auth-context";
84
- import { codexAccountNamespaceForModel } from "../codex/account-namespace-match";
85
- import { codexAccountNamespaceEntries, isMainCodexAccountTarget } from "../codex/account-namespaces";
86
76
  import { MAIN_CODEX_ACCOUNT_ID } from "../codex/main-account";
87
- import {
88
- availableAccountGatedNativeModels,
89
- codexModelEntitlementStateForAccount,
90
- resolveCodexModelEntitlements,
91
- } from "../codex/model-entitlements";
92
77
  export {
93
78
  clearThreadAccountMap,
94
79
  formatCodexProviderForLog,
95
80
  resolveCodexAccountForThread,
96
81
  } from "../codex/routing";
97
- import { formatCodexProviderForLog } from "../codex/routing";
98
- import { CatalogGatherBusyError } from "../codex/catalog/provider-fetch";
99
- import { registerCodexWebSocket, tryReserveCodexWebSocket, unregisterCodexWebSocket, updateCodexWebSocketAuthContext } from "../codex/websocket-registry";
100
82
  import { resolveGuiFilePath, rootFallbackPayload, serveGuiFile, serveSessionBootstrap } from "./gui-static";
101
83
  export { resolveGuiFilePath, rootFallbackPayload } from "./gui-static";
102
84
  export { resolveAdapter } from "./adapter-resolve";
@@ -135,6 +117,7 @@ import {
135
117
  } from "./request-log";
136
118
  import { sessionLaneIdFromRequest } from "./request-log-conversation";
137
119
  import { admitWorkflowTurn, type WorkflowLane } from "../lib/workflow-budget";
120
+ import { workflowRefusalResponse, type WorkflowRefusalLog } from "./workflow-refusal";
138
121
  export {
139
122
  addFinalRequestLog,
140
123
  filterRequestLogs,
@@ -148,13 +131,6 @@ export {
148
131
  type RequestLogContext,
149
132
  type RequestLogEntry,
150
133
  } from "./request-log";
151
- import {
152
- consumeForInspection,
153
- relaySseWithHeartbeat,
154
- relayWithAbort,
155
- responseWithDeferredRequestLog,
156
- sanitizePassthroughHeaders,
157
- } from "./relay";
158
134
  export {
159
135
  consumeForInspection,
160
136
  codexSafetyBufferingFilterOptions,
@@ -193,13 +169,7 @@ export {
193
169
  jsonResponse,
194
170
  safeConfigDTO,
195
171
  } from "./auth-cors";
196
- import { disableResponsesRequestTimeout, handleResponses, handleResponsesCompact } from "./responses";
197
172
  export { disableResponsesRequestTimeout, linkAbortSignal } from "./responses";
198
- import { handleClaudeCountTokens, handleClaudeMessages } from "./claude-messages";
199
- import { handleChatCompletions } from "./chat-completions";
200
- import { anthropicErrorResponse } from "../claude/outbound";
201
- import { buildDesktop3pRegistry, generateDesktop3pModels } from "../claude/desktop-3p";
202
- import { buildDesktopDiscoveryInputs } from "../claude/desktop-discovery-inputs";
203
173
  import { runClaudeAuthModeMigration } from "../claude/auth-mode-migration";
204
174
  import { runRetiredCodexModelMigration } from "../codex/retired-model-migration";
205
175
  import {
@@ -210,17 +180,7 @@ import {
210
180
  type NativeMainStartupGateDeps,
211
181
  type NativeMainStartupLifecycle,
212
182
  } from "../codex/native-profile-startup";
213
- import { handleImages } from "./images";
214
- import { handleLive, logLiveSidebandFrame, parseLiveSidebandTarget, resolveLiveSidebandUpgrade } from "./live";
215
- import { handleAudioTranscriptions } from "./audio-transcriptions";
216
- import { resolveAudioAdmission, TRANSCRIPTION_MODEL } from "./audio-upstream";
217
- import { resolveAudioClient } from "./audio-client";
218
- import { resolveDictationSocket } from "./audio-dictation";
219
- import { handleExternalLive, resolveExternalLiveSocket } from "./audio-live";
220
183
  import { EXTERNAL_CALL_PREFIX, LiveCallBindings } from "./live-call-bindings";
221
- import { clearableDeadline } from "../lib/abort";
222
- import { handleSearch } from "./search";
223
- import { handleContextHistory } from "./context-history";
224
184
  import { codexCompatibleUrl, contextEndpoint, contextRelayActivated } from "../codex/context-compat";
225
185
  import { fetchAllModels, handleManagementAPI, VERSION, type ManagementApiDeps } from "./management-api";
226
186
  import {
@@ -237,770 +197,14 @@ import {
237
197
  createLocalAttestationProof,
238
198
  createLocalAttestationSecret,
239
199
  } from "../lib/local-management-attestation";
240
- import { SYSTEM_RESTART_CAPABILITY_VERSION } from "../lib/system-restart-contract";
241
- import { LOCAL_PROVIDER_RELOAD_CAPABILITY_VERSION } from "../lib/local-provider-reload-contract";
242
- import {
243
- GUI_PAIR_BROWSER_ORIGIN_HEADER,
244
- GUI_PAIR_CAPABILITY_VERSION,
245
- GUI_PAIR_PATH,
246
- } from "../lib/gui-pair-capability";
247
- import {
248
- GuiPairingGrantRateLimitError,
249
- consumeGuiPairingGrant,
250
- createGuiPairingGrant,
251
- } from "./gui-session";
252
200
  import { createReadinessGate, type ReadinessGate } from "./readiness";
253
201
  import {
254
202
  createRuntimePackageTreeIntegrityGuard,
255
203
  type PackageTreeIntegrityGuard,
256
204
  } from "../lib/package-tree-integrity";
257
205
  import { detectInstall } from "../update/index";
258
- import { readyProtocolMetadata } from "../remote/protocol";
259
- import { modelCapabilityFields } from "./models-capabilities";
260
- import { recordCursorSeen } from "../integrations/cursor-seen";
261
- import { detectCursorInstalls } from "../integrations/cursor-detect";
262
- import { loadCursorEffortTable } from "../integrations/cursor-effort-table";
263
- import { expandCursorEffortRow, knownEffortRowIds } from "./effort-row";
264
- import { catalogFastRowEligible, expandFastRow } from "./fast-row";
265
-
266
- export const MAX_WS_FRAME_BYTES = 50 * 1024 * 1024;
267
- const WEBSOCKET_IDLE_TIMEOUT_SECONDS = 0;
268
-
269
- // Header-safe by construction: a key id reaches a response header, so anything outside this
270
- // class could inject a header break or a control character into a response we control.
271
- const REMOTE_CATALOG_KEY_ID_PATTERN = /^[A-Za-z0-9._-]{1,64}$/;
272
- const GUI_PAIRING_EXCHANGE_BODY_LIMIT = 4 * 1024;
273
- const REMOTE_WORKSPACE_PAIRING_BODY_LIMIT = 32 * 1024;
274
-
275
- /**
276
- * Read at most `limit` bytes of a request body, or refuse.
277
- *
278
- * Returns null the moment the body is known to exceed `limit`, without retaining the excess.
279
- * `req.text()` cannot express that: it buffers to completion first, so a caller who omits
280
- * Content-Length or uses chunked framing decides how much memory the process spends. That
281
- * matters here because the one caller is an unauthenticated endpoint.
282
- *
283
- * limit+1 is the stopping point rather than limit, so a body exactly at the limit is still
284
- * accepted and only a genuinely over-limit body is rejected.
285
- */
286
- async function readBoundedRequestText(req: Request, limit: number): Promise<string | null> {
287
- const body = req.body;
288
- if (!body) return "";
289
- const reader = body.getReader();
290
- const chunks: Uint8Array[] = [];
291
- let total = 0;
292
- try {
293
- for (;;) {
294
- const { done, value } = await reader.read();
295
- if (done) break;
296
- if (!value || value.byteLength === 0) continue;
297
- total += value.byteLength;
298
- if (total > limit) return null;
299
- chunks.push(value);
300
- }
301
- } finally {
302
- // Cancel rather than only releasing the lock: on the reject path the peer may still be
303
- // sending, and an uncancelled body keeps that transfer alive.
304
- await reader.cancel().catch(() => {});
305
- }
306
- const joined = new Uint8Array(total);
307
- let offset = 0;
308
- for (const chunk of chunks) {
309
- joined.set(chunk, offset);
310
- offset += chunk.byteLength;
311
- }
312
- return new TextDecoder().decode(joined);
313
- }
314
-
315
- /**
316
- * Name WHICH configured credential was admitted, so a multi-key operator can attribute a
317
- * catalog read.
318
- *
319
- * Scoped to configured keys on purpose: an environment token or a loopback bind has no key
320
- * to name, and emitting one anyway would invent an attribution that does not exist. 200 only
321
- * — this route emits no validator and therefore never answers 304.
322
- *
323
- * An id that fails the header-safe pattern is omitted rather than sanitized, with one warning
324
- * that does NOT repeat the id: logging the offending value is how a malformed id becomes a
325
- * log-injection vector instead of a dropped header.
326
- */
327
- function withRemoteCatalogKeyId(response: Response, admission: DataPlaneAdmission): Response {
328
- if (response.status !== 200 || admission.kind !== "configured") return response;
329
- if (!REMOTE_CATALOG_KEY_ID_PATTERN.test(admission.keyId)) {
330
- console.warn("[remote-catalog] configured API key id is not header-safe; omitting x-opencodex-key-id");
331
- return response;
332
- }
333
- response.headers.set("x-opencodex-key-id", admission.keyId);
334
- return response;
335
- }
336
-
337
- const LIVE_SIDEBAND_PENDING_MAX = 32;
338
- const LIVE_SIDEBAND_PENDING_BYTES_MAX = 1024 * 1024;
339
- const LIVE_SIDEBAND_CLOSE_FALLBACK_MS = 1_000;
340
- /**
341
- * Bound the pre-upgrade upstream handshake. A sideband join that cannot reach 101
342
- * must fail the client upgrade promptly rather than hold it open indefinitely.
343
- */
344
- export const LIVE_SIDEBAND_UPSTREAM_OPEN_TIMEOUT_MS = 10_000;
345
-
346
- /**
347
- * Outcome of the upstream sideband handshake performed before the client upgrade.
348
- *
349
- * `ok: false` carries the HTTP status the client upgrade must fail with. Only an
350
- * upgrade failure reaches codex-rs as a connect error, and only a connect error
351
- * ends its sideband reconnect loop (`realtime_conversation/sideband.rs`: the `Err`
352
- * arm always breaks). A 101 followed by a close is instead read as `TransportLost`
353
- * and retried forever against the same, permanently dead call id.
354
- */
355
- export type LiveSidebandUpstreamOpenResult =
356
- | {
357
- ok: true;
358
- socket: WebSocket;
359
- /** Owns capture and terminal events until the downstream relay attaches. */
360
- handoff: LiveSidebandUpstreamHandoff;
361
- }
362
- | { ok: false; status: number; code: string; message: string; socket?: WebSocket };
363
-
364
- export function exceedsLiveSidebandFrameByteLimit(frameBytes: number): boolean {
365
- return frameBytes > MAX_WS_FRAME_BYTES;
366
- }
367
-
368
- export function exceedsLiveSidebandPendingByteLimit(pendingBytes: number, incomingBytes: number): boolean {
369
- return incomingBytes > LIVE_SIDEBAND_PENDING_BYTES_MAX - pendingBytes;
370
- }
371
-
372
- function webSocketFrameBytes(frame: string | ArrayBuffer | ArrayBufferView | Blob | Buffer): number {
373
- if (typeof frame === "string") return Buffer.byteLength(frame);
374
- if (frame instanceof ArrayBuffer || ArrayBuffer.isView(frame)) return frame.byteLength;
375
- return frame.size;
376
- }
377
-
378
- export type LiveSidebandPendingEnqueueResult = "queued" | "too-many-frames" | "too-many-bytes";
379
-
380
- export function enqueueLiveSidebandPendingFrame(
381
- data: Pick<WsData, "livePending" | "livePendingBytes">,
382
- frame: string | Buffer,
383
- frameBytes = webSocketFrameBytes(frame),
384
- ): LiveSidebandPendingEnqueueResult {
385
- const pending = data.livePending ?? (data.livePending = []);
386
- if (pending.length >= LIVE_SIDEBAND_PENDING_MAX) return "too-many-frames";
387
- const pendingBytes = data.livePendingBytes ?? 0;
388
- if (exceedsLiveSidebandPendingByteLimit(pendingBytes, frameBytes)) return "too-many-bytes";
389
- pending.push(frame);
390
- data.livePendingBytes = pendingBytes + frameBytes;
391
- return "queued";
392
- }
393
-
394
- type LiveSidebandWebSocketFactory = (
395
- url: string,
396
- headers: Record<string, string>,
397
- protocols?: string[],
398
- ) => WebSocket;
399
-
400
- function releaseLiveSidebandAdmission(ws: ServerWebSocket<WsData>): void {
401
- ws.data.liveTurnAdmissionLease?.release();
402
- ws.data.liveTurnAdmissionLease = undefined;
403
- }
404
-
405
- /**
406
- * Send one live-sideband frame to the upstream socket.
407
- *
408
- * Bun's `WebSocket.send` accepts `string | Blob | BufferSource`, but the DOM-lib
409
- * `Buffer` can be backed by a `SharedArrayBuffer`, which `BufferSource` rejects.
410
- * `Uint8Array.from` copies into a fresh `ArrayBuffer`-backed view, so a frame
411
- * arriving from `node:buffer` still round-trips byte-for-byte.
412
- */
413
- function sendUpstreamFrame(upstream: WebSocket, frame: string | Buffer): void {
414
- if (typeof frame === "string") {
415
- upstream.send(frame);
416
- return;
417
- }
418
- upstream.send(Uint8Array.from(frame));
419
- }
420
-
421
- function finalizeLiveSideband(ws: ServerWebSocket<WsData>, upstream?: WebSocket): void {
422
- if (upstream && ws.data.liveUpstream !== upstream) return;
423
- if (ws.data.liveCloseFallback !== undefined) {
424
- clearTimeout(ws.data.liveCloseFallback);
425
- ws.data.liveCloseFallback = undefined;
426
- }
427
- ws.data.liveUpstream = undefined;
428
- ws.data.livePending = undefined;
429
- ws.data.livePendingBytes = undefined;
430
- if (ws.data.liveConnectTimer !== undefined) clearTimeout(ws.data.liveConnectTimer);
431
- if (ws.data.liveSessionTimer !== undefined) clearTimeout(ws.data.liveSessionTimer);
432
- ws.data.liveConnectTimer = undefined;
433
- ws.data.liveSessionTimer = undefined;
434
- ws.data.liveUpstreamHeaders = undefined;
435
- ws.data.liveUpstreamProtocols = undefined;
436
- ws.data.liveValidateFrame = undefined;
437
- if (ws.data.liveAbortListener) ws.data.liveAbortSignal?.removeEventListener("abort", ws.data.liveAbortListener);
438
- ws.data.liveAbortSignal = undefined;
439
- ws.data.liveAbortListener = undefined;
440
- ws.data.cancel = undefined;
441
- const finish = ws.data.liveFinish;
442
- ws.data.liveFinish = undefined;
443
- try { finish?.(ws.data.liveOutcome); }
444
- catch { console.warn("[audio] upstream accounting failed during close"); }
445
- finally { releaseLiveSidebandAdmission(ws); }
446
- }
447
-
448
- function armLiveSidebandCloseFallback(ws: ServerWebSocket<WsData>, upstream: WebSocket): void {
449
- if (ws.data.liveCloseFallback !== undefined) return;
450
- ws.data.liveCloseFallback = setTimeout(() => {
451
- ws.data.liveCloseFallback = undefined;
452
- if (ws.data.liveUpstream !== upstream) return;
453
- if (upstream.readyState === WebSocket.CLOSED) {
454
- finalizeLiveSideband(ws, upstream);
455
- return;
456
- }
457
- // A close frame was already sent below. Retry once, but never surrender
458
- // native-main ownership while the authenticated transport remains live.
459
- try {
460
- upstream.close(1000, "upstream close timeout");
461
- } catch {
462
- /* upstream is already unusable */
463
- }
464
- // Some implementations transition synchronously without delivering the
465
- // close event. That is still an observed CLOSED transport and is safe to
466
- // finalize. CONNECTING/CLOSING peers keep the lease so profile switching
467
- // fails at its own bounded drain deadline instead of racing live traffic.
468
- // The earlier CLOSED check narrowed `readyState` to 0|1|2 in the type
469
- // system, but the socket can still transition to CLOSED (3) before this
470
- // fallback fires; the cast keeps the runtime-identical check.
471
- if ((upstream.readyState as number) === 3) finalizeLiveSideband(ws, upstream);
472
- }, LIVE_SIDEBAND_CLOSE_FALLBACK_MS);
473
- }
474
-
475
- function closeLiveSidebandBeforeUpgrade(
476
- upstream: WebSocket,
477
- release: () => void,
478
- code = 1000,
479
- reason = "",
480
- ): void {
481
- // There is no downstream socket to own this transport yet. Mirror
482
- // closeLiveSideband's bounded close contract directly: release only after a
483
- // close event or an observed CLOSED state, never merely after requesting close.
484
- let released = false;
485
- let fallback: ReturnType<typeof setTimeout> | undefined;
486
- const releaseOnce = (): void => {
487
- if (released) return;
488
- released = true;
489
- if (fallback !== undefined) clearTimeout(fallback);
490
- release();
491
- };
492
- upstream.addEventListener("close", releaseOnce, { once: true });
493
- if (upstream.readyState === WebSocket.CLOSED) {
494
- releaseOnce();
495
- return;
496
- }
497
- fallback = setTimeout(() => {
498
- if (upstream.readyState === WebSocket.CLOSED) {
499
- releaseOnce();
500
- return;
501
- }
502
- try {
503
- upstream.close(1000, "upstream close timeout");
504
- } catch {
505
- /* retain ownership until CLOSED is observed */
506
- }
507
- if ((upstream.readyState as number) === 3) releaseOnce();
508
- }, LIVE_SIDEBAND_CLOSE_FALLBACK_MS);
509
- try {
510
- upstream.close(code, reason);
511
- } catch {
512
- /* the bounded fallback retries without releasing ownership */
513
- }
514
- if ((upstream.readyState as number) === 3) releaseOnce();
515
- }
516
-
517
- function closeLiveSideband(ws: ServerWebSocket<WsData>, code = 1000, reason = ""): void {
518
- if (ws.data.liveClosing) return;
519
- ws.data.liveClosing = true;
520
- if (ws.data.liveConnectTimer !== undefined) clearTimeout(ws.data.liveConnectTimer);
521
- if (ws.data.liveSessionTimer !== undefined) clearTimeout(ws.data.liveSessionTimer);
522
- ws.data.liveConnectTimer = undefined;
523
- ws.data.liveSessionTimer = undefined;
524
- ws.data.livePending = undefined;
525
- ws.data.livePendingBytes = undefined;
526
- ws.data.cancel = undefined;
527
- const upstream = ws.data.liveUpstream;
528
- // Bun's `WebSocket` type narrows `readyState` to 0|1|2 even though the DOM
529
- // constant CLOSED is 3; the numeric literal is the runtime-identical check.
530
- if (!upstream || upstream.readyState === 3) {
531
- finalizeLiveSideband(ws, upstream);
532
- } else {
533
- // The sideband holds a native-main admission lease. Do not release it just
534
- // because the downstream left: its authenticated upstream remains live
535
- // until the close event arrives or the transport is observed CLOSED. The
536
- // bounded fallback only retries close; it does not release ownership.
537
- armLiveSidebandCloseFallback(ws, upstream);
538
- try {
539
- upstream.close(code, reason);
540
- } catch {
541
- /* the fallback retries close without releasing ownership */
542
- }
543
- }
544
- try {
545
- if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
546
- ws.close(code, reason);
547
- }
548
- } catch {
549
- /* client already gone */
550
- }
551
- }
552
-
553
- /**
554
- * Dial the upstream sideband and report whether its handshake reached 101.
555
- *
556
- * Bun's client WebSocket does not surface the upstream handshake status, so the
557
- * result is "opened" or "failed" and nothing finer. That is sufficient for the
558
- * property this exists to guarantee: the client is never told the relay is live
559
- * when it is not. Frames the upstream sends before the client socket exists are
560
- * captured and handed back by `drain`, because a session preamble such as
561
- * `session.created` arrives immediately after the upstream opens.
562
- */
563
- export function openLiveSidebandUpstream(
564
- url: string,
565
- headers: Record<string, string>,
566
- createWebSocket: LiveSidebandWebSocketFactory = (socketUrl, socketHeaders) => (
567
- new WebSocket(socketUrl, { headers: socketHeaders } as unknown as string[])
568
- ),
569
- timeoutMs: number = LIVE_SIDEBAND_UPSTREAM_OPEN_TIMEOUT_MS,
570
- signal?: AbortSignal,
571
- ): Promise<LiveSidebandUpstreamOpenResult> {
572
- return new Promise(resolve => {
573
- let socket: WebSocket;
574
- try {
575
- socket = createWebSocket(url, headers);
576
- } catch {
577
- resolve({ ok: false, status: 502, code: "upstream_error", message: "voice upstream connect failed" });
578
- return;
579
- }
580
-
581
- const buffered: Array<string | Buffer> = [];
582
- let bufferedBytes = 0;
583
- let capturing = true;
584
- let settled = false;
585
- let terminalFailure: LiveSidebandUpstreamFailure | undefined;
586
- let removeAbortListener = (): void => {};
587
-
588
- const finish = (result: LiveSidebandUpstreamOpenResult): void => {
589
- if (settled) return;
590
- settled = true;
591
- clearTimeout(timer);
592
- removeAbortListener();
593
- resolve(result);
594
- };
595
- const timer = setTimeout(() => {
596
- const failure = { status: 504, code: "upstream_timeout", message: "voice upstream did not open in time" };
597
- terminalFailure = failure;
598
- capturing = false;
599
- buffered.length = 0;
600
- bufferedBytes = 0;
601
- finish({ ok: false, ...failure, socket });
602
- try {
603
- socket.close();
604
- } catch {
605
- /* ignore */
606
- }
607
- }, timeoutMs);
608
-
609
- const failCapture = (failure: LiveSidebandUpstreamFailure): void => {
610
- if (!capturing || terminalFailure) return;
611
- terminalFailure = failure;
612
- capturing = false;
613
- buffered.length = 0;
614
- bufferedBytes = 0;
615
- finish({ ok: false, ...failure, socket });
616
- try {
617
- socket.close(1009, "sideband preamble overflow");
618
- } catch {
619
- /* the terminal failure is already retained for the downstream handoff */
620
- }
621
- };
622
- const handoff: LiveSidebandUpstreamHandoff = {
623
- failure: () => terminalFailure,
624
- take: () => {
625
- capturing = false;
626
- if (terminalFailure) return { ok: false, failure: terminalFailure };
627
- const frames = buffered.slice();
628
- buffered.length = 0;
629
- bufferedBytes = 0;
630
- return { ok: true, frames };
631
- },
632
- };
633
-
634
- socket.addEventListener("message", event => {
635
- if (!capturing) return;
636
- const frameBytes = webSocketFrameBytes(event.data);
637
- if (exceedsLiveSidebandFrameByteLimit(frameBytes)) {
638
- failCapture({ status: 502, code: "upstream_overflow", message: "voice upstream preamble frame is too large" });
639
- return;
640
- }
641
- if (buffered.length >= LIVE_SIDEBAND_PENDING_MAX) {
642
- failCapture({ status: 502, code: "upstream_overflow", message: "voice upstream sent too many preamble frames" });
643
- return;
644
- }
645
- if (exceedsLiveSidebandPendingByteLimit(bufferedBytes, frameBytes)) {
646
- failCapture({ status: 502, code: "upstream_overflow", message: "voice upstream preamble is too large" });
647
- return;
648
- }
649
- if (typeof event.data === "string") buffered.push(event.data);
650
- else if (event.data instanceof ArrayBuffer) buffered.push(Buffer.from(new Uint8Array(event.data)));
651
- else if (ArrayBuffer.isView(event.data)) {
652
- buffered.push(Buffer.from(new Uint8Array(event.data.buffer, event.data.byteOffset, event.data.byteLength)));
653
- } else return;
654
- bufferedBytes += frameBytes;
655
- });
656
- socket.addEventListener("open", () => {
657
- finish({
658
- ok: true,
659
- socket,
660
- handoff,
661
- });
662
- });
663
- socket.addEventListener("error", () => {
664
- const failure = { status: 502, code: "upstream_error", message: "voice upstream rejected the sideband join" };
665
- terminalFailure ??= failure;
666
- capturing = false;
667
- buffered.length = 0;
668
- bufferedBytes = 0;
669
- finish({ ok: false, ...terminalFailure, socket });
670
- try {
671
- socket.close();
672
- } catch {
673
- /* the terminal failure is already retained */
674
- }
675
- });
676
- socket.addEventListener("close", event => {
677
- const failure = {
678
- status: 502,
679
- code: "upstream_error",
680
- message: `voice upstream closed before opening (code ${event.code})`,
681
- closeCode: event.code,
682
- closeReason: event.reason,
683
- };
684
- terminalFailure ??= failure;
685
- capturing = false;
686
- buffered.length = 0;
687
- bufferedBytes = 0;
688
- finish({ ok: false, ...terminalFailure, socket });
689
- });
690
- const abortOpen = (): void => {
691
- const failure = { status: 499, code: "request_cancelled", message: "voice sideband join was cancelled" };
692
- terminalFailure ??= failure;
693
- capturing = false;
694
- buffered.length = 0;
695
- bufferedBytes = 0;
696
- finish({ ok: false, ...terminalFailure, socket });
697
- try {
698
- socket.close();
699
- } catch {
700
- /* the cancelled join no longer owns the socket */
701
- }
702
- };
703
- if (signal) {
704
- signal.addEventListener("abort", abortOpen, { once: true });
705
- removeAbortListener = () => signal.removeEventListener("abort", abortOpen);
706
- if (signal.aborted) abortOpen();
707
- }
708
- });
709
- }
710
-
711
- export function attachLiveSidebandUpstream(
712
- ws: ServerWebSocket<WsData>,
713
- createWebSocket: LiveSidebandWebSocketFactory = (url, headers, protocols) => (
714
- new WebSocket(url, { headers, protocols } as unknown as string[])
715
- ),
716
- ): void {
717
- if (ws.data.liveAbortSignal?.aborted) {
718
- closeLiveSideband(ws, 1000, "audio connection canceled");
719
- return;
720
- }
721
- const preOpened = ws.data.liveUpstream;
722
- let upstream: WebSocket;
723
- if (preOpened) {
724
- upstream = preOpened;
725
- } else {
726
- const url = ws.data.liveUpstreamUrl;
727
- if (!url) {
728
- closeLiveSideband(ws, 1011, "missing upstream");
729
- return;
730
- }
731
- try {
732
- // Bun accepts per-handshake headers; the DOM lib types only list protocol arrays.
733
- upstream = createWebSocket(url, ws.data.liveUpstreamHeaders ?? {}, ws.data.liveUpstreamProtocols);
734
- } catch {
735
- closeLiveSideband(ws, 1011, "upstream connect failed");
736
- return;
737
- }
738
- }
739
- ws.data.liveUpstream = upstream;
740
- ws.data.liveUpstreamHeaders = undefined;
741
- ws.data.liveUpstreamProtocols = undefined;
742
- ws.data.liveClosing = false;
743
- ws.data.cancel = () => closeLiveSideband(ws, 1000, "client closed");
744
- if (ws.data.liveMaxSessionMs !== undefined) {
745
- ws.data.liveConnectTimer = setTimeout(() => {
746
- ws.data.liveOutcome = "timeout";
747
- closeLiveSideband(ws, 1011, "audio connection timed out");
748
- }, 10_000);
749
- ws.data.liveSessionTimer = setTimeout(() => closeLiveSideband(ws, 1000, "audio session expired"), ws.data.liveMaxSessionMs);
750
- }
751
-
752
- upstream.addEventListener("close", (event) => {
753
- if (ws.data.liveUpstream !== upstream) return;
754
- if (ws.data.liveFinish && !ws.data.liveClosing && event.code !== 1000) ws.data.liveOutcome = "connect_error";
755
- ws.data.liveClosing = true;
756
- finalizeLiveSideband(ws, upstream);
757
- try {
758
- const external = ws.data.liveMaxSessionMs !== undefined;
759
- const validCode = event.code === 1000 || (event.code >= 1001 && event.code <= 1014 && ![1004, 1005, 1006].includes(event.code))
760
- || (event.code >= 3000 && event.code <= 4999);
761
- ws.close(external && !validCode ? 1011 : event.code || 1000, external ? "audio upstream closed" : event.reason || "");
762
- } catch {
763
- /* ignore */
764
- }
765
- });
766
- upstream.addEventListener("error", () => {
767
- if (ws.data.liveUpstream !== upstream) return;
768
- if (ws.data.liveFinish && !ws.data.liveClosing) ws.data.liveOutcome = "connect_error";
769
- closeLiveSideband(ws, 1011, "upstream error");
770
- });
771
- if (ws.data.liveAbortSignal) {
772
- ws.data.liveAbortListener = () => closeLiveSideband(ws, 1000, "audio connection canceled");
773
- ws.data.liveAbortSignal.addEventListener("abort", ws.data.liveAbortListener, { once: true });
774
- if (ws.data.liveAbortSignal.aborted) closeLiveSideband(ws, 1000, "audio connection canceled");
775
- }
776
-
777
- if (preOpened) {
778
- // The upstream opened before this socket existed, so its `open` event has already
779
- // fired and the listener below will never run. Its early frames were captured for
780
- // us; forward the capture now rather than dropping the session preamble.
781
- const handoff = ws.data.liveUpstreamHandoff;
782
- ws.data.liveUpstreamHandoff = undefined;
783
- const takeover = handoff?.take();
784
- if (!takeover?.ok || preOpened.readyState !== WebSocket.OPEN) {
785
- const failure = takeover && !takeover.ok ? takeover.failure : undefined;
786
- closeLiveSideband(
787
- ws,
788
- failure?.closeCode ?? 1011,
789
- failure?.closeReason ?? "upstream closed before relay attachment",
790
- );
791
- return;
792
- }
793
- ws.data.liveOpened = true;
794
- // The upstream opened before this socket existed, so the "open" listener
795
- // below can never fire for it. Disarm the connect watchdog exactly as that
796
- // listener would, or every session with a max lifetime is force-closed ten
797
- // seconds after attach. The session timer stays armed: it bounds the whole
798
- // session, not the connect phase.
799
- if (ws.data.liveConnectTimer !== undefined) clearTimeout(ws.data.liveConnectTimer);
800
- ws.data.liveConnectTimer = undefined;
801
- for (const frame of takeover.frames) {
802
- try {
803
- // Mirror the live message listener exactly: same ceiling, same diagnostic
804
- // record. These frames are upstream-to-client like any other.
805
- if (exceedsLiveSidebandFrameByteLimit(webSocketFrameBytes(frame))) {
806
- closeLiveSideband(ws, 1009, "message too large");
807
- return;
808
- }
809
- logLiveSidebandFrame("u2c", frame);
810
- ws.send(frame);
811
- } catch {
812
- closeLiveSideband(ws, 1011, "client send failed");
813
- return;
814
- }
815
- }
816
- }
817
-
818
- upstream.addEventListener("open", () => {
819
- if (ws.data.liveUpstream !== upstream || ws.data.liveClosing) return;
820
- ws.data.liveOpened = true;
821
- if (ws.data.liveConnectTimer !== undefined) clearTimeout(ws.data.liveConnectTimer);
822
- ws.data.liveConnectTimer = undefined;
823
- // An accepted transport alone does not prove inference/quota recovery.
824
- // Keep healthy closes neutral; explicit transport failures are recorded below.
825
- const pending = ws.data.livePending ?? [];
826
- ws.data.livePending = undefined;
827
- ws.data.livePendingBytes = undefined;
828
- for (const frame of pending) {
829
- try {
830
- sendUpstreamFrame(upstream, frame);
831
- } catch {
832
- closeLiveSideband(ws, 1011, "upstream send failed");
833
- return;
834
- }
835
- }
836
- });
837
- upstream.addEventListener("message", (event) => {
838
- if (ws.data.liveUpstream !== upstream || ws.data.liveClosing) return;
839
- try {
840
- if (exceedsLiveSidebandFrameByteLimit(webSocketFrameBytes(event.data))) {
841
- closeLiveSideband(ws, 1009, "message too large");
842
- return;
843
- }
844
- logLiveSidebandFrame("u2c", event.data);
845
- let sent: number;
846
- if (typeof event.data === "string") sent = ws.send(event.data);
847
- else if (event.data instanceof ArrayBuffer) sent = ws.send(event.data);
848
- else if (ArrayBuffer.isView(event.data)) {
849
- sent = ws.send(event.data.buffer.slice(event.data.byteOffset, event.data.byteOffset + event.data.byteLength));
850
- } else sent = ws.send(event.data as Buffer);
851
- if (ws.data.liveMaxSessionMs !== undefined && (sent === 0 || ws.getBufferedAmount() > MAX_WS_FRAME_BYTES)) {
852
- closeLiveSideband(ws, 1013, "audio client backpressure");
853
- }
854
- } catch {
855
- closeLiveSideband(ws, 1011, "client send failed");
856
- }
857
- });
858
- }
859
-
860
- // GUI static serving extracted to ./server/gui-static. Re-exported below to keep the
861
- // "../src/server" import surface stable for tests/callers.
862
-
863
- // Adapter resolution + wire-protocol override extracted to ./server/adapter-resolve.
864
-
865
- // Source invariant for tests/responses/passthrough-abort.test.ts after the pure module split:
866
- // if (isEventStream && upstreamResponse.body) {
867
- // const repairConfig = route.provider.responsesItemIdRepair;
868
- // const needsClientRewrite = imageGenCallAliases.size > 0
869
- // #314 gated shape: win32 always uses the terminal-aware eager relay so a keep-alive
870
- // upstream cannot hold Codex open after response.completed; darwin no-rewrite traffic
871
- // requires explicit config-eager opt-in (`auto` always stays tee on darwin).
872
- // selectEagerPath(process.platform, needsClientRewrite, config.streamMode ?? "auto")
873
- // Codex upstream WS runtime gating and the forced bounded single-reader branch
874
- // are owned by responses/ws-upstream.ts and responses/core.ts respectively.
875
- // relaySseEagerBounded(upstreamResponse.body, turnAc,
876
- // new Response(eagerBody,
877
- // Default shape (tee + background inspection):
878
- // upstreamResponse.body.tee()
879
- // const repairedBody = hasResponsesItemIdRepair(repairConfig)
880
- // relaySseWithFailedTail(repairedBody, upstream)
881
- // new Response(clientBody
882
- // markNativePassthroughSseResponse
883
- // const body = relayWithAbort(upstreamResponse.body, upstream);
884
- // function responseWithDeferredRequestLog
885
- // isNativePassthroughSseResponse(response)
886
- // trackSseForRequestLog(
887
- // export function relaySseWithHeartbeat
888
-
889
- const REQUEST_LOG_ID_RESPONSE_HEADER = "x-opencodex-request-id";
890
-
891
- function withRequestLogId(response: Response, requestId: string): Response {
892
- const headers = new Headers(response.headers);
893
- headers.set(REQUEST_LOG_ID_RESPONSE_HEADER, requestId);
894
- // A custom `x-` header is not CORS-safelisted, so cross-origin JavaScript gets null from
895
- // `response.headers.get()` even though the header is on the wire. Naming it here is what
896
- // makes the id readable by a browser client — the only caller that needs a correlation id
897
- // it did not send itself.
898
- //
899
- // Appending to whatever `withCors` already set, rather than overwriting, keeps this
900
- // independent of the CORS layer: if the data plane later exposes another header, both
901
- // survive. Duplicate names are harmless, and the header stays absent from responses that
902
- // never reach this wrapper, so no management or rejected-origin response is widened.
903
- const exposed = headers.get("Access-Control-Expose-Headers");
904
- const already = (exposed ?? "")
905
- .split(",")
906
- .some(name => name.trim().toLowerCase() === REQUEST_LOG_ID_RESPONSE_HEADER);
907
- if (!already) {
908
- headers.set(
909
- "Access-Control-Expose-Headers",
910
- exposed ? `${exposed}, ${REQUEST_LOG_ID_RESPONSE_HEADER}` : REQUEST_LOG_ID_RESPONSE_HEADER,
911
- );
912
- }
913
- return new Response(response.body, {
914
- status: response.status,
915
- statusText: response.statusText,
916
- headers,
917
- });
918
- }
919
-
920
- export interface StartServerDeps {
921
- /** Test-only seam; production always initializes its own management credential state. */
922
- managementAuthState?: ManagementAuthState;
923
- /** Test-only route dependencies, forwarded only after management admission succeeds. */
924
- managementApi?: ManagementApiDeps;
925
- /** Test-only native-main recovery dependencies; production constructs the normal manager. */
926
- nativeMainStartup?: NativeMainStartupGateDeps;
927
- /** Test-only ownership evidence; production inspects the installed service state. */
928
- inspectNativeCodexOwnership?: typeof inspectNativeCodexOwnership;
929
- /** Test-only service-home resolver; production resolves the current homes directly. */
930
- resolveServiceHomes?: typeof currentServiceHomes;
931
- /** Test-only seam for an upstream that cannot complete its WebSocket close handshake. */
932
- liveSidebandWebSocketFactory?: LiveSidebandWebSocketFactory;
933
- /** Test-only seam; production derives a fresh local-attestation secret per process. */
934
- localAttestationSecret?: string;
935
- /** Optional readiness gate; a fresh pending gate is created when omitted. */
936
- readinessGate?: ReadinessGate;
937
- /** Test-only package-tree observation; production captures package.json identity at boot. */
938
- packageTreeIntegrity?: PackageTreeIntegrityGuard;
939
- /** Test-only seam for observing quota-worker registration ownership. */
940
- registerCodexQuotaAutoRefreshWorker?: typeof registerCodexQuotaAutoRefreshWorker;
941
- }
942
-
943
- function inspectStartupOwnership(
944
- deps: StartServerDeps,
945
- currentHomes: ReturnType<typeof currentServiceHomes> | null,
946
- statePaths: readonly string[] | null,
947
- windowsTaskListingCache?: ReturnType<typeof createWindowsTaskListingCache>,
948
- ): OwnershipInspection {
949
- try {
950
- if (currentHomes === null || statePaths === null) {
951
- return {
952
- ownership: "unknown",
953
- reason: "startup service-home resolution failed",
954
- };
955
- }
956
- if (deps.inspectNativeCodexOwnership) {
957
- return deps.inspectNativeCodexOwnership({ currentHomes, statePaths, windowsTaskListingCache });
958
- }
959
- return inspectNativeCodexOwnership({ currentHomes, statePaths, windowsTaskListingCache });
960
- } catch {
961
- return {
962
- ownership: "unknown",
963
- reason: "service-home ownership inspection failed",
964
- };
965
- }
966
- }
967
-
968
- /*
969
- * #1046. `startServer` rewrites the Codex models cache during boot, and an
970
- * app-server that started earlier keeps its own in-memory model list. The stale
971
- * warning is not emitted here: `handleStart` runs a catalog sync moments later,
972
- * so warning now would read an mtime that write is about to move, and both sites
973
- * calling the helper independently would warn twice. This records the fact; the
974
- * CLI start path owns the single decision.
975
- *
976
- * A caller that starts a server without `handleStart` (tests, embedded use)
977
- * deliberately gets no warning — lifecycle diagnostics belong to whoever owns
978
- * the lifecycle.
979
- */
980
- let startupCacheInvalidationWrote = false;
981
-
982
- /** #1046: did this process's startup cache invalidation actually write? */
983
- export function consumeStartupCacheInvalidationWrite(): boolean {
984
- const wrote = startupCacheInvalidationWrote;
985
- startupCacheInvalidationWrote = false;
986
- return wrote;
987
- }
988
-
989
- export function warnAgentTaskRecoveryStartup(config: {
990
- agentTaskRecovery?: { enabled?: boolean };
991
- }): void {
992
- if (config.agentTaskRecovery?.enabled !== true) return;
993
- console.warn("⚠️ Experimental encrypted V2 task recovery is enabled.");
994
- 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.");
995
- 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.");
996
- }
997
-
998
- export function warnPlaintextV2AgentMessagesStartup(config: { plaintextV2AgentMessages?: boolean }): void {
999
- if (config.plaintextV2AgentMessages !== true) return;
1000
- console.warn("⚠️ Experimental plaintext V2 agent messages are enabled.");
1001
- 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.");
1002
- console.warn(" This depends on undocumented ChatGPT and Codex behavior; it does not decrypt existing tasks.");
1003
- }
206
+ import { createServeOptions, type ServerIngress } from "./index/serve-options";
207
+ import { inspectStartupOwnership, setStartupCacheInvalidationWrite, warnAgentTaskRecoveryStartup, warnPlaintextV2AgentMessagesStartup, type StartServerDeps } from "./index/startup-warnings";
1004
208
 
1005
209
  export function startServer(port?: number, deps: StartServerDeps = {}): Server<WsData> {
1006
210
  const localAttestationSecret = deps.localAttestationSecret ?? createLocalAttestationSecret();
@@ -1043,7 +247,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
1043
247
  // Resolve unattended service-home authority before any Codex lock, cache, owner,
1044
248
  // journal, or credential path. Both positive foreign evidence and an unprovable
1045
249
  // ownership state are non-authority.
1046
- startupCacheInvalidationWrote = false;
250
+ setStartupCacheInvalidationWrite(false);
1047
251
  const resolveServiceHomes = deps.resolveServiceHomes ?? currentServiceHomes;
1048
252
  let startupOwnershipHomes: ReturnType<typeof currentServiceHomes> | null = null;
1049
253
  let startupOwnershipStatePaths: readonly string[] | null = null;
@@ -1064,11 +268,11 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
1064
268
  startupOwnershipStatePaths,
1065
269
  startupWindowsTaskListingCache,
1066
270
  );
1067
- // Startup cache invalidation is best-effort and must never block the server from
1068
- // serving. It now takes K so it cannot race a convergence commit. Use the home
1069
- // paired with the ownership inspection; re-reading ambient CODEX_HOME here could
1070
- // invalidate a different installation after an environment or mount change.
1071
- if (startupCacheOwnership.ownership === "owned" && startupOwnershipHomes !== null) {
271
+ // Startup cache invalidation is best-effort and applies only when startup sync is enabled.
272
+ // Check OFF before taking K: on Windows K resolves SID + LocalAppData through PowerShell,
273
+ // and run 35093667426 exceeded healthy controls by 33.8 s against that 30 s child budget.
274
+ // The permit callback still re-reads intent under K to close a concurrent disable race.
275
+ if (shouldSyncCodexOnStart(config) && startupCacheOwnership.ownership === "owned" && startupOwnershipHomes !== null) {
1072
276
  try {
1073
277
  const startupCodexHome = startupOwnershipHomes.codexHome;
1074
278
  // #1046: record whether this actually rewrote the cache. `handleStart` ORs this
@@ -1077,7 +281,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
1077
281
  const outcome = withCatalogWriteSerialization(startupCodexHome, permit =>
1078
282
  invalidateCodexModelsCacheWithPermit(permit, startupCodexHome));
1079
283
  // A refused permit is not a write; only a completed run that returned true is.
1080
- startupCacheInvalidationWrote = outcome.kind === "completed" && outcome.value === true;
284
+ setStartupCacheInvalidationWrite(outcome.kind === "completed" && outcome.value === true);
1081
285
  } catch { /* no readable Codex home: nothing to invalidate */ }
1082
286
  }
1083
287
  // Arm the `claudeCode` hand-edit guard (devlog 260726_claude_auth_auto/040 H1) BEFORE
@@ -1290,6 +494,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
1290
494
  req: Request,
1291
495
  policy: RequestPolicyView,
1292
496
  work: (lease: ActiveTurnLease) => Promise<Response>,
497
+ refusalLog?: WorkflowRefusalLog,
1293
498
  ): Promise<Response> {
1294
499
  const lease = tryAdmitTurn(sessionLaneIdFromRequest(req.headers));
1295
500
  if (!lease) return serverBusyResponse(req, "active turns", policy);
@@ -1307,11 +512,9 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
1307
512
  const workflow = admitWorkflowTurn(workflowRootId, workflowLane, undefined, workflowThreadId);
1308
513
  if (workflow && !workflow.admitted) {
1309
514
  lease.release();
1310
- return formatErrorResponse(
1311
- 429,
1312
- workflow.reason === "workflow-sends-exhausted" ? "workflow_budget_exhausted" : "queue_capacity_exceeded",
1313
- "This task has reached its concurrent-work limit, so no further upstream request was made. Work already in flight settles as it finishes.",
1314
- );
515
+ // withCors, because without Access-Control-Allow-Origin the exposed refusal header is
516
+ // still unreadable to a browser dashboard -- which made exposing it pointless.
517
+ return withCors(workflowRefusalResponse(workflow.reason, undefined, refusalLog), req, policy);
1315
518
  }
1316
519
  const releaseWorkflow = (): void => { if (workflow?.admitted) workflow.lease.release(); };
1317
520
  let response: Response;
@@ -1443,7 +646,6 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
1443
646
  );
1444
647
  }
1445
648
 
1446
- type ServerIngress = "public" | "unauthenticated-loopback" | "hub-management";
1447
649
  function ingressForServer(requestServer: Server<WsData>): ServerIngress {
1448
650
  if (requestServer === loopbackServer) return "unauthenticated-loopback";
1449
651
  if (requestServer === managementIngressServer) return "hub-management";
@@ -1478,1746 +680,32 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
1478
680
  // Started inside the guarded startup transaction so the catch below can
1479
681
  // release the owner-scoped lease on any listener failure.
1480
682
  userCostOverlayReconciler = startUserCostOverlayReconciler({ liveConfig: config });
1481
- const serveOptions = {
1482
- idleTimeout: 255,
1483
- // Bun rejects an oversized body before `fetch` runs, so the listener has to be raised
1484
- // with the admission limit or the opt-in would do nothing. Fixed at bind time: a live
1485
- // `maxInboundBodyBytes` edit needs a restart, which the config doc states.
1486
- maxRequestBodySize: inboundBodyLimitBytes,
1487
- async fetch(req: Request, requestServer: Server<WsData>): Promise<Response> {
1488
- const ingress = ingressForServer(requestServer);
1489
- // The unauthenticated loopback listener (#1102) serves a fixed allowlist and nothing
1490
- // else. Rejecting here, before any handler runs, is what keeps the surface from growing
1491
- // silently when a route is added below.
1492
- if (ingress === "unauthenticated-loopback" && !loopbackRouteAllowed(codexCompatibleUrl(req.url), req)) {
1493
- return withCors(
1494
- formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${new URL(req.url).pathname}`),
1495
- req,
1496
- loopbackPolicy(),
1497
- );
1498
- }
1499
- // Tailscale Serve terminates only on this separately bound loopback socket. Reject before
1500
- // dispatch so no data, readiness, health, WebSocket, or unknown-static handler can run.
1501
- if (ingress === "hub-management" && !managementIngressRouteAllowed(codexCompatibleUrl(req.url), req)) {
1502
- return withCors(
1503
- formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${new URL(req.url).pathname}`),
1504
- req,
1505
- config,
1506
- );
1507
- }
1508
- // Auth and CORS decisions below read `policy`, not `config`. For the public listener the
1509
- // two are the same object, so its behaviour is unchanged; for the loopback listener the
1510
- // view substitutes 127.0.0.1 as the bind address, which is what routes it through the
1511
- // same code path a plain loopback bind has always taken — Host-header check included.
1512
- // Routing, provider selection and response bodies keep using `config`.
1513
- const policy: RequestPolicyView = ingress === "unauthenticated-loopback" ? loopbackPolicy() : config;
1514
- const url = codexCompatibleUrl(req.url);
1515
- markActivity(`${req.method} ${url.pathname}`);
1516
-
1517
- // Readiness is exact-GET on the literal /readyz path. Compare the DECODED
1518
- // pathname so an encoded variant like /readyz%2F (which decodes to
1519
- // /readyz/) cannot bypass the exact-path rejection and reach the GUI
1520
- // fallback (serveGuiFile decodes the pathname and would serve index.html
1521
- // with 200). Malformed percent-sequences fall back to the raw pathname,
1522
- // which still cannot match the exact literal below.
1523
- let readyzPath: string | undefined;
1524
- try {
1525
- const decoded = decodeURIComponent(url.pathname);
1526
- if (decoded === "/readyz" || decoded === "/readyz/") readyzPath = decoded;
1527
- } catch { /* malformed encoding — not a readiness path */ }
1528
-
1529
- const packageTreeStatus = packageTreeIntegrity.status();
1530
- if (!packageTreeStatus.ok && (
1531
- url.pathname === "/healthz"
1532
- || readyzPath !== undefined
1533
- || url.pathname.startsWith("/v1/")
1534
- )) {
1535
- const message = "OpenCodex package files changed while this proxy was running; restart OpenCodex before retrying.";
1536
- const response = url.pathname === "/healthz" || readyzPath !== undefined
1537
- ? jsonResponse({
1538
- status: "restart_required",
1539
- service: "opencodex",
1540
- version: VERSION,
1541
- uptime: process.uptime(),
1542
- pid: process.pid,
1543
- port: boundPort ?? requestServer.port ?? listenPort,
1544
- error: { code: "package_tree_changed", message },
1545
- }, 503, req, policy)
1546
- : packageTreeChangedResponse(req, policy, message);
1547
- const headers = new Headers(response.headers);
1548
- headers.set("Retry-After", "5");
1549
- return new Response(response.body, { status: 503, headers });
1550
- }
1551
-
1552
- if (req.method === "OPTIONS") {
1553
- // /readyz is exact-GET only; OPTIONS (like POST and the trailing-slash
1554
- // path) must answer the deterministic JSON 404, never the generic 204
1555
- // preflight response that the SPA fallback would otherwise allow.
1556
- if (readyzPath !== undefined) {
1557
- return withCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, policy);
1558
- }
1559
- const managementPreflight = url.pathname.startsWith("/api/");
1560
- const allowed = managementPreflight
1561
- ? isAllowedManagementOrigin(req, config)
1562
- : isAllowedRequestOrigin(req, policy);
1563
- if (!allowed) {
1564
- return new Response(null, { status: 403, headers: corsHeaders() });
1565
- }
1566
- return new Response(null, {
1567
- status: 204,
1568
- headers: managementPreflight ? managementCorsHeaders(req, config) : corsHeaders(req, policy),
1569
- });
1570
- }
1571
-
1572
- // An OCX-only executor exchanges one short-lived pairing code for a device-scoped
1573
- // token. This is intentionally outside /api: management auth belongs to the browser
1574
- // that created the grant, while the new device owns only that one-time code.
1575
- if (url.pathname === "/remote-workspace/pair" && req.method === "POST") {
1576
- if (!remoteWorkspaceEnabled(config)) {
1577
- return Response.json({ error: "Remote Workspace is not enabled on this OpenCodex instance." }, { status: 404 });
1578
- }
1579
- // Browser JavaScript must use the authenticated dashboard route. Refusing Origin-bearing
1580
- // requests leaves this exchange to an explicit OCX device process and avoids turning a
1581
- // copied pairing code into a cross-site enrollment action.
1582
- if (req.headers.get("origin") !== null) {
1583
- return Response.json({ error: "Remote Workspace device pairing does not accept browser-origin requests." }, {
1584
- status: 403,
1585
- headers: { "cache-control": "no-store" },
1586
- });
1587
- }
1588
- const [{ remoteWorkspaceHubForConfig }, { RemoteWorkspacePairingRateLimitError }] = await Promise.all([
1589
- loadRemoteWorkspaceRuntime(),
1590
- import("../remote-control/workspace-hub"),
1591
- ]);
1592
- if (remoteWorkspaceStopping) return Response.json({ error: "Remote Workspace is stopping." }, { status: 503 });
1593
- const hub = deps.managementApi?.remoteWorkspaceHub ?? remoteWorkspaceHubForConfig(config);
1594
- // A loopback socket alone cannot prove that Tailscale Serve supplied its identity header:
1595
- // another local process can connect directly and forge it. Pairing therefore uses only the
1596
- // kernel-observed peer on every listener; proxied management users intentionally share the
1597
- // loopback bucket rather than gaining a header-rotation bypass.
1598
- const peer = requestServer.requestIP(req)?.address ?? "unknown";
1599
- const pairingSource = `${ingress}:${peer}`;
1600
- const rateLimitResponse = (error: unknown): Response | null => {
1601
- if (!(error instanceof RemoteWorkspacePairingRateLimitError)) return null;
1602
- return Response.json({ error: "Remote Workspace pairing is temporarily rate limited." }, {
1603
- status: 429,
1604
- headers: {
1605
- "cache-control": "no-store",
1606
- "retry-after": String(error.retryAfterSeconds),
1607
- },
1608
- });
1609
- };
1610
- try {
1611
- // Check the existing source block before reading or parsing an attacker-controlled body.
1612
- // pairDevice checks again after the await and records only code-shaped authentication
1613
- // failures, so malformed JSON cannot allocate one limiter entry per request.
1614
- hub.assertPairingSourceAllowed(pairingSource);
1615
- } catch (error) {
1616
- const limited = rateLimitResponse(error);
1617
- if (limited) return limited;
1618
- throw error;
1619
- }
1620
- const declaredLength = Number(req.headers.get("content-length") ?? "0");
1621
- if (!Number.isFinite(declaredLength) || declaredLength > REMOTE_WORKSPACE_PAIRING_BODY_LIMIT) {
1622
- return Response.json({ error: "Remote Workspace pairing body is too large." }, { status: 413 });
1623
- }
1624
- const text = await readBoundedRequestText(req, REMOTE_WORKSPACE_PAIRING_BODY_LIMIT);
1625
- if (text === null) return Response.json({ error: "Remote Workspace pairing body is too large." }, { status: 413 });
1626
- if (remoteWorkspaceStopping) return Response.json({ error: "Remote Workspace is stopping." }, { status: 503 });
1627
- let body: unknown;
1628
- try { body = JSON.parse(text); }
1629
- catch { return Response.json({ error: "Invalid Remote Workspace pairing request." }, { status: 400 }); }
1630
- if (!body || typeof body !== "object" || Array.isArray(body)) {
1631
- return Response.json({ error: "Invalid Remote Workspace pairing request." }, { status: 400 });
1632
- }
1633
- const record = body as Record<string, unknown>;
1634
- const required = ["code", "name", "platform", "publicKey", "roots"];
1635
- const allowed = new Set([...required, "capabilities"]);
1636
- if (required.some(key => !Object.hasOwn(record, key))
1637
- || Object.keys(record).some(key => !allowed.has(key))) {
1638
- return Response.json({ error: "Invalid Remote Workspace pairing request." }, { status: 400 });
1639
- }
1640
- try {
1641
- const paired = hub.pairDevice(record, pairingSource);
1642
- return Response.json(paired, { status: 201, headers: { "cache-control": "no-store" } });
1643
- } catch (error) {
1644
- const limited = rateLimitResponse(error);
1645
- if (limited) return limited;
1646
- const message = error instanceof Error ? error.message : "Remote Workspace pairing failed.";
1647
- const conflict = /already in use|limit reached/i.test(message);
1648
- return Response.json({ error: message }, {
1649
- status: conflict ? 409 : 401,
1650
- headers: { "cache-control": "no-store" },
1651
- });
1652
- }
1653
- }
1654
-
1655
- // Each executor holds one device-scoped bearer and opens one outbound WSS. The token is
1656
- // authenticated only at upgrade and never enters ws.data; subsequent frames are bound to
1657
- // the device identity and per-session signed E2EE handshake.
1658
- if (url.pathname === "/remote-workspace/agent" && req.headers.get("upgrade")?.toLowerCase() === "websocket") {
1659
- if (!remoteWorkspaceEnabled(config) || req.headers.get("origin") !== null) {
1660
- return Response.json({ error: "Remote Workspace agent upgrade refused." }, { status: 403 });
1661
- }
1662
- const authorization = req.headers.get("authorization") ?? "";
1663
- const match = /^Bearer (ocxrw_[A-Za-z0-9_-]{43})$/.exec(authorization);
1664
- if (!match) return Response.json({ error: "Remote Workspace device authentication required." }, { status: 401 });
1665
- const { remoteWorkspaceHubForConfig } = await loadRemoteWorkspaceRuntime();
1666
- const { RemoteWorkspaceHubAgentConnection } = await import("../remote-control/workspace-agent-connection");
1667
- if (remoteWorkspaceStopping) return Response.json({ error: "Remote Workspace is stopping." }, { status: 503 });
1668
- const hub = deps.managementApi?.remoteWorkspaceHub ?? remoteWorkspaceHubForConfig(config);
1669
- const device = hub.authenticateDeviceToken(match[1]!);
1670
- if (!device) return Response.json({ error: "Remote Workspace device authentication failed." }, { status: 401 });
1671
- const upgraded = requestServer.upgrade(req, {
1672
- data: {
1673
- kind: "remote-workspace-agent",
1674
- remoteWorkspaceOpen: socket => {
1675
- const connection = new RemoteWorkspaceHubAgentConnection({
1676
- deviceId: device.id,
1677
- devicePublicKey: device.publicKey,
1678
- hubIdentity: hub.identity(),
1679
- capabilities: device.capabilities,
1680
- onCapabilities: capabilities => hub.updateDeviceCapabilities(device.id, capabilities),
1681
- socket: {
1682
- send: value => {
1683
- if (socket.send(value) === 0) throw new Error("remote workspace socket send dropped");
1684
- },
1685
- close: (code, reason) => socket.close(code, reason),
1686
- },
1687
- });
1688
- hub.attachConnection(device.id, connection);
1689
- socket.data.remoteWorkspaceClose = () => hub.detachConnection(device.id, connection);
1690
- return connection;
1691
- },
1692
- } satisfies WsData,
1693
- });
1694
- return upgraded
1695
- ? undefined as unknown as Response
1696
- : Response.json({ error: "Remote Workspace WebSocket upgrade failed." }, { status: 426 });
1697
- }
1698
-
1699
- // Responses WebSocket (phase 120.2). Codex upgrades the same /v1/responses path; auth is
1700
- // handshake-time only, so capture inbound headers and thread them into the pipeline.
1701
- if (url.pathname === "/v1/responses" && req.headers.get("upgrade")?.toLowerCase() === "websocket") {
1702
- if (isDraining()) {
1703
- return drainingResponse(req, policy);
1704
- }
1705
- const admission = resolveResponsesApiAuth(req, policy);
1706
- if (!admission) {
1707
- return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
1708
- }
1709
- if (!isAllowedRequestOrigin(req, policy)) {
1710
- return withCors(formatErrorResponse(403, "origin_rejected", "WebSocket upgrade blocked: non-local Origin"), req, policy);
1711
- }
1712
- // WS transport gate: Codex's built-in `openai` provider hardcodes supports_websockets=true,
1713
- // so under Design B it always tries the WS transport first. When the feature is off, reject
1714
- // the upgrade with 426 — codex-rs maps a connect-time UPGRADE_REQUIRED to a clean
1715
- // session-scoped HTTP fallback (client.rs WebsocketStreamOutcome::FallbackToHttp) instead of
1716
- // surfacing broken-pipe errors from sockets a "disabled" feature would otherwise accept.
1717
- if (!websocketsEnabled(config)) {
1718
- return withCors(formatErrorResponse(426, "upgrade_required", "Responses WebSocket transport is disabled; use HTTP"), req, policy);
1719
- }
1720
- const websocketLease = tryReserveCodexWebSocket();
1721
- if (!websocketLease) return serverBusyResponse(req, "Codex WebSockets", policy);
1722
- // Upgrade on the server that RECEIVED this request, not the captured `server`
1723
- // binding. They are the same object for the public listener, but the
1724
- // unauthenticated loopback listener (#1102) is a second Bun.serve, and handing its
1725
- // request to the public server's upgrade would fail or cross sockets.
1726
- if (requestServer.upgrade(req, {
1727
- data: buildResponsesWsData(
1728
- selectForwardHeaders(req.headers),
1729
- admission,
1730
- websocketLease,
1731
- sessionLaneIdFromRequest(req.headers),
1732
- ),
1733
- })) return undefined as unknown as Response;
1734
- websocketLease.release();
1735
- return withCors(formatErrorResponse(426, "upgrade_required", "WebSocket upgrade failed"), req, policy);
1736
- }
1737
-
1738
- if (url.pathname === "/healthz" && req.method === "GET") {
1739
- // service/pid/port let CLI liveness reject foreign 200s and verify pid identity.
1740
- const healthPort = server.port ?? listenPort;
1741
- const response = jsonResponse({
1742
- status: "ok",
1743
- service: "opencodex",
1744
- version: VERSION,
1745
- uptime: process.uptime(),
1746
- pid: process.pid,
1747
- port: healthPort,
1748
- restartCapability: SYSTEM_RESTART_CAPABILITY_VERSION,
1749
- providerReloadCapability: LOCAL_PROVIDER_RELOAD_CAPABILITY_VERSION,
1750
- guiPairCapability: GUI_PAIR_CAPABILITY_VERSION,
1751
- }, 200, req, policy);
1752
- const challenge = req.headers.get(LOCAL_ATTESTATION_CHALLENGE_HEADER);
1753
- if (challenge) {
1754
- const proof = createLocalAttestationProof(localAttestationSecret, challenge, process.pid, healthPort);
1755
- if (proof) response.headers.set(LOCAL_ATTESTATION_PROOF_HEADER, proof);
1756
- }
1757
- return response;
1758
- }
1759
-
1760
- // Readiness: like /healthz this is exact GET and unauthenticated (so a client can
1761
- // back off BEFORE knowing the admission token), but stricter than liveness. The
1762
- // body carries only sanitized identity + the fixed status enum; the sync message,
1763
- // warning text, catalog path, provider output, and account data are never exposed.
1764
- // POST or "/readyz/" must NOT match (exact pathname + GET method): answer them
1765
- // with a JSON 404 here so they can never be silently accepted by the GUI SPA
1766
- // fallback (which would serve index.html with HTTP 200 once gui/dist exists).
1767
- if (readyzPath !== undefined) {
1768
- if (readyzPath !== "/readyz" || req.method !== "GET") {
1769
- return withCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, policy);
1770
- }
1771
- // A draining proxy must never advertise ready: every data-plane branch
1772
- // answers drainingResponse while isDraining() is set, but the one-shot
1773
- // readiness gate is not mutated on shutdown (it is owned by the startup
1774
- // sync). Report pending so `ocx ready --wait` and external supervisors
1775
- // keep polling instead of promoting a proxy that is draining.
1776
- const status = isDraining() ? "pending" : readinessGate.getStatus();
1777
- const body = {
1778
- service: "opencodex",
1779
- version: VERSION,
1780
- uptime: process.uptime(),
1781
- pid: process.pid,
1782
- port: boundPort ?? listenPort,
1783
- status,
1784
- ...readyProtocolMetadata(config, req),
1785
- };
1786
- if (status === "ready") {
1787
- return jsonResponse(body, 200, req, policy);
1788
- }
1789
- // Pending/failed: 503 with a conservative Retry-After so well-behaved clients
1790
- // (and `ocx ready --wait`) back off instead of hot-looping.
1791
- const resp = jsonResponse(body, 503, req, policy);
1792
- const headers = new Headers(resp.headers);
1793
- headers.set("Retry-After", "1");
1794
- return new Response(resp.body, { status: 503, headers });
1795
- }
1796
-
1797
- if (url.pathname.startsWith("/api/")) {
1798
- const localManagementAuth = {
1799
- attestationSecret: localAttestationSecret,
1800
- pid: process.pid,
1801
- port: boundPort ?? requestServer.port ?? listenPort,
1802
- };
1803
- const apiAuthError = requireManagementAuth(req, managementAuth, config, localManagementAuth);
1804
- if (apiAuthError) return withManagementCors(apiAuthError, req, config);
1805
- // Which credential passed the gate, resolved from the same session table the
1806
- // gate used. Consent-bearing routes need this: request headers are forgeable
1807
- // by anything holding the admin token, the credential is not.
1808
- const principal = managementPrincipal(req, managementAuth, config, localManagementAuth) ?? undefined;
1809
- if (url.pathname === GUI_PAIR_PATH) {
1810
- if (req.method !== "POST" || principal !== "gui-pair-capability" || !managementAuth.available) {
1811
- return withManagementCors(Response.json({ error: "GUI pairing capability required" }, { status: 403 }), req, config);
1812
- }
1813
- try {
1814
- const grant = createGuiPairingGrant(
1815
- req.headers.get(GUI_PAIR_BROWSER_ORIGIN_HEADER) ?? "",
1816
- config,
1817
- managementAuth,
1818
- );
1819
- return withManagementCors(Response.json(grant, {
1820
- status: 201,
1821
- headers: { "Cache-Control": "no-store" },
1822
- }), req, config);
1823
- } catch (error) {
1824
- const status = error instanceof GuiPairingGrantRateLimitError ? 429 : 403;
1825
- return withManagementCors(Response.json({ error: "GUI pairing grant refused" }, {
1826
- status,
1827
- ...(status === 429 ? { headers: { "Retry-After": "60" } } : {}),
1828
- }), req, config);
1829
- }
1830
- }
1831
- const mgmtResponse = await handleManagementAPI(req, url, config, managementApiDeps, principal, managementSessionControl);
1832
- if (mgmtResponse) return withManagementCors(mgmtResponse, req, config);
1833
- return withManagementCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, config);
1834
- }
1835
-
1836
- if (url.pathname === "/v1/catalog" && (req.method === "GET" || req.method === "HEAD")) {
1837
- // #809: remote Codex clients need the model catalog, and the only prior source was
1838
- // GET /api/catalog behind management auth — so operators had to hand out an admin
1839
- // token to read a list of models. This route fixes that on the data plane instead of
1840
- // widening /api/*, which stays exactly as restricted as before.
1841
- //
1842
- // resolveApiAuth (not resolveResponsesApiAuth) for the same reason /v1/models uses
1843
- // it: nothing here forwards a caller credential upstream, so accepting the dedicated
1844
- // header, a recognized bearer, or x-api-key is safe — and rejecting x-api-key would
1845
- // 401 Anthropic-SDK clients holding a perfectly valid data credential.
1846
- const admission = resolveApiAuth(req, policy);
1847
- if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
1848
- if (!isAllowedRequestOrigin(req, policy)) {
1849
- return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy);
1850
- }
1851
- const { serializePersistedCatalog, persistedCodexVersion, MAX_REMOTE_CATALOG_BYTES } = await import("./catalog-download");
1852
- const serialized = await serializePersistedCatalog();
1853
- if (serialized.body === null) {
1854
- // Built directly rather than through formatErrorResponse: that helper derives
1855
- // `code` from the status and message via classifyError, and these two need stable,
1856
- // specific codes. `catalog_not_found` in particular is what lets a caller — and
1857
- // tests/server/api-key-attribution.test.ts — tell "this route exists and has no catalog"
1858
- // apart from "this route is gone", which is the difference between admission proof
1859
- // and a vacuous pass.
1860
- return withCors(
1861
- new Response(JSON.stringify({
1862
- error: { type: "invalid_request_error", code: "catalog_not_found", message: "no materialized catalog is available" },
1863
- }), {
1864
- status: 404,
1865
- headers: { "content-type": "application/json" },
1866
- }),
1867
- req,
1868
- policy,
1869
- );
1870
- }
1871
- // Size policy belongs to this route, not the shared serializer: the management route
1872
- // must keep its existing behavior for a catalog of any supported size.
1873
- if (serialized.bytes !== undefined && serialized.bytes > MAX_REMOTE_CATALOG_BYTES) {
1874
- return withCors(
1875
- new Response(JSON.stringify({
1876
- error: { type: "server_error", code: "catalog_too_large", message: "catalog exceeds the maximum served size" },
1877
- }), {
1878
- status: 507,
1879
- headers: { "content-type": "application/json" },
1880
- }),
1881
- req,
1882
- policy,
1883
- );
1884
- }
1885
- const headers: Record<string, string> = {
1886
- "content-type": "application/json",
1887
- // Identity-varying content behind a credential: never let a shared cache keep it,
1888
- // and never hand out a validator it could revalidate with. `no-cache` alone does
1889
- // not prevent storage — it forces revalidation, and the revalidation is exactly
1890
- // what would cross identities here, because this body varies by key type and key
1891
- // id while the ETag would be derived from bytes alone. A store keyed on URL plus
1892
- // validator could then serve one credential's representation to another. Proving
1893
- // an identity-partitioned cache key across every intermediary in the path is a
1894
- // much larger commitment than the bandwidth a 304 saves on this payload, so this
1895
- // route declines the trade: no-store, no ETag, no 304.
1896
- //
1897
- // GET /api/catalog keeps its validator. That route is management-authenticated
1898
- // and loopback-scoped, and its representation does not vary by data-key identity.
1899
- "cache-control": "no-store",
1900
- };
1901
- const version = await persistedCodexVersion();
1902
- if (version) headers["x-opencodex-codex-version"] = version;
1903
- // No conditional handling: with no validator emitted, an If-None-Match on this route
1904
- // can only have been guessed or copied from elsewhere, and honoring it would
1905
- // reintroduce the cross-identity path above. Every request gets the full body.
1906
- if (serialized.bytes !== undefined) headers["content-length"] = String(serialized.bytes);
1907
- // HEAD returns identical status and headers with no body.
1908
- return withRemoteCatalogKeyId(
1909
- withCors(
1910
- new Response(req.method === "HEAD" ? null : serialized.body, { status: 200, headers }),
1911
- req,
1912
- policy,
1913
- ),
1914
- admission,
1915
- );
1916
- }
1917
-
1918
- if (url.pathname === "/v1/usage" && req.method === "GET") {
1919
- const { handleHubUsage } = await import("./hub-usage");
1920
- return handleHubUsage(req, config, policy);
1921
- }
1922
-
1923
- if (url.pathname === "/v1/hub-state" && (req.method === "GET" || req.method === "HEAD")) {
1924
- // #4236: a connected client had no way to learn which providers this hub can actually
1925
- // serve, so `ocx status` on the client reported the CLIENT's empty credential store as
1926
- // if it were the truth — "xai ✗ not logged in" on a machine whose hub has xAI logged
1927
- // in. The fix is one least-privilege data-plane read, in the /v1/catalog (#809)
1928
- // tradition: same admission resolver, same origin check, no parameters, no caller
1929
- // credential forwarded upstream, and a body of booleans plus model ids. Widening
1930
- // `/api/*` or handing the client an admin token to read `GET /api/providers` would
1931
- // have traded a reporting defect for a credential one.
1932
- //
1933
- // What it discloses beyond /v1/catalog and /v1/models, exactly: `hasCredential`,
1934
- // `loggedIn`, `authMode`, the featured roster, and the NAME and adapter of an ENABLED
1935
- // provider those routes omit for want of a usable credential — which is the point of
1936
- // the route. A `disabled` provider is NOT exported (`buildHubState` drops it), because
1937
- // the catalog filters it out too and naming it here would be the only place a data key
1938
- // learns of it.
1939
- //
1940
- // Placed between /v1/catalog and /v1/models so all three least-privilege client reads
1941
- // stay in sight of each other.
1942
- const admission = resolveApiAuth(req, policy);
1943
- if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
1944
- if (!isAllowedRequestOrigin(req, policy)) {
1945
- return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy);
1946
- }
1947
- // Role gate AFTER admission, deliberately: answering an unauthenticated caller would
1948
- // turn this into a free "is that machine a hub?" probe. A standalone or client install
1949
- // gains no surface at all — the route simply does not exist there.
1950
- //
1951
- // Built, not formatErrorResponse'd, for the same reason /v1/catalog builds its 404: the
1952
- // code has to distinguish "this route exists and this host is not a hub" from "this
1953
- // build has no such route", which is the difference between admission proof and a
1954
- // vacuous pass in tests/server/api-key-attribution.test.ts.
1955
- if (config.runtimeRole !== "hub") {
1956
- return withCors(
1957
- new Response(JSON.stringify({
1958
- error: {
1959
- type: "invalid_request_error",
1960
- code: "hub_state_not_a_hub",
1961
- message: "hub state is served only by a host whose runtimeRole is hub",
1962
- },
1963
- }), { status: 404, headers: { "content-type": "application/json" } }),
1964
- req,
1965
- policy,
1966
- );
1967
- }
1968
- const { buildHubState } = await import("./hub-state");
1969
- const { MAX_HUB_STATE_BYTES } = await import("../remote/hub-state");
1970
- const { oauthLoginSummary } = await import("../oauth");
1971
- // `true` masks emails, but the projection drops the field entirely; passing the mask
1972
- // anyway means a future refactor that starts copying fields cannot leak a raw address.
1973
- const body = JSON.stringify(buildHubState(config, oauthLoginSummary(true), VERSION));
1974
- const bytes = Buffer.byteLength(body);
1975
- if (bytes > MAX_HUB_STATE_BYTES) {
1976
- return withCors(
1977
- new Response(JSON.stringify({
1978
- error: { type: "server_error", code: "hub_state_too_large", message: "hub state exceeds the maximum served size" },
1979
- }), { status: 507, headers: { "content-type": "application/json" } }),
1980
- req,
1981
- policy,
1982
- );
1983
- }
1984
- return withCors(
1985
- new Response(req.method === "HEAD" ? null : body, {
1986
- status: 200,
1987
- headers: {
1988
- "content-type": "application/json",
1989
- // Varies by credential-bearing identity and by live login state: never cached,
1990
- // and no validator to revalidate with (same rule as /v1/catalog).
1991
- "cache-control": "no-store",
1992
- "content-length": String(bytes),
1993
- },
1994
- }),
1995
- req,
1996
- policy,
1997
- );
1998
- }
1999
-
2000
- if (url.pathname === "/v1/models" && req.method === "GET") {
2001
- // #809: the catalog read sits immediately before model discovery because it shares
2002
- // that route's admission rationale exactly. Keep them adjacent so a future change to
2003
- // one is made in sight of the other.
2004
- // Model discovery never forwards Authorization upstream, so the broader admission
2005
- // set (Authorization / x-api-key / x-opencodex-api-key) is safe here and required by
2006
- // remote OpenAI-style bearer clients and Claude gateway discovery (anthropic-version).
2007
- const admission = resolveApiAuth(req, policy);
2008
- if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
2009
- if (!isAllowedRequestOrigin(req, policy)) {
2010
- return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy);
2011
- }
2012
- const wantsDesktopConfig = url.searchParams.get("format") === "desktop-config";
2013
- if (wantsDesktopConfig && (url.searchParams.get("ids") === "cli" || url.searchParams.has("client_version"))) {
2014
- return jsonResponse({ error: "Desktop config format cannot use CLI or client-version selectors" }, 400, req, policy);
2015
- }
2016
- // The Integrations page reports whether a Cursor client has reached this proxy; the
2017
- // recorder keeps only a bounded User-Agent value and a timestamp, in memory.
2018
- recordCursorSeen(req.headers);
2019
- let goModels;
2020
- let modelEntitlements;
2021
- try {
2022
- [goModels, modelEntitlements] = await Promise.all([
2023
- fetchAllModels(config),
2024
- // Codex sends its own client_version on this request, and upstream filters the
2025
- // entitlement roster by it. Passing it through is what stops an entitled account
2026
- // being told it cannot use models a newer client can (#2886).
2027
- resolveCodexModelEntitlements(config, { clientVersion: url.searchParams.get("client_version") }),
2028
- ]);
2029
- } catch (error) {
2030
- if (error instanceof CatalogGatherBusyError) {
2031
- return withCors(new Response(JSON.stringify({ error: { type: "server_error", code: "catalog_busy", message: error.message } }), {
2032
- status: 503,
2033
- headers: { "content-type": "application/json", "Retry-After": "1" },
2034
- }), req, policy);
2035
- }
2036
- throw error;
2037
- }
2038
- 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");
2039
- const { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } = await import("../codex/catalog/native-models");
2040
- const includeNativeOpenAi = shouldIncludeNativeOpenAi(config);
2041
- const includeAccountBoundNativeOpenAi = shouldIncludeAccountBoundNativeOpenAi(config);
2042
- const bareEligibleAccountIds = providerCodexAccountMode(
2043
- OPENAI_CODEX_PROVIDER_ID,
2044
- config.providers[OPENAI_CODEX_PROVIDER_ID],
2045
- ) === "direct" ? new Set([MAIN_CODEX_ACCOUNT_ID]) : undefined;
2046
- const availableBareGatedNativeSlugs = availableAccountGatedNativeModels(
2047
- modelEntitlements,
2048
- bareEligibleAccountIds,
2049
- );
2050
- const availableAccountGatedNativeSlugs = availableAccountGatedNativeModels(modelEntitlements);
2051
- const availableBareNativeSlugs = NATIVE_OPENAI_MODELS.filter(slug => (
2052
- !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || availableBareGatedNativeSlugs.has(slug)
2053
- ));
2054
- const availableAccountNativeSlugs = NATIVE_OPENAI_MODELS.filter(slug => (
2055
- !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || availableAccountGatedNativeSlugs.has(slug)
2056
- ));
2057
- const nativeSlugs = includeNativeOpenAi
2058
- ? nativeOpenAiSlugs().filter(slug => (
2059
- !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || availableBareGatedNativeSlugs.has(slug)
2060
- ))
2061
- : [];
2062
- const disabledNatives = disabledNativeSlugs(config);
2063
- const disabledModels = new Set(config.disabledModels ?? []);
2064
- const exactComboSlugs = exactComboCatalogSlugs(config);
2065
- const shadowedNativeSlugs = configuredNativeAliasSlugs(config);
2066
- const suppressedBareNativeSlugs = new Set([
2067
- ...desktopAllowlistSuppressedNativeSlugs(config),
2068
- ...[...ACCOUNT_GATED_NATIVE_OPENAI_MODELS].filter(slug => !availableBareGatedNativeSlugs.has(slug)),
2069
- ]);
2070
- const accountSelectors = includeAccountBoundNativeOpenAi
2071
- ? visibleCodexAccountSelectors(config)
2072
- : [];
2073
- const accountTargets = new Map(codexAccountNamespaceEntries(config));
2074
- const accountNativeSlugsBySelector = includeAccountBoundNativeOpenAi
2075
- ? new Map([...accountBoundNativeOpenAiSlugsBySelector(config)].map(([selector, slugs]) => {
2076
- const target = accountTargets.get(selector);
2077
- const accountId = target && isMainCodexAccountTarget(target) ? MAIN_CODEX_ACCOUNT_ID : target;
2078
- return [selector, slugs.filter(slug => (
2079
- !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug)
2080
- || (accountId !== undefined
2081
- && codexModelEntitlementStateForAccount(modelEntitlements, accountId, slug) === "granted")
2082
- ))] as const;
2083
- }))
2084
- : new Map<string, readonly string[]>();
2085
- const accountNativeSlugs = [...new Set(
2086
- [...accountNativeSlugsBySelector.values()].flatMap(slugs => [...slugs]),
2087
- )];
2088
- const desktopInputs = buildDesktopDiscoveryInputs({
2089
- config, models: goModels, modelEntitlements,
2090
- desktopNativeCandidates: desktopVisibleNativeSlugs(config),
2091
- });
2092
- const desktopNativeSlugs = desktopInputs.nativeSlugs;
2093
- const goOrdered = desktopInputs.routedModels;
2094
- // Claude Code / Claude Desktop gateway model discovery (GET /v1/models with
2095
- // Anthropic-style headers; 003 G1-G8 + devlog 131). Entries use the official
2096
- // ModelInfo shape incl. capabilities (effort ladder / thinking) — Desktop 3P can
2097
- // only learn capabilities through discovery, and Claude Code 2.1.207 strips the
2098
- // extra fields (backward-safe). Ids are the claude-opus-4-8-{code} Desktop
2099
- // aliases; legacy claude-ocx-* ids keep decoding via resolveAlias. Detection:
2100
- // anthropic-version header (Claude Code sends it) or explicit ?flavor=anthropic.
2101
- // Codex catalog (client_version) and the OpenAI list shape below stay byte-identical.
2102
- const wantsAnthropicList = wantsDesktopConfig || req.headers.get("anthropic-version") !== null
2103
- || url.searchParams.get("flavor") === "anthropic";
2104
- /**
2105
- * Whether a NATIVE slug may carry a Fast sibling.
2106
- *
2107
- * Both halves are required. Upstream asserts the tier per model — the same
2108
- * `additional_speed_tiers` the Codex picker's own toggle is built from — but an
2109
- * operator capability override or the final wire resolution can still make the
2110
- * route ineligible, and `decideTier` would then drop the tier the row advertised.
2111
- *
2112
- * Declared here, above the Claude discovery call, because that call reads it while
2113
- * the raw OpenAI mapper further down does too; defining it there would leave this
2114
- * use in its temporal dead zone.
2115
- */
2116
- const nativeFastEligible = (metadataId: string): boolean =>
2117
- catalogFastRowEligible(config, { provider: OPENAI_CODEX_PROVIDER_ID, id: metadataId, native: true });
2118
-
2119
- /**
2120
- * Whether a routed catalog row may carry a Fast sibling.
2121
- *
2122
- * A combo is its own namespace with no `config.providers` entry — declaring a
2123
- * provider named `combo` is rejected (combos/types.ts:191) — so provider lookup
2124
- * cannot classify it. Its aggregated `supportsServiceTier` is already true only
2125
- * when EVERY member supports the tier (aggregation.ts:201), which is the right
2126
- * rule for a row that fans out to all of them.
2127
- *
2128
- * Declared beside nativeFastEligible, above the Claude discovery call that reads
2129
- * both; defining it near the raw OpenAI mapper below would leave that use in its
2130
- * temporal dead zone.
2131
- */
2132
- const catalogRowFastEligible = (m: { provider: string; id: string; supportsServiceTier?: boolean }): boolean =>
2133
- catalogFastRowEligible(config, m);
2134
-
2135
- if (wantsAnthropicList && !url.searchParams.has("client_version")) {
2136
- if (wantsDesktopConfig) {
2137
- const models = config.claudeCode?.enabled === false ? [] : generateDesktop3pModels(
2138
- desktopInputs.nativeSlugs, desktopInputs.routedModels,
2139
- config.claudeCode?.desktopProfile, desktopInputs.nativeContextCap,
2140
- );
2141
- const response = jsonResponse({ version: 1, models }, 200, req, policy);
2142
- response.headers.set("Cache-Control", "no-store");
2143
- return response;
2144
- }
2145
- if (config.claudeCode?.enabled === false) return jsonResponse({ data: [] }, 200, req, policy);
2146
- // Build Desktop 3P registry so inbound alias resolution works for subsequent requests.
2147
- buildDesktop3pRegistry(
2148
- desktopNativeSlugs,
2149
- desktopInputs.routedModels,
2150
- config.claudeCode?.desktopProfile,
2151
- desktopInputs.nativeContextCap,
2152
- );
2153
- const { buildAnthropicModelInfos } = await import("../claude/model-info");
2154
- const { resolveAutoContext } = await import("../claude/context-windows");
2155
- const { activeDesktop3pAlias } = await import("../claude/desktop-3p");
2156
- // Per-surface id family (devlog 050): explicit ?ids= wins; otherwise the
2157
- // Claude Code CLI discovery UA (`claude-code/<version>`, binary n_()) gets
2158
- // readable claude-ocx ids and every other client (Desktop 3P) keeps the
2159
- // hashed family its config was written with. Unknown UA -> hashed (safe).
2160
- const idsParam = url.searchParams.get("ids");
2161
- const idStyle = idsParam === "cli"
2162
- ? "readable" as const
2163
- : idsParam === "desktop"
2164
- ? "desktop3p" as const
2165
- : (/^claude-code\//i.test(req.headers.get("user-agent") ?? "") ? "readable" as const : "desktop3p" as const);
2166
- const data = buildAnthropicModelInfos(
2167
- desktopNativeSlugs,
2168
- goOrdered,
2169
- resolveAutoContext(config.claudeCode),
2170
- idStyle,
2171
- activeDesktop3pAlias,
2172
- desktopInputs.nativeContextCap,
2173
- config.fastMode,
2174
- // Explicit opt-out omits the Fast predicate.
2175
- config.fastRows !== false
2176
- ? (model: { provider: string; id: string; supportsServiceTier?: boolean }) =>
2177
- model.provider === "native"
2178
- ? nativeFastEligible(model.id)
2179
- : catalogRowFastEligible(model)
2180
- : undefined,
2181
- { modelPickerOrder: config.modelPickerOrder, featured: config.subagentModels },
2182
- );
2183
- return jsonResponse({ data }, 200, req, policy);
2184
- }
2185
- if (url.searchParams.has("client_version")) {
2186
- // Codex client → Codex catalog shape: native gpt + namespaced routed models,
2187
- // cloned from a native template so required fields (base_instructions, etc.) are present.
2188
- // Pass the subagent picks so featured models lead by priority (matches the on-disk file).
2189
- // Disabled natives stay in the catalog shape with visibility "hide" (mirrors the
2190
- // on-disk sync; codex-rs keeps them out of the picker itself).
2191
- const maMode = config.multiAgentMode === "v1" || config.multiAgentMode === "v2" ? config.multiAgentMode : "default";
2192
- // Account rows use the same hidden-inclusive supported set as on-disk sync. This lets a
2193
- // newly re-enabled native reappear under each selector before the next sync, while the
2194
- // no-selector path keeps nativeOpenAiSlugs()'s existing visibility-sensitive behavior.
2195
- const catalogNativeSlugs = accountSelectors.length > 0
2196
- ? [...new Set([
2197
- ...availableAccountNativeSlugs,
2198
- ...accountNativeSlugs,
2199
- ])]
2200
- : nativeSlugs;
2201
- const entries = buildCatalogEntries(
2202
- loadCatalogTemplate(),
2203
- catalogNativeSlugs,
2204
- goOrdered,
2205
- config.subagentModels,
2206
- websocketsEnabled(config),
2207
- maMode as "v1" | "default" | "v2",
2208
- exactComboSlugs,
2209
- accountSelectors,
2210
- suppressedBareNativeSlugs,
2211
- new Set(),
2212
- nativeContextLimits(config),
2213
- accountNativeSlugs,
2214
- accountNativeSlugsBySelector,
2215
- config.keepNativeChatGptOnV1 === true,
2216
- config.modelPickerOrder,
2217
- );
2218
- return jsonResponse({
2219
- models: applyNativeVisibility(
2220
- entries,
2221
- disabledModels,
2222
- accountSelectors.length > 0,
2223
- new Set(accountNativeSlugs),
2224
- ),
2225
- }, 200, req, policy);
2226
- }
2227
- // OpenAI list shape: native gpt bare + routed models namespaced "<provider>/<id>"
2228
- // (pure availability list — disabled natives are omitted entirely).
2229
- // Grok Build discovers models through this endpoint too, and its model picker only
2230
- // enables /effort for entries that advertise the reasoning ladder in the Grok model
2231
- // catalog shape (supports_reasoning_effort + reasoning_efforts[]). The Codex catalog
2232
- // branch above already carries the same ladders, so mirror them here — native rows
2233
- // from the upstream snapshot, routed rows from the configured provider tiers. The
2234
- // default uses the same canonical fallback as the Codex catalog resolver
2235
- // (configured default, then medium, then high, then the first tier). Extra fields
2236
- // are ignored by plain OpenAI clients.
2237
- const grokEffortOption = (value: string, isDefault: boolean) => ({
2238
- value,
2239
- label: `${value[0].toUpperCase()}${value.slice(1)} Effort`,
2240
- ...(isDefault ? { default: true } : {}),
2241
- });
2242
- const grokEffortFields = (efforts: string[], configuredDefault?: string) => {
2243
- const defaultEffort = grokDefaultReasoningEffort(efforts, configuredDefault);
2244
- if (defaultEffort === undefined) return {};
2245
- return {
2246
- supports_reasoning_effort: true,
2247
- reasoning_effort: defaultEffort,
2248
- reasoning_efforts: efforts.map(effort => grokEffortOption(effort, effort === defaultEffort)),
2249
- };
2250
- };
2251
- // Cursor's local-agent runtime (Private Inference build) reads api_types + capabilities
2252
- // to enable its effort control; every other consumer ignores them. See
2253
- // src/server/models-capabilities.ts.
2254
- const nativeLimits = nativeContextLimits(config);
2255
- const nativeContextInput = (metadataId: string) => {
2256
- const tier = nativeOpenAiContextTier(metadataId, nativeLimits);
2257
- return tier
2258
- ? { contextWindow: tier.defaultWindow, longContextWindow: tier.longWindow }
2259
- : { contextWindow: nativeOpenAiContextWindow(metadataId, nativeLimits) };
2260
- };
2261
- const nativeModelRow = (id: string, metadataId = id) => ({
2262
- id,
2263
- object: "model",
2264
- created: 0,
2265
- owned_by: "openai",
2266
- ...grokEffortFields(
2267
- nativeReasoningEfforts(metadataId),
2268
- nativeDefaultReasoningEffort(metadataId),
2269
- ),
2270
- ...modelCapabilityFields({
2271
- reasoningEfforts: nativeReasoningEfforts(metadataId),
2272
- // Cursor "Max Mode": advertise the family's default/long pair (272k/922k for
2273
- // GPT-5.6) so the client can pick per request; without a tier, the effective
2274
- // window is the only value.
2275
- ...nativeContextInput(metadataId),
2276
- maxOutputTokens: nativeOpenAiMaxOutputTokens(metadataId),
2277
- inputModalities: nativeInputModalities(metadataId),
2278
- }),
2279
- });
2280
- // Resolved once per request, not per model: the global fast switch offers the fast
2281
- // identity to clients that have no Fast toggle of their own. Null when the switch is
2282
- // off, so the row mapper does no work and loads no adapter module.
2283
- const cursorFastIdForListing = config.fastMode === true
2284
- ? await (async () => {
2285
- const { cursorFastIdFor } = await import("../adapters/cursor/catalog");
2286
- return (modelId: string, provider = "cursor") => provider === "cursor" ? cursorFastIdFor(modelId) : undefined;
2287
- })()
2288
- : null;
2289
- // Selector-active discovery follows the same complete supported set as the Codex catalog
2290
- // for both bare and qualified rows. Without selectors, the live catalog continues to own
2291
- // bare availability.
2292
- const selectorNativeSlugs = accountSelectors.length > 0
2293
- ? availableBareNativeSlugs.filter(slug => !disabledNatives.has(slug))
2294
- : [];
2295
- const bareSelectorNativeSlugs = accountSelectors.length > 0
2296
- ? selectorNativeSlugs
2297
- : [];
2298
- const visibleNatives = includeNativeOpenAi
2299
- ? accountSelectors.length > 0
2300
- ? bareSelectorNativeSlugs.filter(slug => !shadowedNativeSlugs.has(slug))
2301
- : visibleNativeSlugs(config)
2302
- : [];
2303
- const visibleAccountNatives = accountSelectors.flatMap(selector =>
2304
- (accountNativeSlugsBySelector.get(selector) ?? []).filter(metadataId => !disabledNatives.has(metadataId)).flatMap(metadataId => {
2305
- const id = `${selector}/${metadataId}`;
2306
- return disabledModels.has(id) ? [] : [{ id, metadataId }];
2307
- })
2308
- );
2309
- // The projection is opt-in. Keep the default path free of Cursor install detection,
2310
- // and resolve the bundle table once for the whole list rather than once per row.
2311
- const effortRowsEnabled = config.cursorEffortRows === true;
2312
- // Explicit opt-out skips policy resolution and additional rows.
2313
- const fastRowsEnabled = config.fastRows !== false;
2314
- // One inventory serves both grammars; building it twice would double the work on a
2315
- // hot path for no benefit.
2316
- const effortRowKnownIds = effortRowsEnabled || fastRowsEnabled
2317
- ? knownEffortRowIds(config)
2318
- : undefined;
2319
- const privateInference = effortRowsEnabled
2320
- ? detectCursorInstalls().find(install => install.build === "private-inference")
2321
- : undefined;
2322
- const cursorEffortTable = effortRowsEnabled
2323
- ? (deps.managementApi?.loadCursorEffortTable ?? loadCursorEffortTable)(privateInference)
2324
- : null;
2325
- const expandedNativeModelRow = (id: string, metadataId = id) => {
2326
- const reasoningEfforts = nativeReasoningEfforts(metadataId);
2327
- return expandCursorEffortRow(nativeModelRow(id, metadataId), reasoningEfforts, config, {
2328
- knownIds: effortRowKnownIds,
2329
- table: cursorEffortTable,
2330
- supportsReasoning: reasoningEfforts.length > 0,
2331
- }).flatMap(row => expandFastRow(
2332
- row,
2333
- // Only the BASE row earns a fast sibling. An effort row already spent the
2334
- // grammar, and the parser requires the stripped base to be routable, so
2335
- // `<base>--<effort>--fast` would publish a row no ingress can resolve.
2336
- row.id === id && nativeFastEligible(metadataId),
2337
- config,
2338
- effortRowKnownIds,
2339
- ));
2340
- };
2341
- const routedRows = await Promise.all(uniqueCatalogModelsForRawPublicList(goOrdered).map(async m => {
2342
- // Same rule as the anthropic branch: with the global fast switch on, a client
2343
- // that has no Fast toggle is offered the fast identity directly. An operator
2344
- // alias is an explicit decision and still wins.
2345
- const fastModelId = cursorFastIdForListing?.(m.id, m.provider);
2346
- const publicId = m.alias ?? `${m.provider}/${fastModelId ?? m.id}`;
2347
- const isCombo = m.provider === "combo" && exactComboSlugs.has(publicId);
2348
- const provider = config.providers[m.provider];
2349
- const effective = provider
2350
- ? (await import("../providers/default-aliases")).effectiveModelAliases(
2351
- config,
2352
- provider,
2353
- knownModelIdsForProvider(m.provider, provider, config),
2354
- ).get(m.id)
2355
- : undefined;
2356
- const row = {
2357
- id: publicId,
2358
- object: "model",
2359
- created: 0,
2360
- // This endpoint is an OpenAI-compatible inbound contract. Some clients use
2361
- // owned_by as an adapter selector, so a virtual combo must name that wire
2362
- // adapter rather than the internal catalog authority marker.
2363
- owned_by: isCombo ? "openai" : (m.owned_by ?? m.provider),
2364
- ...(isCombo ? { is_combo: true } : {}),
2365
- ...(effective ? { alias_of: `${provider?.alias || m.provider}/${effective.alias}` } : {}),
2366
- ...grokEffortFields(m.reasoningEfforts ?? [], m.defaultReasoningEffort),
2367
- ...modelCapabilityFields({
2368
- reasoningEfforts: m.reasoningEfforts,
2369
- // contextWindow is already the post-cap effective value; contextCap is the raw
2370
- // operator knob and over-reports models whose real window sits below it.
2371
- contextWindow: m.contextWindow,
2372
- maxOutputTokens: m.maxOutputTokens,
2373
- inputModalities: m.inputModalities,
2374
- }),
2375
- };
2376
- return expandCursorEffortRow(row, m.reasoningEfforts, config, {
2377
- knownIds: effortRowKnownIds,
2378
- table: cursorEffortTable,
2379
- supportsReasoning: (m.reasoningEfforts ?? []).length > 0,
2380
- }).flatMap(expanded => expandFastRow(
2381
- expanded,
2382
- expanded.id === row.id && catalogRowFastEligible(m),
2383
- config,
2384
- effortRowKnownIds,
2385
- ));
2386
- }));
2387
- const data = [
2388
- ...visibleNatives.flatMap(id => expandedNativeModelRow(id)),
2389
- ...visibleAccountNatives.flatMap(({ id, metadataId }) => expandedNativeModelRow(id, metadataId)),
2390
- ...routedRows.flat(),
2391
- ];
2392
- return jsonResponse({ object: "list", data }, 200, req, policy);
2393
- }
2394
-
2395
- // Remote compaction v1 (codex-rs with Feature::RemoteCompactionV2 off — the default).
2396
- // Must be matched BEFORE the /v1/responses POST branch never sees it (distinct path) and
2397
- // before the /v1/* 404 guard below.
2398
- if (url.pathname === "/v1/responses/compact" && req.method === "POST") {
2399
- if (isDraining()) {
2400
- return drainingResponse(req, policy);
2401
- }
2402
- const admission = resolveResponsesApiAuth(req, policy);
2403
- if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
2404
- if (!isAllowedRequestOrigin(req, policy)) {
2405
- return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy);
2406
- }
2407
- const start = Date.now();
2408
- const requestId = nextRequestLogId(start);
2409
- const logCtx: RequestLogContext = {
2410
- model: "unknown",
2411
- provider: "unknown",
2412
- ...admissionFields(admission),
2413
- inboundProtocol: "responses",
2414
- };
2415
- return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => {
2416
- let response: Response;
2417
- try {
2418
- response = await handleResponsesCompact(req, config, logCtx, turnAdmissionLease, admission, {
2419
- onRequestBodyRead: () => disableResponsesRequestTimeout(req, requestServer),
2420
- });
2421
- } catch {
2422
- response = formatErrorResponse(500, "server_error", "Unexpected compact request failure");
2423
- }
2424
- addFinalRequestLog(requestId, start, logCtx, response.status,
2425
- response.status === 499 ? { closeReason: "client_cancel" } : undefined);
2426
- return withCors(response, req, policy);
2427
- });
2428
- }
2429
-
2430
- if (
2431
- req.method === "POST"
2432
- && (url.pathname === "/v1/images/generations" || url.pathname === "/v1/images/edits")
2433
- ) {
2434
- disableResponsesRequestTimeout(req, requestServer);
2435
- if (isDraining()) {
2436
- return drainingResponse(req, policy);
2437
- }
2438
- const admission = resolveApiAuth(req, policy);
2439
- if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
2440
- if (!isAllowedRequestOrigin(req, policy)) {
2441
- return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy);
2442
- }
2443
- const start = Date.now();
2444
- const requestId = nextRequestLogId(start);
2445
- const logCtx: RequestLogContext = {
2446
- model: "image_gen",
2447
- provider: "unknown",
2448
- ...admissionFields(admission),
2449
- };
2450
- const endpoint = url.pathname.endsWith("/edits") ? "edits" as const : "generations" as const;
2451
- return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => {
2452
- const response = await handleImages(req, config, endpoint, logCtx, turnAdmissionLease);
2453
- addFinalRequestLog(requestId, start, logCtx, response.status, response.status === 499 ? { closeReason: "client_cancel" } : undefined);
2454
- return withCors(response, req, policy);
2455
- });
2456
- }
2457
-
2458
- if (req.method === "GET" && url.pathname.startsWith("/v1/opencodex/artifacts/")) {
2459
- const admission = resolveApiAuth(req, policy);
2460
- if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
2461
- if (!isAllowedRequestOrigin(req, policy)) {
2462
- return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy);
2463
- }
2464
- const id = decodeURIComponent(url.pathname.slice("/v1/opencodex/artifacts/".length));
2465
- const { resolveArtifactPath } = await import("../images/artifacts");
2466
- const artifactPath = resolveArtifactPath(id);
2467
- if (!artifactPath) {
2468
- return withCors(formatErrorResponse(404, "not_found", "artifact not found"), req, policy);
2469
- }
2470
- const file = Bun.file(artifactPath);
2471
- const ext = artifactPath.split(".").pop()?.toLowerCase();
2472
- const contentType =
2473
- ext === "png" ? "image/png"
2474
- : ext === "jpg" || ext === "jpeg" ? "image/jpeg"
2475
- : ext === "webp" ? "image/webp"
2476
- : ext === "gif" ? "image/gif"
2477
- : "application/octet-stream";
2478
- return withCors(new Response(file, {
2479
- status: 200,
2480
- headers: {
2481
- "content-type": contentType,
2482
- "cache-control": "private, max-age=3600",
2483
- "x-content-type-options": "nosniff",
2484
- },
2485
- }), req, policy);
2486
- }
2487
-
2488
- if (contextEndpoint(url.pathname) !== undefined && req.method === "POST" && contextRelayActivated()) {
2489
- // No timeout disable here. The relay is a bounded JSON round trip that owns one deadline
2490
- // from entry; removing the idle timeout first would let an unfinished body hold an
2491
- // admitted turn slot indefinitely, before that deadline ever starts.
2492
- if (isDraining()) {
2493
- return drainingResponse(req, policy);
2494
- }
2495
- const admission = resolveApiAuth(req, policy);
2496
- if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
2497
- if (!isAllowedRequestOrigin(req, policy)) {
2498
- return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy);
2499
- }
2500
- const start = Date.now();
2501
- const requestId = nextRequestLogId(start);
2502
- const logCtx: RequestLogContext = {
2503
- model: "context_history",
2504
- provider: "unknown",
2505
- ...admissionFields(admission),
2506
- };
2507
- return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => {
2508
- const response = await handleContextHistory(req, config, logCtx, contextEndpoint(url.pathname)!,
2509
- turnAdmissionLease, admission, () => resolveApiAuth(req, policy));
2510
- addFinalRequestLog(requestId, start, logCtx, response.status,
2511
- response.status === 499 ? { closeReason: "client_cancel" } : undefined);
2512
- return withCors(response, req, policy);
2513
- });
2514
- }
2515
-
2516
- if (url.pathname === "/v1/alpha/search" && req.method === "POST") {
2517
- disableResponsesRequestTimeout(req, requestServer);
2518
- if (isDraining()) {
2519
- return drainingResponse(req, policy);
2520
- }
2521
- const admission = resolveApiAuth(req, policy);
2522
- if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
2523
- if (!isAllowedRequestOrigin(req, policy)) {
2524
- return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy);
2525
- }
2526
- const start = Date.now();
2527
- const requestId = nextRequestLogId(start);
2528
- const logCtx: RequestLogContext = {
2529
- model: "web_search",
2530
- provider: "unknown",
2531
- ...admissionFields(admission),
2532
- };
2533
- return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => {
2534
- const response = await handleSearch(req, config, logCtx, turnAdmissionLease, admission);
2535
- addFinalRequestLog(requestId, start, logCtx, response.status,
2536
- response.status === 499 ? { closeReason: "client_cancel" } : undefined);
2537
- return withCors(response, req, policy);
2538
- });
2539
- }
2540
-
2541
- if (url.pathname === "/v1/responses" && req.method === "POST") {
2542
- if (isDraining()) {
2543
- return drainingResponse(req, policy);
2544
- }
2545
- const admission = resolveResponsesApiAuth(req, policy);
2546
- if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
2547
- if (!isAllowedRequestOrigin(req, policy)) {
2548
- return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy);
2549
- }
2550
- const start = Date.now();
2551
- const requestId = nextRequestLogId(start);
2552
- const logCtx: RequestLogContext = {
2553
- model: "unknown",
2554
- provider: "unknown",
2555
- ...admissionFields(admission),
2556
- inboundProtocol: "responses",
2557
- };
2558
- if (req.headers.get("x-opencodex-grok") === "1") logCtx.surface = "grok";
2559
- let logged = false;
2560
- const finalizeNativePassthroughLog = (
2561
- status: number,
2562
- meta: { terminalStatus?: ResponsesTerminalStatus; closeReason: "terminal" | "client_cancel" },
2563
- ) => {
2564
- if (logged) return;
2565
- logged = true;
2566
- addFinalRequestLog(requestId, start, logCtx, status, meta);
2567
- };
2568
- return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => {
2569
- const response = await handleResponses(req, config, logCtx, {
2570
- turnAdmissionLease,
2571
- admission,
2572
- onRequestBodyRead: () => disableResponsesRequestTimeout(req, requestServer),
2573
- abortSignal: req.signal,
2574
- onFirstOutput: () => recordFirstOutput(logCtx, start),
2575
- onNativePassthroughTerminal: status => {
2576
- finalizeNativePassthroughLog(httpStatusForRequestLogTerminal(status, logCtx), {
2577
- terminalStatus: status,
2578
- closeReason: "terminal",
2579
- });
2580
- },
2581
- onNativePassthroughCancel: () => {
2582
- finalizeNativePassthroughLog(499, { closeReason: "client_cancel" });
2583
- },
2584
- });
2585
- return withRequestLogId(
2586
- withCors(responseWithDeferredRequestLog(response, requestId, start, logCtx), req, policy),
2587
- requestId,
2588
- );
2589
- });
2590
- }
2591
-
2592
- // Anthropic Messages inbound (Claude Code). count_tokens FIRST (longer path).
2593
- // Claude Code posts `/v1/messages?beta=true` — pathname match ignores the query (003 G9).
2594
- if (url.pathname === "/v1/messages/count_tokens" && req.method === "POST") {
2595
- if (isDraining()) {
2596
- return drainingResponse(req, policy);
2597
- }
2598
- const admission = resolveApiAuth(req, policy);
2599
- if (!admission) {
2600
- return withCors(anthropicErrorResponse(401, "opencodex API key required", "authentication_error"), req, policy);
2601
- }
2602
- if (!isAllowedRequestOrigin(req, policy)) {
2603
- return withCors(anthropicErrorResponse(403, "cross-origin data-plane request blocked", "permission_error"), req, policy);
2604
- }
2605
- return runAdmittedHttpTurn(req, policy, async () => withCors(
2606
- await handleClaudeCountTokens(req, config, policy),
2607
- req,
2608
- policy,
2609
- ));
2610
- }
2611
-
2612
- if (url.pathname === "/v1/messages" && req.method === "POST") {
2613
- disableResponsesRequestTimeout(req, requestServer);
2614
- if (isDraining()) {
2615
- return drainingResponse(req, policy);
2616
- }
2617
- const admission = resolveApiAuth(req, policy);
2618
- if (!admission) {
2619
- return withCors(anthropicErrorResponse(401, "opencodex API key required", "authentication_error"), req, policy);
2620
- }
2621
- if (!isAllowedRequestOrigin(req, policy)) {
2622
- return withCors(anthropicErrorResponse(403, "cross-origin data-plane request blocked", "permission_error"), req, policy);
2623
- }
2624
- const start = Date.now();
2625
- const requestId = nextRequestLogId(start);
2626
- const logCtx: RequestLogContext = {
2627
- model: "unknown",
2628
- provider: "unknown",
2629
- ...admissionFields(admission),
2630
- inboundProtocol: "messages",
2631
- };
2632
- // Logging is finalized inside handleClaudeMessages (Responses-vocab tap on the
2633
- // pre-translation stream + native passthrough callbacks) — do not re-wrap the
2634
- // translated Anthropic stream here.
2635
- return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => withCors(
2636
- await handleClaudeMessages(req, config, logCtx, { requestId, start, turnAdmissionLease, admission }, policy),
2637
- req,
2638
- policy,
2639
- ));
2640
- }
2641
-
2642
-
2643
- // OpenAI Chat Completions inbound (GitHub Copilot App / OpenAI-compatible clients).
2644
- if (url.pathname === "/v1/chat/completions" && req.method === "POST") {
2645
- disableResponsesRequestTimeout(req, requestServer);
2646
- if (isDraining()) {
2647
- return drainingResponse(req, policy);
2648
- }
2649
- const admission = resolveResponsesApiAuth(req, policy);
2650
- if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
2651
- if (!isAllowedRequestOrigin(req, policy)) {
2652
- return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy);
2653
- }
2654
- const start = Date.now();
2655
- const requestId = nextRequestLogId(start);
2656
- const logCtx: RequestLogContext = {
2657
- model: "unknown",
2658
- provider: "unknown",
2659
- ...admissionFields(admission),
2660
- inboundProtocol: "chat",
2661
- };
2662
- // `policy`, not `config`: this route is now served on the unauthenticated loopback
2663
- // listener too (#4236), and only the receiving listener's view produces CORS headers
2664
- // that match the admission decision made above.
2665
- return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => withCors(
2666
- await handleChatCompletions(req, config, logCtx, { requestId, start, turnAdmissionLease, admission }),
2667
- req,
2668
- policy,
2669
- ));
2670
- }
2671
-
2672
- if (url.pathname === "/v1/audio/transcriptions" && req.method === "POST") {
2673
- disableResponsesRequestTimeout(req, requestServer);
2674
- if (isDraining()) return drainingResponse(req, policy);
2675
- const admission = resolveAudioAdmission(req.headers, config);
2676
- if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
2677
- if (!isAllowedRequestOrigin(req, policy)) {
2678
- return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin audio request blocked"), req, policy);
2679
- }
2680
- const start = Date.now();
2681
- const requestId = nextRequestLogId(start);
2682
- const logCtx: RequestLogContext = { model: TRANSCRIPTION_MODEL, provider: "unknown", ...admissionFields(admission) };
2683
- return runAdmittedHttpTurn(req, policy, async lease => {
2684
- const response = await handleAudioTranscriptions(req, config, logCtx, admission, lease);
2685
- addFinalRequestLog(requestId, start, logCtx, response.status);
2686
- return withCors(response, req, policy);
2687
- });
2688
- }
2689
-
2690
- // ChatGPT / Codex App voice (GPT‑Live / Frameless Bidi) + OpenAI Realtime call-create.
2691
- // Clients hit either /v1/live (Frameless App) or /v1/realtime/calls (codex RealtimeCallClient /
2692
- // public Realtime API). Sideband WS joins are handled just below.
2693
- if (
2694
- req.method === "POST"
2695
- && (url.pathname === "/v1/live" || url.pathname === "/v1/realtime/calls")
2696
- ) {
2697
- disableResponsesRequestTimeout(req, requestServer);
2698
- if (isDraining()) {
2699
- return drainingResponse(req, policy);
2700
- }
2701
- const audioClient = resolveAudioClient(req, config);
2702
- if (audioClient instanceof Response) return withCors(audioClient, req, policy);
2703
- const admission = audioClient?.admission ?? resolveApiAuth(req, policy);
2704
- if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
2705
- if (!isAllowedRequestOrigin(req, policy)) {
2706
- return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy);
2707
- }
2708
- const start = Date.now();
2709
- const requestId = nextRequestLogId(start);
2710
- const logCtx: RequestLogContext = {
2711
- model: "gpt-live",
2712
- provider: "unknown",
2713
- ...admissionFields(admission),
2714
- };
2715
- return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => {
2716
- const response = audioClient
2717
- ? await handleExternalLive(req, config, logCtx, { client: audioClient, lease: turnAdmissionLease, bindings: liveCallBindings })
2718
- : await handleLive(req, config, logCtx, turnAdmissionLease);
2719
- addFinalRequestLog(
2720
- requestId,
2721
- start,
2722
- logCtx,
2723
- response.status,
2724
- response.status === 499 ? { closeReason: "client_cancel" } : undefined,
2725
- );
2726
- return withCors(response, req, policy);
2727
- });
2728
- }
2729
-
2730
- // Voice / Realtime WebSocket relay. Sideband joins: Frameless /v1/live/{callId};
2731
- // Realtime v1 /v1/realtime?call_id= (or /v1/realtime/calls/{callId}). Standalone
2732
- // sessions (codex-rs thread/realtime/start, WebSocket transport — the desktop voice
2733
- // path): /v1/realtime?intent=quicksilver&model= and /v1/live?model=.
2734
- // Transparent bidirectional relay.
2735
- const liveSidebandTarget = req.headers.get("upgrade")?.toLowerCase() === "websocket"
2736
- ? parseLiveSidebandTarget(url.pathname, url.searchParams, url.search.replace(/^\?/, ""))
2737
- : null;
2738
- const dictationSocket = url.pathname === "/v1/audio/transcriptions/stream"
2739
- && req.headers.get("upgrade")?.toLowerCase() === "websocket";
2740
- if (liveSidebandTarget || dictationSocket) {
2741
- if (isDraining()) {
2742
- return drainingResponse(req, policy);
2743
- }
2744
- const audioClient = resolveAudioClient(req, config, dictationSocket);
2745
- if (audioClient instanceof Response) return withCors(audioClient, req, policy);
2746
- if (!audioClient && liveSidebandTarget && "callId" in liveSidebandTarget
2747
- && liveSidebandTarget.callId.startsWith(EXTERNAL_CALL_PREFIX)) {
2748
- return withCors(formatErrorResponse(401, "authentication_error", "Live call requires its creator API key"), req, policy);
2749
- }
2750
- const admission = audioClient?.admission ?? resolveApiAuth(req, policy);
2751
- if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
2752
- if (!isAllowedRequestOrigin(req, policy)) {
2753
- return withCors(formatErrorResponse(403, "origin_rejected", "WebSocket upgrade blocked: non-local Origin"), req, policy);
2754
- }
2755
- const start = Date.now();
2756
- const requestId = nextRequestLogId(start);
2757
- const logCtx: RequestLogContext = {
2758
- model: "gpt-live",
2759
- provider: "unknown",
2760
- ...admissionFields(admission),
2761
- };
2762
- const turnAdmissionLease = tryAdmitTurn(sessionLaneIdFromRequest(req.headers));
2763
- if (!turnAdmissionLease) return serverBusyResponse(req, "active turns", policy);
2764
- const audioController = audioClient ? new AbortController() : undefined;
2765
- if (audioController) registerTurn(audioController, turnAdmissionLease);
2766
- const acquisition = audioController
2767
- ? clearableDeadline(120_000, AbortSignal.any([req.signal, audioController.signal])) : undefined;
2768
- const releaseAcquisition = () => {
2769
- acquisition?.clear();
2770
- if (audioController) unregisterTurn(audioController);
2771
- else turnAdmissionLease.release();
2772
- };
2773
- let resolved;
2774
- try {
2775
- resolved = dictationSocket && audioClient
2776
- ? await resolveDictationSocket(audioClient, config, logCtx, turnAdmissionLease, acquisition?.signal)
2777
- : liveSidebandTarget && audioClient
2778
- ? await resolveExternalLiveSocket(audioClient, config, logCtx, liveSidebandTarget, { lease: turnAdmissionLease, bindings: liveCallBindings, signal: acquisition?.signal })
2779
- : liveSidebandTarget
2780
- ? await resolveLiveSidebandUpgrade(req, config, logCtx, liveSidebandTarget, turnAdmissionLease)
2781
- : formatErrorResponse(401, "authentication_error", "opencodex API key required");
2782
- } catch (error) {
2783
- releaseAcquisition();
2784
- throw error;
2785
- }
2786
- if (acquisition?.signal.aborted) {
2787
- try { if (!(resolved instanceof Response) && "finish" in resolved) resolved.finish(); }
2788
- finally { releaseAcquisition(); }
2789
- return withCors(formatErrorResponse(req.signal.aborted ? 499 : acquisition.didExpire() ? 504 : 503,
2790
- "upstream_error", acquisition.didExpire() ? "Audio connection timed out" : "Audio connection canceled"), req, policy);
2791
- }
2792
- if (resolved instanceof Response) {
2793
- releaseAcquisition();
2794
- addFinalRequestLog(requestId, start, logCtx, resolved.status);
2795
- return withCors(resolved, req, policy);
2796
- }
2797
- const audio = "finish" in resolved ? resolved : undefined;
2798
- const finish = audio ? (outcome?: number | "timeout" | "connect_error") => {
2799
- try { audio.finish(outcome); }
2800
- finally { releaseAcquisition(); }
2801
- } : undefined;
2802
- const discardUpgrade = () => {
2803
- if (finish) finish();
2804
- else releaseAcquisition();
2805
- };
2806
- if (req.signal.aborted) {
2807
- discardUpgrade();
2808
- return withCors(formatErrorResponse(499, "client_closed_request", "Audio connection canceled"), req, policy);
2809
- }
2810
- const upstreamHandshake = await openLiveSidebandUpstream(
2811
- resolved.upstreamWsUrl,
2812
- resolved.headers,
2813
- (url, headers) => (deps.liveSidebandWebSocketFactory ?? ((socketUrl, socketHeaders, protocols) => (
2814
- new WebSocket(socketUrl, { headers: socketHeaders, protocols } as unknown as string[])
2815
- )))(url, headers, audio?.protocols),
2816
- LIVE_SIDEBAND_UPSTREAM_OPEN_TIMEOUT_MS,
2817
- req.signal,
2818
- );
2819
- if (!upstreamHandshake.ok) {
2820
- if (upstreamHandshake.socket) {
2821
- closeLiveSidebandBeforeUpgrade(upstreamHandshake.socket, () => discardUpgrade());
2822
- } else {
2823
- discardUpgrade();
2824
- }
2825
- addFinalRequestLog(requestId, start, logCtx, upstreamHandshake.status);
2826
- console.error("[live] sideband upstream handshake failed: " + upstreamHandshake.message);
2827
- return withCors(
2828
- formatErrorResponse(upstreamHandshake.status, upstreamHandshake.code, upstreamHandshake.message),
2829
- req,
2830
- policy,
2831
- );
2832
- }
2833
- const handoffFailure = upstreamHandshake.handoff.failure();
2834
- if (handoffFailure || upstreamHandshake.socket.readyState !== WebSocket.OPEN) {
2835
- closeLiveSidebandBeforeUpgrade(upstreamHandshake.socket, () => discardUpgrade());
2836
- const failure = handoffFailure ?? {
2837
- status: 502,
2838
- code: "upstream_error",
2839
- message: "voice upstream closed before client upgrade",
2840
- };
2841
- addFinalRequestLog(requestId, start, logCtx, failure.status);
2842
- return withCors(formatErrorResponse(failure.status, failure.code, failure.message), req, policy);
2843
- }
2844
- let upgraded = false;
2845
- try {
2846
- upgraded = requestServer.upgrade(req, {
2847
- ...(audioClient?.protocol ? { headers: { "sec-websocket-protocol": audioClient.protocol } } : {}),
2848
- data: {
2849
- kind: "live-sideband",
2850
- liveUpstream: upstreamHandshake.socket,
2851
- liveUpstreamUrl: resolved.upstreamWsUrl,
2852
- liveUpstreamHeaders: resolved.headers,
2853
- liveUpstreamHandoff: upstreamHandshake.handoff,
2854
- admission,
2855
- liveUpstreamProtocols: audio?.protocols,
2856
- liveValidateFrame: audio?.validateFrame,
2857
- liveMaxSessionMs: audio?.maxSessionMs,
2858
- liveFinish: finish,
2859
- liveAbortSignal: audioController?.signal,
2860
- livePending: [],
2861
- livePendingBytes: 0,
2862
- liveOpened: true,
2863
- liveTurnAdmissionLease: turnAdmissionLease,
2864
- } satisfies WsData,
2865
- });
2866
- } catch {
2867
- try {
2868
- upstreamHandshake.handoff.take();
2869
- } catch {
2870
- /* ignore */
2871
- }
2872
- closeLiveSidebandBeforeUpgrade(upstreamHandshake.socket, () => discardUpgrade());
2873
- return withCors(formatErrorResponse(502, "upstream_error", "Audio WebSocket upgrade failed"), req, policy);
2874
- }
2875
- if (upgraded) {
2876
- acquisition?.clear();
2877
- addFinalRequestLog(requestId, start, logCtx, 101);
2878
- return undefined as unknown as Response;
2879
- }
2880
- try {
2881
- upstreamHandshake.handoff.take();
2882
- } catch {
2883
- /* ignore */
2884
- }
2885
- closeLiveSidebandBeforeUpgrade(upstreamHandshake.socket, () => discardUpgrade());
2886
- return withCors(formatErrorResponse(426, "upgrade_required", "WebSocket upgrade failed"), req, policy);
2887
- }
2888
-
2889
- // Data-plane guard: unknown /v1/* paths must fail with JSON 404, never fall through to the
2890
- // GUI static handler (extensionless paths would get index.html with HTTP 200 and codex-rs
2891
- // endpoint clients — memories/*, realtime/* — would surface confusing
2892
- // serde decode errors instead of a clean not-found).
2893
- if (url.pathname.startsWith("/v1/")) {
2894
- return withCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, policy);
2895
- }
2896
-
2897
- if (url.pathname === "/opencodex-session") {
2898
- if (req.method === "GET") {
2899
- const session = issueGuiSession(req, config, managementAuth, {
2900
- trustedTailscaleIngress: ingress === "hub-management",
2901
- });
2902
- return session
2903
- ? withManagementCors(serveSessionBootstrap(session), req, config)
2904
- : withManagementCors(new Response(null, { status: 401, headers: { "Cache-Control": "no-store" } }), req, config);
2905
- }
2906
- if (req.method === "POST") {
2907
- // This endpoint is reachable WITHOUT a credential — that is the point of a pairing
2908
- // exchange — so the body limit has to hold against a caller who controls the
2909
- // framing. A declared Content-Length is a claim, not a bound: omit the header and
2910
- // `Number(null ?? "0")` is 0, send `Transfer-Encoding: chunked` and there is no
2911
- // header at all. Both used to pass the pre-check and land in `req.text()`, which
2912
- // buffers whatever arrives. The post-check then measured a string the process had
2913
- // already been forced to hold.
2914
- //
2915
- // So the declared length is only a cheap early reject, and the real bound is
2916
- // applied while reading: stop at limit+1 bytes and never accumulate more.
2917
- const declaredLength = Number(req.headers.get("content-length") ?? "0");
2918
- if (!Number.isFinite(declaredLength) || declaredLength > GUI_PAIRING_EXCHANGE_BODY_LIMIT) {
2919
- return withManagementCors(Response.json({ error: "pairing exchange body too large" }, { status: 413, headers: { "Cache-Control": "no-store" } }), req, config);
2920
- }
2921
- const bounded = await readBoundedRequestText(req, GUI_PAIRING_EXCHANGE_BODY_LIMIT);
2922
- if (bounded === null) {
2923
- return withManagementCors(Response.json({ error: "pairing exchange body too large" }, { status: 413, headers: { "Cache-Control": "no-store" } }), req, config);
2924
- }
2925
- const text = bounded;
2926
- let body: unknown;
2927
- try {
2928
- body = JSON.parse(text);
2929
- } catch {
2930
- return withManagementCors(Response.json({ error: "invalid pairing exchange body" }, { status: 400, headers: { "Cache-Control": "no-store" } }), req, config);
2931
- }
2932
- if (!body || typeof body !== "object" || Array.isArray(body)
2933
- || Object.keys(body as Record<string, unknown>).length !== 1
2934
- || typeof (body as Record<string, unknown>).grant !== "string") {
2935
- return withManagementCors(Response.json({ error: "invalid pairing exchange body" }, { status: 400, headers: { "Cache-Control": "no-store" } }), req, config);
2936
- }
2937
- const pairing = managementAuth.available
2938
- ? consumeGuiPairingGrant(req, body, config, managementAuth, Date.now(), {
2939
- ingress: ingress === "hub-management" ? "hub-management" : "public",
2940
- peerAddress: requestServer.requestIP(req)?.address ?? null,
2941
- tailscaleUser: ingress === "hub-management" ? req.headers.get("Tailscale-User-Login") : null,
2942
- browserOrigin: req.headers.get("Origin") ?? "",
2943
- })
2944
- : null;
2945
- if (pairing && "allowed" in pairing) {
2946
- return withManagementCors(Response.json({ error: "pairing exchange refused" }, {
2947
- status: 429,
2948
- headers: { "Cache-Control": "no-store", "Retry-After": String(pairing.retryAfterSeconds) },
2949
- }), req, config);
2950
- }
2951
- return pairing
2952
- ? withManagementCors(serveSessionBootstrap(pairing), req, config)
2953
- : withManagementCors(new Response(null, { status: 401, headers: { "Cache-Control": "no-store" } }), req, config);
2954
- }
2955
- return withCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, policy);
2956
- }
2957
- const guiSessionCandidate = req.method === "GET" && (url.pathname === "/" || !url.pathname.includes("."))
2958
- ? issueGuiSession(req, config, managementAuth, {
2959
- trustedTailscaleIngress: ingress === "hub-management",
2960
- })
2961
- : null;
2962
- const guiFile = serveGuiFile(
2963
- url.pathname,
2964
- undefined,
2965
- guiSessionCandidate ?? undefined,
2966
- config.runtimeRole ?? "standalone",
2967
- isApiAuthRequired(config),
2968
- );
2969
- if (guiFile) return guiFile;
2970
- if (url.pathname === "/" && req.method === "GET") {
2971
- return jsonResponse(rootFallbackPayload());
2972
- }
2973
-
2974
- return withCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, config);
2975
- },
2976
- websocket: {
2977
- maxPayloadLength: MAX_WS_FRAME_BYTES,
2978
- idleTimeout: WEBSOCKET_IDLE_TIMEOUT_SECONDS,
2979
- // Responses WebSocket data plane (phase 120.2). Re-frames the same SSE pipeline onto the
2980
- // socket: parse response.create → run handleResponses unchanged → pump its SSE body as WS
2981
- // Text frames. response.processed is a no-op ack. close() aborts the upstream (RC2 parity).
2982
- // Live sideband sockets (kind=live-sideband) are a transparent bidirectional relay instead.
2983
- open(ws: ServerWebSocket<WsData>) {
2984
- if (ws.data.kind === "remote-workspace-agent") {
2985
- const open = ws.data.remoteWorkspaceOpen;
2986
- if (!open) {
2987
- ws.close(1011, "remote workspace connection unavailable");
2988
- return;
2989
- }
2990
- try {
2991
- ws.data.remoteWorkspaceConnection = open(ws);
2992
- } catch {
2993
- ws.close(1011, "remote workspace connection failed");
2994
- }
2995
- return;
2996
- }
2997
- if (ws.data.kind === "live-sideband") {
2998
- if (!ws.data.liveTurnAdmissionLease) {
2999
- closeLiveSideband(ws, 1013, "server busy");
3000
- return;
3001
- }
3002
- attachLiveSidebandUpstream(ws, deps.liveSidebandWebSocketFactory);
3003
- return;
3004
- }
3005
- if (!ws.data.admissionLease) {
3006
- ws.close(1013, "server busy");
3007
- return;
3008
- }
3009
- ws.data.admissionLease.bind(ws);
3010
- registerCodexWebSocket(ws);
3011
- },
3012
- message(ws: ServerWebSocket<WsData>, raw: string | Buffer) {
3013
- if (ws.data.kind === "remote-workspace-agent") {
3014
- try {
3015
- ws.data.remoteWorkspaceConnection?.receive(raw);
3016
- } catch {
3017
- ws.close(1008, "remote workspace protocol error");
3018
- }
3019
- return;
3020
- }
3021
- if (ws.data.kind === "live-sideband") {
3022
- if (ws.data.liveClosing) return;
3023
- if (ws.data.liveValidateFrame && !ws.data.liveValidateFrame(raw)) {
3024
- closeLiveSideband(ws, 1008, "invalid audio event");
3025
- return;
3026
- }
3027
- const rawBytes = webSocketFrameBytes(raw);
3028
- if (exceedsLiveSidebandFrameByteLimit(rawBytes)) {
3029
- closeLiveSideband(ws, 1009, "message too large");
3030
- return;
3031
- }
3032
- logLiveSidebandFrame("c2u", raw);
3033
- const upstream = ws.data.liveUpstream;
3034
- if (!upstream || upstream.readyState === WebSocket.CONNECTING || !ws.data.liveOpened) {
3035
- const enqueueResult = enqueueLiveSidebandPendingFrame(ws.data, raw, rawBytes);
3036
- if (enqueueResult === "too-many-frames") {
3037
- closeLiveSideband(ws, 1009, "too many pending frames");
3038
- return;
3039
- }
3040
- if (enqueueResult === "too-many-bytes") {
3041
- closeLiveSideband(ws, 1009, "too many pending bytes");
3042
- return;
3043
- }
3044
- return;
3045
- }
3046
- if (upstream.readyState !== WebSocket.OPEN) {
3047
- closeLiveSideband(ws, 1011, "upstream not open");
3048
- return;
3049
- }
3050
- try {
3051
- sendUpstreamFrame(upstream, raw);
3052
- if (ws.data.liveMaxSessionMs !== undefined && upstream.bufferedAmount > MAX_WS_FRAME_BYTES) {
3053
- closeLiveSideband(ws, 1013, "audio upstream backpressure");
3054
- }
3055
- } catch {
3056
- closeLiveSideband(ws, 1011, "upstream send failed");
3057
- }
3058
- return;
3059
- }
3060
- const rawBytes = typeof raw === "string" ? Buffer.byteLength(raw) : raw.byteLength;
3061
- if (rawBytes > MAX_WS_FRAME_BYTES) {
3062
- sendJsonFrame(ws, buildWsErrorFrame(413, {
3063
- type: "invalid_request_error",
3064
- message: "WebSocket response.create frame is too large",
3065
- }));
3066
- ws.close(1009, "message too large");
3067
- return;
3068
- }
3069
- let frame: Record<string, unknown>;
3070
- try {
3071
- frame = JSON.parse(typeof raw === "string" ? raw : raw.toString()) as Record<string, unknown>;
3072
- } catch {
3073
- return; // text-only contract; ignore unparseable frames
3074
- }
3075
- if (frame.type === "response.processed") return; // ack — no-op
3076
- if (frame.type !== "response.create") return;
3077
- markActivity("ws response.create");
3078
-
3079
- ws.data.cancel?.();
3080
- const turnId = (ws.data.turnId ?? 0) + 1;
3081
- ws.data.turnId = turnId;
3082
- const isCurrent = () => ws.data.turnId === turnId;
3083
- const turnAbort = new AbortController();
3084
- const cancelTurn = () => {
3085
- turnAbort.abort("websocket turn superseded or closed");
3086
- };
3087
- ws.data.cancel = cancelTurn;
3088
- // A socket may carry several response.create frames. Clear the previous
3089
- // account before resolving this frame so a failed Multi resolution cannot
3090
- // leave stale invalidation ownership behind.
3091
- updateCodexWebSocketAuthContext(ws, undefined);
3092
-
3093
- if (frame.generate === false) {
3094
- for (const payload of buildWarmupCompletionFrames(frame)) {
3095
- if (!isCurrent()) return;
3096
- sendTextFrame(ws, payload);
3097
- }
3098
- if (ws.data.cancel === cancelTurn) ws.data.cancel = undefined;
3099
- return;
3100
- }
3101
-
3102
- const turnAdmissionLease = tryAdmitTurn(ws.data.sessionLaneId);
3103
- if (!turnAdmissionLease) {
3104
- sendJsonFrame(ws, buildWsErrorFrame(503, {
3105
- type: "server_error",
3106
- code: "server_busy",
3107
- message: "active turns capacity reached",
3108
- retryable: true,
3109
- }, new Headers({ "Retry-After": "1" })));
3110
- if (ws.data.cancel === cancelTurn) ws.data.cancel = undefined;
3111
- return;
3112
- }
3113
-
3114
- const payload: Record<string, unknown> = { ...frame };
3115
- delete payload.type;
3116
- turnAdmissionLease.bindAbortController(turnAbort);
3117
- void (async () => {
3118
- const start = Date.now();
3119
- const requestId = nextRequestLogId(start);
3120
- // Resolved once at the handshake — a frame has no request headers left
3121
- // to re-resolve from. Optional on WsData like every other member, so
3122
- // narrow rather than assume: an unattributed frame is preferable to a
3123
- // fabricated attribution.
3124
- const wsAdmission = ws.data.admission;
3125
- const logCtx: RequestLogContext = {
3126
- model: "unknown",
3127
- provider: "unknown",
3128
- ...(wsAdmission ? admissionFields(wsAdmission) : {}),
3129
- inboundProtocol: "responses",
3130
- };
3131
- let logged = false;
3132
- const finalizeLog = (
3133
- status: number,
3134
- meta?: Pick<RequestLogEntry, "terminalStatus" | "closeReason">,
3135
- ) => {
3136
- if (logged) return;
3137
- logged = true;
3138
- addFinalRequestLog(requestId, start, logCtx, status, meta);
3139
- };
3140
- const baseHeaders = ws.data.headers ?? new Headers();
3141
- const fwd = new Headers({ "content-type": "application/json" });
3142
- baseHeaders.forEach((value, key) => fwd.set(key, value));
3143
- const req = new Request("http://localhost/v1/responses", {
3144
- method: "POST",
3145
- headers: fwd,
3146
- body: JSON.stringify({ ...payload, stream: true }),
3147
- });
3148
- try {
3149
- let terminalRecorder: ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined;
3150
- const response = await handleResponses(req, config, logCtx, {
3151
- ...(wsAdmission ? { admission: wsAdmission } : {}),
3152
- forceEmptyResponseId: true,
3153
- inboundTransport: "websocket",
3154
- abortSignal: turnAbort.signal,
3155
- turnAdmissionLease,
3156
- onFirstOutput: () => recordFirstOutput(logCtx, start),
3157
- onCodexAuthContextResolved: context => updateCodexWebSocketAuthContext(ws, context),
3158
- recordTerminalOutcomes: false,
3159
- setTerminalOutcomeRecorder: recorder => {
3160
- terminalRecorder = recorder;
3161
- },
3162
- });
3163
- await sendResponseToWebSocket(ws, response, isCurrent, {
3164
- onSsePayload: payload => inspectResponseLogSsePayload(logCtx, payload),
3165
- onTerminal: status => {
3166
- terminalRecorder?.(status, logCtx.terminalHttpStatus);
3167
- finalizeLog(httpStatusForRequestLogTerminal(status, logCtx), {
3168
- terminalStatus: status,
3169
- closeReason: "terminal",
3170
- });
3171
- },
3172
- });
3173
- if (!logged) finalizeLog(turnAbort.signal.aborted ? 499 : response.status);
3174
- } catch (err) {
3175
- if (!isCurrent()) return;
3176
- try {
3177
- if (err instanceof CodexAccountCooldownError) {
3178
- finalizeLog(429);
3179
- // Codex Desktop rides this WS transport, so it must carry the same
3180
- // actionable text as HTTP; a frame has no headers, hence message-only.
3181
- const accountSelector = typeof payload.model === "string"
3182
- ? codexAccountNamespaceForModel(config.codexAccountNamespaces, payload.model)
3183
- : undefined;
3184
- sendJsonFrame(ws, buildWsErrorFrame(429, {
3185
- type: "rate_limit_error",
3186
- message: cooldownErrorMessage(err, accountSelector),
3187
- }));
3188
- return;
3189
- }
3190
- finalizeLog(502);
3191
- sendJsonFrame(ws, buildWsErrorFrame(502, {
3192
- type: "proxy_error",
3193
- message: err instanceof Error ? err.message : String(err),
3194
- }));
3195
- } catch {
3196
- /* socket already gone or send dropped */
3197
- }
3198
- } finally {
3199
- turnAdmissionLease.release();
3200
- if (!logged && turnAbort.signal.aborted) finalizeLog(499);
3201
- if (ws.data.cancel === cancelTurn) ws.data.cancel = undefined;
3202
- }
3203
- })();
3204
- },
3205
- close(ws: ServerWebSocket<WsData>) {
3206
- if (ws.data.kind === "remote-workspace-agent") {
3207
- ws.data.remoteWorkspaceClose?.();
3208
- return;
3209
- }
3210
- if (ws.data.kind === "live-sideband") {
3211
- closeLiveSideband(ws);
3212
- return;
3213
- }
3214
- unregisterCodexWebSocket(ws);
3215
- ws.data.admissionLease?.release();
3216
- ws.data.admissionLease = undefined;
3217
- ws.data.cancel?.(); // RC2: abort the upstream when the client disconnects
3218
- },
3219
- },
3220
- } as const;
683
+ const serveOptions = createServeOptions({
684
+ drainingResponse,
685
+ ingressForServer,
686
+ loopbackRouteAllowed,
687
+ managementIngressRouteAllowed,
688
+ packageTreeChangedResponse,
689
+ serverBusyResponse,
690
+ runAdmittedHttpTurn,
691
+ config,
692
+ inboundBodyLimitBytes,
693
+ listenPort,
694
+ liveCallBindings,
695
+ loadRemoteWorkspaceRuntime,
696
+ localAttestationSecret,
697
+ loopbackPolicy,
698
+ managementApiDeps,
699
+ managementAuth,
700
+ managementSessionControl,
701
+ packageTreeIntegrity,
702
+ readinessGate,
703
+ deps,
704
+ port,
705
+ get server() { return server; },
706
+ get boundPort() { return boundPort; },
707
+ get remoteWorkspaceStopping() { return remoteWorkspaceStopping; },
708
+ });
3221
709
 
3222
710
  server = Bun.serve<WsData>({ ...serveOptions, port: listenPort, hostname: bindHost });
3223
711
 
@@ -3398,3 +886,8 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
3398
886
 
3399
887
  return server;
3400
888
  }
889
+
890
+ export { LIVE_SIDEBAND_UPSTREAM_OPEN_TIMEOUT_MS, MAX_WS_FRAME_BYTES, attachLiveSidebandUpstream, enqueueLiveSidebandPendingFrame, exceedsLiveSidebandFrameByteLimit, exceedsLiveSidebandPendingByteLimit, openLiveSidebandUpstream } from "./index/live-sideband";
891
+ export type { LiveSidebandPendingEnqueueResult, LiveSidebandUpstreamOpenResult } from "./index/live-sideband";
892
+ export { consumeStartupCacheInvalidationWrite, warnAgentTaskRecoveryStartup, warnPlaintextV2AgentMessagesStartup } from "./index/startup-warnings";
893
+ export type { StartServerDeps } from "./index/startup-warnings";