@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.
- package/bin/ocx.mjs +10 -0
- package/gui/dist/assets/{index-BBOZWGB6.css → index-C5-RdDmD.css} +1 -1
- package/gui/dist/assets/{index-VuoiWj9J.js → index-Cz7CLdif.js} +21 -21
- package/gui/dist/index.html +2 -2
- package/package.json +4 -3
- package/src/adapters/base.ts +21 -0
- package/src/adapters/codebuddy/adapter.ts +2 -1
- package/src/adapters/codebuddy/scaffold-guard.ts +248 -0
- package/src/adapters/command-code.ts +1 -1
- package/src/adapters/cursor/envelope-echo.ts +8 -2
- package/src/adapters/cursor/transport-retry.ts +46 -1
- package/src/adapters/cursor.ts +4 -0
- package/src/adapters/google.ts +7 -7
- package/src/adapters/kiro/adapter.ts +42 -1
- package/src/adapters/kiro/payload.ts +17 -3
- package/src/adapters/kiro/reasoning.ts +70 -7
- package/src/adapters/kiro/stream.ts +8 -2
- package/src/adapters/kiro/wire.ts +2 -1
- package/src/adapters/kiro-events.ts +21 -13
- package/src/adapters/kiro-retry.ts +23 -4
- package/src/adapters/openai-chat/errors.ts +116 -0
- package/src/adapters/openai-chat/messages.ts +346 -0
- package/src/adapters/openai-chat/passthrough.ts +146 -0
- package/src/adapters/openai-chat/response-events.ts +117 -0
- package/src/adapters/openai-chat/tool-call-validation.ts +200 -0
- package/src/adapters/openai-chat/tool-name-registry.ts +166 -0
- package/src/adapters/openai-chat/tool-schema.ts +495 -0
- package/src/adapters/openai-chat/wire.ts +50 -0
- package/src/adapters/openai-chat.ts +40 -1452
- package/src/adapters/openai-responses/canonical-forward.ts +202 -0
- package/src/adapters/openai-responses/image-gen.ts +406 -0
- package/src/adapters/openai-responses/internal.ts +3 -0
- package/src/adapters/openai-responses/passthrough.ts +642 -0
- package/src/adapters/openai-responses/prompt-cache.ts +83 -0
- package/src/adapters/openai-responses/reasoning.ts +220 -0
- package/src/adapters/openai-responses/request-strips.ts +185 -0
- package/src/adapters/openai-responses/tool-output-recovery.ts +509 -0
- package/src/adapters/openai-responses/tool-schema.ts +293 -0
- package/src/adapters/openai-responses/web-search.ts +156 -0
- package/src/adapters/openai-responses.ts +4 -2625
- package/src/bridge/errors.ts +58 -0
- package/src/bridge/internal.ts +174 -0
- package/src/bridge/response-json.ts +630 -0
- package/src/bridge/sse.ts +1462 -0
- package/src/bridge.ts +5 -2204
- package/src/chat/inbound.ts +12 -1
- package/src/claude/desktop-profile.ts +66 -9
- package/src/claude/outbound.ts +18 -0
- package/src/cli/account-main.ts +1 -1
- package/src/cli/capabilities.ts +2 -2
- package/src/cli/combo.ts +10 -1
- package/src/cli/index.ts +48 -5
- package/src/cli/registry.ts +2 -1
- package/src/cli/system-command.ts +4 -4
- package/src/clients/config-export.ts +7 -3
- package/src/codex/account-label.ts +14 -3
- package/src/codex/account-lifecycle.ts +3 -0
- package/src/codex/account-store.ts +184 -35
- package/src/codex/account-usability.ts +21 -0
- package/src/codex/auth-api/account-list.ts +507 -0
- package/src/codex/auth-api/http.ts +32 -0
- package/src/codex/auth-api/login-flow.ts +566 -0
- package/src/codex/auth-api/login-state.ts +64 -0
- package/src/codex/auth-api/main-account-probe.ts +331 -0
- package/src/codex/auth-api/pool-mode-gate.ts +274 -0
- package/src/codex/auth-api/pool-quota-probe.ts +512 -0
- package/src/codex/auth-api/reset-credit-service.ts +431 -0
- package/src/codex/auth-api/routes.ts +425 -0
- package/src/codex/auth-api/runtime-config.ts +48 -0
- package/src/codex/auth-api.ts +27 -3118
- package/src/codex/auth-context.ts +252 -35
- package/src/codex/catalog/aggregation.ts +80 -1
- package/src/codex/catalog/auto-review.ts +507 -0
- package/src/codex/catalog/build-entries.ts +981 -0
- package/src/codex/catalog/combo-member.ts +375 -0
- package/src/codex/catalog/derive-entry.ts +229 -0
- package/src/codex/catalog/effort.ts +0 -1
- package/src/codex/catalog/gated-native-warn.ts +63 -0
- package/src/codex/catalog/gather-capture.ts +533 -0
- package/src/codex/catalog/model-hints.ts +691 -0
- package/src/codex/catalog/model-visibility.ts +305 -0
- package/src/codex/catalog/provider-fetch.ts +52 -2942
- package/src/codex/catalog/provider-models.ts +685 -0
- package/src/codex/catalog/remote.ts +30 -0
- package/src/codex/catalog/restore.ts +132 -0
- package/src/codex/catalog/retained-sync.ts +714 -0
- package/src/codex/catalog/routed-gather.ts +895 -0
- package/src/codex/catalog/subagent-roster.ts +176 -0
- package/src/codex/catalog/sync.ts +52 -2698
- package/src/codex/cli-install-provenance.ts +7 -1
- package/src/codex/convergence.ts +7 -2
- package/src/codex/desktop-app/types.ts +11 -2
- package/src/codex/desktop-app/windows.ts +5 -5
- package/src/codex/inject/config-toml.ts +563 -0
- package/src/codex/inject/remove.ts +192 -0
- package/src/codex/inject/restore.ts +567 -0
- package/src/codex/inject/routing-classify.ts +109 -0
- package/src/codex/inject/routing-target.ts +125 -0
- package/src/codex/inject.ts +89 -1444
- package/src/codex/lineage.ts +458 -0
- package/src/codex/model-entitlements.ts +152 -15
- package/src/codex/pool-refresh-backoff.ts +161 -0
- package/src/codex/quota-rejection.ts +104 -15
- package/src/codex/routing/active-account.ts +194 -0
- package/src/codex/routing/cache-affinity.ts +70 -0
- package/src/codex/routing/cooldown-math.ts +285 -0
- package/src/codex/routing/health-store.ts +402 -0
- package/src/codex/routing/probe-lease.ts +358 -0
- package/src/codex/routing/selection.ts +780 -0
- package/src/codex/routing/thread-affinity.ts +586 -0
- package/src/codex/routing/transient-hold-dispatch.ts +141 -0
- package/src/codex/routing.ts +370 -2271
- package/src/codex/shim-fingerprint.ts +223 -0
- package/src/codex/shim-inspect.ts +175 -0
- package/src/codex/shim-probe.ts +367 -0
- package/src/codex/shim-restore-lock.ts +169 -0
- package/src/codex/shim-state-file.ts +151 -0
- package/src/codex/shim-templates.ts +265 -0
- package/src/codex/shim.ts +48 -1268
- package/src/codex/warmup.ts +1 -1
- package/src/combos/failover.ts +85 -0
- package/src/combos/request.ts +17 -10
- package/src/combos/types.ts +23 -2
- package/src/config/diagnostics.ts +705 -0
- package/src/config/feature-flags.ts +55 -0
- package/src/config/live-reconcile.ts +403 -0
- package/src/config/load-degrade.ts +880 -0
- package/src/config/mutation-lock.ts +244 -0
- package/src/config/openai-tier-backup.ts +268 -0
- package/src/config/pending-teardown.ts +31 -0
- package/src/config/persist-unlocked.ts +92 -0
- package/src/config/proxy-env.ts +188 -0
- package/src/config/salvage.ts +244 -0
- package/src/config/schema/config-schema.ts +640 -0
- package/src/config/schema/leaf-validators.ts +855 -0
- package/src/config/warn-memo.ts +28 -0
- package/src/config.ts +234 -4481
- package/src/generated/compatibility-version.json +649 -121
- package/src/images/loop.ts +1 -1
- package/src/lib/errors.ts +17 -0
- package/src/lib/request-execution-budget.ts +198 -23
- package/src/lib/spend-reservation-ledger.ts +958 -0
- package/src/lib/state-store-registrations.ts +6 -2
- package/src/lib/test-home-guard.ts +85 -1
- package/src/lib/upstream-retry.ts +132 -21
- package/src/lib/windows-elevation.ts +76 -14
- package/src/lib/workflow-budget.ts +553 -30
- package/src/oauth/index.ts +2 -2
- package/src/oauth/key-providers.ts +2 -2
- package/src/providers/kiro-models.ts +4 -3
- package/src/providers/label.ts +19 -1
- package/src/providers/model-discovery.ts +16 -0
- package/src/providers/quota/account-cache.ts +441 -0
- package/src/providers/quota/antigravity.ts +295 -0
- package/src/providers/quota/report-cache.ts +320 -0
- package/src/providers/quota/vendor-probes-key.ts +1243 -0
- package/src/providers/quota/vendor-probes-oauth.ts +590 -0
- package/src/providers/quota.ts +324 -3079
- package/src/providers/registry/entries-core.ts +1228 -0
- package/src/providers/registry/entries-extended.ts +1213 -0
- package/src/providers/registry/model-seeds.ts +912 -0
- package/src/providers/registry/types.ts +352 -0
- package/src/providers/registry.ts +24 -3536
- package/src/responses/continuation-ownership.ts +29 -0
- package/src/responses/reasoning-envelope.ts +6 -3
- package/src/responses/state/replay-fingerprint.ts +80 -0
- package/src/responses/state/snapshot-codec.ts +104 -0
- package/src/responses/state/spill-failure.ts +118 -0
- package/src/responses/state/spill-queue.ts +665 -0
- package/src/responses/state/temp-recovery.ts +257 -0
- package/src/responses/state.ts +82 -1143
- package/src/routing/identity-domains.ts +456 -0
- package/src/routing/probe-lease.ts +613 -0
- package/src/server/chat-completions.ts +3 -1
- package/src/server/chat-native.ts +37 -9
- package/src/server/index/bounded-request.ts +88 -0
- package/src/server/index/live-sideband.ts +601 -0
- package/src/server/index/serve-options.ts +1766 -0
- package/src/server/index/startup-warnings.ts +213 -0
- package/src/server/index/websocket-handler.ts +339 -0
- package/src/server/index.ts +45 -2552
- package/src/server/inspection-tee.ts +107 -0
- package/src/server/live.ts +46 -1
- package/src/server/management/combo-routes.ts +10 -1
- package/src/server/management/route-registry.ts +26 -23
- package/src/server/management/shared.ts +8 -5
- package/src/server/management/workflow-budget-routes.ts +133 -0
- package/src/server/management-api.ts +12 -0
- package/src/server/relay-eager.ts +2 -0
- package/src/server/relay.ts +14 -19
- package/src/server/request-log-conversation.ts +9 -7
- package/src/server/request-log.ts +372 -4
- package/src/server/response-log-body.ts +153 -0
- package/src/server/responses/account-change-state.ts +307 -0
- package/src/server/responses/adapter-continuation.ts +540 -0
- package/src/server/responses/adapter-delivery.ts +208 -0
- package/src/server/responses/adapter-dispatch.ts +1042 -0
- package/src/server/responses/codex-ws-wire.ts +5 -0
- package/src/server/responses/collaboration.ts +74 -4
- package/src/server/responses/combo-session-recall.ts +68 -8
- package/src/server/responses/compact.ts +113 -17
- package/src/server/responses/completion-policy.ts +33 -0
- package/src/server/responses/core-auth.ts +529 -0
- package/src/server/responses/core-codex-account.ts +907 -0
- package/src/server/responses/core-combo-failure.ts +210 -0
- package/src/server/responses/core-combo.ts +787 -0
- package/src/server/responses/core-errors.ts +170 -0
- package/src/server/responses/core-lifetime.ts +95 -0
- package/src/server/responses/core-normalize.ts +350 -0
- package/src/server/responses/core-opaque-recovery.ts +380 -0
- package/src/server/responses/core-options.ts +159 -0
- package/src/server/responses/core-replay.ts +298 -0
- package/src/server/responses/core.ts +192 -8893
- package/src/server/responses/encrypted-payload.ts +0 -1
- package/src/server/responses/input-admission.ts +126 -6
- package/src/server/responses/passthrough-delivery.ts +869 -0
- package/src/server/responses/passthrough-dispatch.ts +1494 -0
- package/src/server/responses/passthrough-error.ts +38 -2
- package/src/server/responses/passthrough-execution.ts +54 -0
- package/src/server/responses/request-prepare.ts +1080 -0
- package/src/server/responses/request-send-budget.ts +259 -0
- package/src/server/responses/request-sidecar-auth.ts +149 -0
- package/src/server/responses/request-spend.ts +147 -0
- package/src/server/responses/request-transport.ts +803 -0
- package/src/server/responses/response-effects.ts +157 -0
- package/src/server/responses/run-turn-execution.ts +476 -0
- package/src/server/responses/sidecar-execution.ts +463 -0
- package/src/server/responses/terminal-guard.ts +65 -4
- package/src/server/responses-image-gen-repair.ts +1 -1
- package/src/server/responses-undeclared-tool-guard.ts +9 -5
- package/src/server/workflow-refusal.ts +84 -0
- package/src/service/windows-ops.ts +210 -16
- package/src/service/windows-scheduler.ts +28 -21
- package/src/service.ts +1 -1
- package/src/types/config.ts +34 -1
- package/src/types/request.ts +8 -5
- package/src/types/tools.ts +24 -0
- package/src/types.ts +2 -0
- package/src/update/index.ts +10 -0
- package/src/update/stop-contract.d.mts +1 -0
- package/src/update/stop-contract.mjs +19 -0
- package/src/update/stop-decision.d.mts +1 -1
- package/src/update/stop-decision.mjs +12 -3
- package/src/usage/log.ts +147 -1
- package/src/usage/summary.ts +171 -21
- package/src/vision/anthropic-describe.ts +1 -1
- package/src/vision/describe.ts +5 -5
- package/src/web-search/anthropic-executor.ts +1 -1
- package/src/web-search/exa-executor.ts +1 -1
- package/src/web-search/executor.ts +1 -1
- package/src/web-search/gemini-executor.ts +1 -1
- package/src/web-search/loop.ts +1 -1
- package/src/web-search/ollama-executor.ts +1 -1
- package/src/web-search/parse.ts +67 -14
- package/src/web-search/passthrough-bridge.ts +64 -31
- package/src/web-search/xai-executor.ts +1 -1
package/src/server/index.ts
CHANGED
|
@@ -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 {
|
|
259
|
-
import {
|
|
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
|
-
|
|
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
|
|
1068
|
-
//
|
|
1069
|
-
//
|
|
1070
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
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
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
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";
|