@bitkyc08/opencodex 2.10.2 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/README.md +31 -0
  2. package/bin/ocx.mjs +10 -0
  3. package/gui/dist/assets/index-Bk-PN-70.css +1 -0
  4. package/gui/dist/assets/index-BynIEIV-.js +70 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +4 -2
  7. package/src/adapters/cursor/effort-map.ts +11 -0
  8. package/src/adapters/cursor/live-transport.ts +11 -0
  9. package/src/adapters/cursor/native-exec-fs.ts +9 -6
  10. package/src/adapters/cursor/native-exec.ts +4 -2
  11. package/src/adapters/cursor/protobuf-events.ts +176 -4
  12. package/src/adapters/cursor/request-builder.ts +15 -4
  13. package/src/adapters/cursor/tool-definitions.ts +118 -2
  14. package/src/adapters/google.ts +15 -5
  15. package/src/adapters/openai-chat.ts +24 -2
  16. package/src/adapters/openai-responses.ts +2 -1
  17. package/src/bridge.ts +9 -5
  18. package/src/chat/outbound.ts +4 -3
  19. package/src/claude/desktop-3p.ts +222 -2
  20. package/src/claude/outbound.ts +15 -6
  21. package/src/cli/account-api.ts +4 -0
  22. package/src/cli/account-extended.ts +112 -0
  23. package/src/cli/account.ts +23 -6
  24. package/src/cli/claude-desktop.ts +26 -3
  25. package/src/cli/config-command.ts +9 -0
  26. package/src/cli/help.ts +18 -2
  27. package/src/cli/index.ts +277 -55
  28. package/src/cli/models.ts +5 -1
  29. package/src/cli/provider.ts +8 -2
  30. package/src/cli/ready.ts +301 -0
  31. package/src/cli/system-restart-client.ts +146 -0
  32. package/src/cli/tray-proxy.ts +153 -6
  33. package/src/clients/config-export.ts +12 -19
  34. package/src/codex/account-lifecycle.ts +3 -0
  35. package/src/codex/account-namespaces.ts +49 -3
  36. package/src/codex/account-priority.ts +83 -0
  37. package/src/codex/auth-api.ts +83 -0
  38. package/src/codex/auth-context.ts +5 -2
  39. package/src/codex/catalog/provider-fetch.ts +11 -0
  40. package/src/codex/catalog/sync.ts +23 -1
  41. package/src/codex/codex-write-lock.ts +16 -4
  42. package/src/codex/desired-state.ts +37 -4
  43. package/src/codex/history-job.ts +15 -5
  44. package/src/codex/history-provider.ts +31 -14
  45. package/src/codex/history-worker.ts +28 -4
  46. package/src/codex/inject-coordination.ts +13 -1
  47. package/src/codex/inject.ts +360 -66
  48. package/src/codex/internal/history-writer.ts +1 -1
  49. package/src/codex/native-main-lock-file.ts +5 -1
  50. package/src/codex/native-main-owner.ts +17 -3
  51. package/src/codex/native-profile-manager.ts +19 -0
  52. package/src/codex/native-profile-startup.ts +8 -0
  53. package/src/codex/native-residue.ts +140 -27
  54. package/src/codex/pool-rotation.ts +74 -4
  55. package/src/codex/refresh.ts +7 -0
  56. package/src/codex/routing.ts +177 -36
  57. package/src/codex/subagent-model-fallback.ts +34 -4
  58. package/src/codex/sync.ts +61 -0
  59. package/src/codex/upstream-host-health.ts +329 -31
  60. package/src/combos/request.ts +2 -0
  61. package/src/config.ts +221 -2
  62. package/src/images/loop.ts +1 -1
  63. package/src/integrations/native/ownership-preflight.ts +39 -2
  64. package/src/lib/bun-stream-caps.ts +3 -3
  65. package/src/lib/sse-decoder.ts +41 -0
  66. package/src/lib/system-restart-contract.ts +73 -0
  67. package/src/lib/windows-secret-acl.ts +141 -39
  68. package/src/lib/windows-user-principal.ts +283 -0
  69. package/src/lib/winsw.ts +18 -2
  70. package/src/oauth/key-providers.ts +12 -0
  71. package/src/providers/derive.ts +54 -2
  72. package/src/providers/free-directory.ts +6 -5
  73. package/src/providers/model-discovery.ts +9 -3
  74. package/src/providers/quota.ts +592 -0
  75. package/src/providers/registry.ts +316 -13
  76. package/src/responses/parser.ts +26 -10
  77. package/src/responses/reasoning-replay-cache.ts +1 -0
  78. package/src/routing/profile-namespace.ts +15 -0
  79. package/src/routing/profile.ts +2 -1
  80. package/src/server/auth-cors.ts +44 -13
  81. package/src/server/chat-completions.ts +0 -4
  82. package/src/server/claude-messages.ts +73 -15
  83. package/src/server/github-copilot-responses-repair.ts +338 -0
  84. package/src/server/index.ts +328 -111
  85. package/src/server/lifecycle.ts +36 -0
  86. package/src/server/management/agent-settings-routes.ts +147 -56
  87. package/src/server/management/config-routes.ts +7 -2
  88. package/src/server/management/context.ts +4 -0
  89. package/src/server/management/native-integration-routes.ts +199 -20
  90. package/src/server/management/provider-routes.ts +41 -0
  91. package/src/server/management/routing-profile-routes.ts +234 -5
  92. package/src/server/management/system-restart.ts +12 -10
  93. package/src/server/management/system-routes.ts +20 -0
  94. package/src/server/management-auth.ts +51 -3
  95. package/src/server/ports.ts +41 -1
  96. package/src/server/proxy-liveness.ts +129 -4
  97. package/src/server/readiness.ts +99 -0
  98. package/src/server/relay.ts +113 -97
  99. package/src/server/request-log.ts +10 -4
  100. package/src/server/responses/compact.ts +107 -12
  101. package/src/server/responses/core.ts +220 -39
  102. package/src/server/responses-item-id-repair.ts +22 -3
  103. package/src/server/responses-model-rewrite.ts +29 -0
  104. package/src/server/sse-frame-buffer.ts +292 -0
  105. package/src/server/sse-payload-rewrite.ts +25 -14
  106. package/src/server/ws-bridge.ts +27 -22
  107. package/src/service-manager-probe.ts +520 -10
  108. package/src/service.ts +134 -2
  109. package/src/storage/worker-lifecycle.ts +14 -14
  110. package/src/tray/windows-tray.ps1 +74 -9
  111. package/src/types.ts +68 -2
  112. package/src/update/index.ts +12 -0
  113. package/src/update/job.ts +392 -18
  114. package/src/update/npm-cache-preflight.d.mts +47 -0
  115. package/src/update/npm-cache-preflight.mjs +201 -0
  116. package/src/usage/log.ts +1 -1
  117. package/src/vision/index.ts +77 -2
  118. package/src/web-search/loop.ts +1 -1
  119. package/src/web-search/parse.ts +4 -1
  120. package/gui/dist/assets/index-BKVqyYqT.js +0 -70
  121. package/gui/dist/assets/index-Ca_3269W.css +0 -1
@@ -22,6 +22,8 @@ import { reconcileOAuthProviders } from "../oauth";
22
22
  import { withCatalogWriteSerialization } from "../codex/catalog-write-serialization";
23
23
  import { invalidateCodexModelsCacheWithPermit } from "../codex/catalog/sync";
24
24
  import { getCodexHome } from "../codex/paths";
25
+ import { shouldSyncCodexOnStart } from "../codex/desired-state";
26
+ import { inspectNativeCodexOwnership } from "../integrations/native/ownership-preflight";
25
27
  import { registerCodexCooldownRecoveryProbeWorker } from "../codex/auth-api";
26
28
  import { startMemoryWatchdog } from "./memory-watchdog";
27
29
  import {
@@ -69,6 +71,7 @@ import {
69
71
  getActiveTurnCount,
70
72
  isDraining,
71
73
  registerTurn,
74
+ runListenerShutdown,
72
75
  setServerRef,
73
76
  trackStreamLifetime,
74
77
  tryAdmitTurn,
@@ -136,6 +139,8 @@ import {
136
139
  admissionFields,
137
140
  resolveApiAuth,
138
141
  resolveResponsesApiAuth,
142
+ requestPolicyView,
143
+ type RequestPolicyView,
139
144
  safeConfigDTO,
140
145
  setCorsOrigin,
141
146
  withCors,
@@ -162,6 +167,7 @@ import {
162
167
  releaseNativeMainStartupLifecycle,
163
168
  startNativeMainStartupLifecycle,
164
169
  type NativeMainStartupGateDeps,
170
+ type NativeMainStartupLifecycle,
165
171
  } from "../codex/native-profile-startup";
166
172
  import { handleImages } from "./images";
167
173
  import { handleLive, logLiveSidebandFrame, parseLiveSidebandTarget, resolveLiveSidebandUpgrade } from "./live";
@@ -180,6 +186,8 @@ import {
180
186
  createLocalAttestationProof,
181
187
  createLocalAttestationSecret,
182
188
  } from "../lib/local-management-attestation";
189
+ import { SYSTEM_RESTART_CAPABILITY_VERSION } from "../lib/system-restart-contract";
190
+ import { createReadinessGate, type ReadinessGate } from "./readiness";
183
191
 
184
192
  const MAX_WS_FRAME_BYTES = 50 * 1024 * 1024;
185
193
  const WEBSOCKET_IDLE_TIMEOUT_SECONDS = 0;
@@ -365,6 +373,8 @@ export interface StartServerDeps {
365
373
  liveSidebandWebSocketFactory?: LiveSidebandWebSocketFactory;
366
374
  /** Test-only seam; production derives a fresh local-attestation secret per process. */
367
375
  localAttestationSecret?: string;
376
+ /** Optional readiness gate; a fresh pending gate is created when omitted. */
377
+ readinessGate?: ReadinessGate;
368
378
  }
369
379
 
370
380
  /*
@@ -388,7 +398,7 @@ export function consumeStartupCacheInvalidationWrite(): boolean {
388
398
  return wrote;
389
399
  }
390
400
 
391
- export function startServer(port?: number, deps: StartServerDeps = {}) {
401
+ export function startServer(port?: number, deps: StartServerDeps = {}): Server<WsData> {
392
402
  const localAttestationSecret = deps.localAttestationSecret ?? createLocalAttestationSecret();
393
403
  const config = runAlibabaRegionStartupMigration(runOpenAiTierStartupMigration(loadConfig()));
394
404
  setLiveStateStoreConfig(config);
@@ -486,30 +496,82 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
486
496
  const configuredHost = config.hostname?.trim();
487
497
  const bindHost = !configuredHost || /^localhost$/i.test(configuredHost) ? "127.0.0.1" : configuredHost;
488
498
 
499
+ // Unauthenticated loopback listener (#1102). Off unless explicitly enabled.
500
+ const loopbackListener = config.unauthenticatedLoopbackListener;
501
+ const loopbackListenerPort = loopbackListener?.enabled ? loopbackListener.port : null;
502
+
503
+ /**
504
+ * Which listener a request arrived on, expressed as the only thing that differs: the bind
505
+ * address the auth and CORS decisions should see.
506
+ *
507
+ * The public listener passes the shared config through untouched, so its behaviour is
508
+ * byte-identical to before. The loopback listener substitutes 127.0.0.1, which is what makes
509
+ * `isApiAuthRequired` return false for it — the same code path a plain loopback bind has
510
+ * always taken, including the Host-header check inside `isAllowedRequestOrigin`.
511
+ *
512
+ * Built per request rather than once per listener so a management-API config change is
513
+ * picked up immediately instead of being frozen at listen time.
514
+ */
515
+ const publicPolicy = (): RequestPolicyView => config;
516
+ const loopbackPolicy = (): RequestPolicyView => requestPolicyView(config, "127.0.0.1");
517
+ void publicPolicy;
518
+
519
+ /**
520
+ * Routes the unauthenticated loopback listener will serve. Everything else 404s.
521
+ *
522
+ * This is an allowlist rather than a filter applied to the public handler, because a filter
523
+ * inverts the failure mode: a route added later would be reachable here by default. The four
524
+ * entries are exactly what a directly-spawned `codex app-server` needs.
525
+ *
526
+ * `GET /v1/models` is on the list for a reason that is easy to miss. When catalog
527
+ * materialization fails or finds no source, `syncCodex` warns and injects with
528
+ * `catalogPath: null`; Codex then builds an ONLINE model manager and `model/list` refreshes
529
+ * through `GET {base_url}/models`. Returning 404 there would leave the picker on its bundled
530
+ * fallback — fixing the direct-spawn host while breaking its model list.
531
+ */
532
+ function loopbackRouteAllowed(url: URL, req: Request): boolean {
533
+ const path = url.pathname;
534
+ if (path === "/v1/responses") {
535
+ return req.method === "POST" || req.headers.get("upgrade")?.toLowerCase() === "websocket";
536
+ }
537
+ if (path === "/v1/responses/compact") return req.method === "POST";
538
+ if (path === "/v1/models") return req.method === "GET";
539
+ return false;
540
+ }
541
+
489
542
  // Codex treats empty / non-JSON 503 bodies as "Unknown error" (#452). Keep Retry-After and
490
543
  // the server_is_overloaded code so clients can back off, but always return a JSON envelope.
491
- function drainingResponse(req: Request): Response {
544
+ // These two run BEFORE the auth/origin checks, so they need the receiving listener's policy
545
+ // explicitly (#1102). Reaching for the shared `config` here would attach public-policy CORS
546
+ // headers to a 503 on the loopback listener — no model runs and no credential is spent, but
547
+ // it is the one error path that would answer a rebinding origin with its own origin echoed
548
+ // back.
549
+ function drainingResponse(req: Request, policy: RequestPolicyView): Response {
492
550
  const response = formatErrorResponse(503, "server_error", "Service shutting down");
493
551
  const headers = new Headers(response.headers);
494
- for (const [name, value] of Object.entries(corsHeaders(req, config))) {
552
+ for (const [name, value] of Object.entries(corsHeaders(req, policy))) {
495
553
  headers.set(name, value);
496
554
  }
497
555
  headers.set("Retry-After", "5");
498
556
  return new Response(response.body, { status: 503, headers });
499
557
  }
500
558
 
501
- function serverBusyResponse(req: Request, resource: string): Response {
559
+ function serverBusyResponse(req: Request, resource: string, policy: RequestPolicyView): Response {
502
560
  return withCors(new Response(JSON.stringify({
503
561
  error: { type: "server_error", code: "server_busy", message: `${resource} capacity reached` },
504
562
  }), {
505
563
  status: 503,
506
564
  headers: { "Content-Type": "application/json", "Retry-After": "1" },
507
- }), req, config);
565
+ }), req, policy);
508
566
  }
509
567
 
510
- async function runAdmittedHttpTurn(req: Request, work: (lease: ActiveTurnLease) => Promise<Response>): Promise<Response> {
568
+ async function runAdmittedHttpTurn(
569
+ req: Request,
570
+ policy: RequestPolicyView,
571
+ work: (lease: ActiveTurnLease) => Promise<Response>,
572
+ ): Promise<Response> {
511
573
  const lease = tryAdmitTurn();
512
- if (!lease) return serverBusyResponse(req, "active turns");
574
+ if (!lease) return serverBusyResponse(req, "active turns", policy);
513
575
  let response: Response;
514
576
  try {
515
577
  response = await work(lease);
@@ -523,28 +585,84 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
523
585
  return response;
524
586
  }
525
587
 
526
- const nativeMainLifecycle = startNativeMainStartupLifecycle(deps.nativeMainStartup);
588
+ // Readiness gate: one PRIVATE controller per startServer invocation, captured
589
+ // by this listener's closure. Starting/failing a second server in the same
590
+ // process can never reset or mutate this gate. handleStart creates the gate,
591
+ // passes it in, and transitions it after the post-startup sync settles. When
592
+ // no gate is supplied (tests, ad-hoc starts) a fresh pending gate is created.
593
+ const readinessGate = deps.readinessGate ?? createReadinessGate();
594
+ // Actual bound port, filled in after Bun.serve binds so /readyz reports the
595
+ // real ephemeral port for startServer(0). /healthz keeps its existing port
596
+ // field (the requested listenPort) byte-for-byte.
597
+ let boundPort: number | null = null;
598
+
599
+ // Native-main startup ownership creates several SQLite coordination files in
600
+ // CODEX_HOME. When the user has disabled the Codex integration, starting the
601
+ // proxy must not manufacture those Codex artifacts merely to serve other
602
+ // clients; no Codex request can use this lifecycle in that state.
603
+ const nativeOwnership = inspectNativeCodexOwnership();
604
+ const nativeMainLifecycle: NativeMainStartupLifecycle = shouldSyncCodexOnStart(config)
605
+ && nativeOwnership.ownership !== "foreign"
606
+ ? startNativeMainStartupLifecycle(deps.nativeMainStartup)
607
+ : {
608
+ homeId: null,
609
+ settled: Promise.resolve({ status: "ready", homeId: null }),
610
+ release: async () => {},
611
+ };
527
612
  let server: Server<WsData>;
613
+ let loopbackServer: Server<WsData> | null = null;
528
614
  try {
529
- server = Bun.serve<WsData>({
530
- port: listenPort,
531
- hostname: bindHost,
532
- idleTimeout: 255,
533
- async fetch(req, requestServer): Promise<Response> {
615
+ const serveOptions = {
616
+ idleTimeout: 255,
617
+ async fetch(req: Request, requestServer: Server<WsData>): Promise<Response> {
618
+ // The unauthenticated loopback listener (#1102) serves a fixed allowlist and nothing
619
+ // else. Rejecting here, before any handler runs, is what keeps the surface from growing
620
+ // silently when a route is added below.
621
+ if (requestServer === loopbackServer && !loopbackRouteAllowed(new URL(req.url), req)) {
622
+ return withCors(
623
+ formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${new URL(req.url).pathname}`),
624
+ req,
625
+ loopbackPolicy(),
626
+ );
627
+ }
628
+ // Auth and CORS decisions below read `policy`, not `config`. For the public listener the
629
+ // two are the same object, so its behaviour is unchanged; for the loopback listener the
630
+ // view substitutes 127.0.0.1 as the bind address, which is what routes it through the
631
+ // same code path a plain loopback bind has always taken — Host-header check included.
632
+ // Routing, provider selection and response bodies keep using `config`.
633
+ const policy: RequestPolicyView = requestServer === loopbackServer ? loopbackPolicy() : config;
534
634
  const url = new URL(req.url);
535
635
  markActivity(`${req.method} ${url.pathname}`);
536
636
 
637
+ // Readiness is exact-GET on the literal /readyz path. Compare the DECODED
638
+ // pathname so an encoded variant like /readyz%2F (which decodes to
639
+ // /readyz/) cannot bypass the exact-path rejection and reach the GUI
640
+ // fallback (serveGuiFile decodes the pathname and would serve index.html
641
+ // with 200). Malformed percent-sequences fall back to the raw pathname,
642
+ // which still cannot match the exact literal below.
643
+ let readyzPath: string | undefined;
644
+ try {
645
+ const decoded = decodeURIComponent(url.pathname);
646
+ if (decoded === "/readyz" || decoded === "/readyz/") readyzPath = decoded;
647
+ } catch { /* malformed encoding — not a readiness path */ }
648
+
537
649
  if (req.method === "OPTIONS") {
650
+ // /readyz is exact-GET only; OPTIONS (like POST and the trailing-slash
651
+ // path) must answer the deterministic JSON 404, never the generic 204
652
+ // preflight response that the SPA fallback would otherwise allow.
653
+ if (readyzPath !== undefined) {
654
+ return withCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, policy);
655
+ }
538
656
  const managementPreflight = url.pathname.startsWith("/api/");
539
657
  const allowed = managementPreflight
540
658
  ? isAllowedManagementOrigin(req, config)
541
- : isAllowedRequestOrigin(req, config);
659
+ : isAllowedRequestOrigin(req, policy);
542
660
  if (!allowed) {
543
661
  return new Response(null, { status: 403, headers: corsHeaders() });
544
662
  }
545
663
  return new Response(null, {
546
664
  status: 204,
547
- headers: managementPreflight ? managementCorsHeaders(req, config) : corsHeaders(req, config),
665
+ headers: managementPreflight ? managementCorsHeaders(req, config) : corsHeaders(req, policy),
548
666
  });
549
667
  }
550
668
 
@@ -552,14 +670,14 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
552
670
  // handshake-time only, so capture inbound headers and thread them into the pipeline.
553
671
  if (url.pathname === "/v1/responses" && req.headers.get("upgrade")?.toLowerCase() === "websocket") {
554
672
  if (isDraining()) {
555
- return drainingResponse(req);
673
+ return drainingResponse(req, policy);
556
674
  }
557
- const admission = resolveResponsesApiAuth(req, config);
675
+ const admission = resolveResponsesApiAuth(req, policy);
558
676
  if (!admission) {
559
- return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, config);
677
+ return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
560
678
  }
561
- if (!isAllowedRequestOrigin(req, config)) {
562
- return withCors(formatErrorResponse(403, "origin_rejected", "WebSocket upgrade blocked: non-local Origin"), req, config);
679
+ if (!isAllowedRequestOrigin(req, policy)) {
680
+ return withCors(formatErrorResponse(403, "origin_rejected", "WebSocket upgrade blocked: non-local Origin"), req, policy);
563
681
  }
564
682
  // WS transport gate: Codex's built-in `openai` provider hardcodes supports_websockets=true,
565
683
  // so under Design B it always tries the WS transport first. When the feature is off, reject
@@ -567,21 +685,33 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
567
685
  // session-scoped HTTP fallback (client.rs WebsocketStreamOutcome::FallbackToHttp) instead of
568
686
  // surfacing broken-pipe errors from sockets a "disabled" feature would otherwise accept.
569
687
  if (!websocketsEnabled(config)) {
570
- return withCors(formatErrorResponse(426, "upgrade_required", "Responses WebSocket transport is disabled; use HTTP"), req, config);
688
+ return withCors(formatErrorResponse(426, "upgrade_required", "Responses WebSocket transport is disabled; use HTTP"), req, policy);
571
689
  }
572
690
  const websocketLease = tryReserveCodexWebSocket();
573
- if (!websocketLease) return serverBusyResponse(req, "Codex WebSockets");
574
- if (server.upgrade(req, {
691
+ if (!websocketLease) return serverBusyResponse(req, "Codex WebSockets", policy);
692
+ // Upgrade on the server that RECEIVED this request, not the captured `server`
693
+ // binding. They are the same object for the public listener, but the
694
+ // unauthenticated loopback listener (#1102) is a second Bun.serve, and handing its
695
+ // request to the public server's upgrade would fail or cross sockets.
696
+ if (requestServer.upgrade(req, {
575
697
  data: buildResponsesWsData(selectForwardHeaders(req.headers), admission, websocketLease),
576
698
  })) return undefined as unknown as Response;
577
699
  websocketLease.release();
578
- return withCors(formatErrorResponse(426, "upgrade_required", "WebSocket upgrade failed"), req, config);
700
+ return withCors(formatErrorResponse(426, "upgrade_required", "WebSocket upgrade failed"), req, policy);
579
701
  }
580
702
 
581
703
  if (url.pathname === "/healthz" && req.method === "GET") {
582
704
  // service/pid/port let CLI liveness reject foreign 200s and verify pid identity.
583
705
  const healthPort = server.port ?? listenPort;
584
- const response = jsonResponse({ status: "ok", service: "opencodex", version: VERSION, uptime: process.uptime(), pid: process.pid, port: healthPort }, 200, req, config);
706
+ const response = jsonResponse({
707
+ status: "ok",
708
+ service: "opencodex",
709
+ version: VERSION,
710
+ uptime: process.uptime(),
711
+ pid: process.pid,
712
+ port: healthPort,
713
+ restartCapability: SYSTEM_RESTART_CAPABILITY_VERSION,
714
+ }, 200, req, policy);
585
715
  const challenge = req.headers.get(LOCAL_ATTESTATION_CHALLENGE_HEADER);
586
716
  if (challenge) {
587
717
  const proof = createLocalAttestationProof(localAttestationSecret, challenge, process.pid, healthPort);
@@ -590,13 +720,54 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
590
720
  return response;
591
721
  }
592
722
 
723
+ // Readiness: like /healthz this is exact GET and unauthenticated (so a client can
724
+ // back off BEFORE knowing the admission token), but stricter than liveness. The
725
+ // body carries only sanitized identity + the fixed status enum; the sync message,
726
+ // warning text, catalog path, provider output, and account data are never exposed.
727
+ // POST or "/readyz/" must NOT match (exact pathname + GET method): answer them
728
+ // with a JSON 404 here so they can never be silently accepted by the GUI SPA
729
+ // fallback (which would serve index.html with HTTP 200 once gui/dist exists).
730
+ if (readyzPath !== undefined) {
731
+ if (readyzPath !== "/readyz" || req.method !== "GET") {
732
+ return withCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, policy);
733
+ }
734
+ // A draining proxy must never advertise ready: every data-plane branch
735
+ // answers drainingResponse while isDraining() is set, but the one-shot
736
+ // readiness gate is not mutated on shutdown (it is owned by the startup
737
+ // sync). Report pending so `ocx ready --wait` and external supervisors
738
+ // keep polling instead of promoting a proxy that is draining.
739
+ const status = isDraining() ? "pending" : readinessGate.getStatus();
740
+ const body = {
741
+ service: "opencodex",
742
+ version: VERSION,
743
+ uptime: process.uptime(),
744
+ pid: process.pid,
745
+ port: boundPort ?? listenPort,
746
+ status,
747
+ };
748
+ if (status === "ready") {
749
+ return jsonResponse(body, 200, req, policy);
750
+ }
751
+ // Pending/failed: 503 with a conservative Retry-After so well-behaved clients
752
+ // (and `ocx ready --wait`) back off instead of hot-looping.
753
+ const resp = jsonResponse(body, 503, req, policy);
754
+ const headers = new Headers(resp.headers);
755
+ headers.set("Retry-After", "1");
756
+ return new Response(resp.body, { status: 503, headers });
757
+ }
758
+
593
759
  if (url.pathname.startsWith("/api/")) {
594
- const apiAuthError = requireManagementAuth(req, managementAuth, config);
760
+ const localManagementAuth = {
761
+ attestationSecret: localAttestationSecret,
762
+ pid: process.pid,
763
+ port: boundPort ?? requestServer.port ?? listenPort,
764
+ };
765
+ const apiAuthError = requireManagementAuth(req, managementAuth, config, localManagementAuth);
595
766
  if (apiAuthError) return withManagementCors(apiAuthError, req, config);
596
767
  // Which credential passed the gate, resolved from the same session table the
597
768
  // gate used. Consent-bearing routes need this: request headers are forgeable
598
769
  // by anything holding the admin token, the credential is not.
599
- const principal = managementPrincipal(req, managementAuth, config) ?? undefined;
770
+ const principal = managementPrincipal(req, managementAuth, config, localManagementAuth) ?? undefined;
600
771
  const mgmtResponse = await handleManagementAPI(req, url, config, deps.managementApi, principal);
601
772
  if (mgmtResponse) return withManagementCors(mgmtResponse, req, config);
602
773
  return withManagementCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, config);
@@ -606,10 +777,10 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
606
777
  // Model discovery never forwards Authorization upstream, so the broader admission
607
778
  // set (Authorization / x-api-key / x-opencodex-api-key) is safe here and required by
608
779
  // remote OpenAI-style bearer clients and Claude gateway discovery (anthropic-version).
609
- const admission = resolveApiAuth(req, config);
610
- if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, config);
611
- if (!isAllowedRequestOrigin(req, config)) {
612
- return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, config);
780
+ const admission = resolveApiAuth(req, policy);
781
+ if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
782
+ if (!isAllowedRequestOrigin(req, policy)) {
783
+ return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy);
613
784
  }
614
785
  let goModels;
615
786
  try {
@@ -619,7 +790,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
619
790
  return withCors(new Response(JSON.stringify({ error: { type: "server_error", code: "catalog_busy", message: error.message } }), {
620
791
  status: 503,
621
792
  headers: { "content-type": "application/json", "Retry-After": "1" },
622
- }), req, config);
793
+ }), req, policy);
623
794
  }
624
795
  throw error;
625
796
  }
@@ -645,7 +816,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
645
816
  const wantsAnthropicList = req.headers.get("anthropic-version") !== null
646
817
  || url.searchParams.get("flavor") === "anthropic";
647
818
  if (wantsAnthropicList && !url.searchParams.has("client_version")) {
648
- if (config.claudeCode?.enabled === false) return jsonResponse({ data: [] }, 200, req, config);
819
+ if (config.claudeCode?.enabled === false) return jsonResponse({ data: [] }, 200, req, policy);
649
820
  // Build Desktop 3P registry so inbound alias resolution works for subsequent requests.
650
821
  buildDesktop3pRegistry(
651
822
  [...desktopVisibleNativeSlugs(config)],
@@ -666,7 +837,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
666
837
  ? "desktop3p" as const
667
838
  : (/^claude-code\//i.test(req.headers.get("user-agent") ?? "") ? "readable" as const : "desktop3p" as const);
668
839
  const data = buildAnthropicModelInfos([...desktopVisibleNativeSlugs(config)], goOrdered, resolveAutoContext(config.claudeCode), idStyle, activeDesktop3pAlias);
669
- return jsonResponse({ data }, 200, req, config);
840
+ return jsonResponse({ data }, 200, req, policy);
670
841
  }
671
842
  if (url.searchParams.has("client_version")) {
672
843
  // Codex client → Codex catalog shape: native gpt + namespaced routed models,
@@ -688,7 +859,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
688
859
  disabledModels,
689
860
  accountSelectors.length > 0,
690
861
  ),
691
- }, 200, req, config);
862
+ }, 200, req, policy);
692
863
  }
693
864
  // OpenAI list shape: native gpt bare + routed models namespaced "<provider>/<id>"
694
865
  // (pure availability list — disabled natives are omitted entirely).
@@ -752,7 +923,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
752
923
  ...grokEffortFields(m.reasoningEfforts ?? [], m.defaultReasoningEffort),
753
924
  })),
754
925
  ];
755
- return jsonResponse({ object: "list", data }, 200, req, config);
926
+ return jsonResponse({ object: "list", data }, 200, req, policy);
756
927
  }
757
928
 
758
929
  // Remote compaction v1 (codex-rs with Feature::RemoteCompactionV2 off — the default).
@@ -760,12 +931,12 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
760
931
  // before the /v1/* 404 guard below.
761
932
  if (url.pathname === "/v1/responses/compact" && req.method === "POST") {
762
933
  if (isDraining()) {
763
- return drainingResponse(req);
934
+ return drainingResponse(req, policy);
764
935
  }
765
- const admission = resolveResponsesApiAuth(req, config);
766
- if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, config);
767
- if (!isAllowedRequestOrigin(req, config)) {
768
- return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, config);
936
+ const admission = resolveResponsesApiAuth(req, policy);
937
+ if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
938
+ if (!isAllowedRequestOrigin(req, policy)) {
939
+ return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy);
769
940
  }
770
941
  const start = Date.now();
771
942
  const requestId = nextRequestLogId(start);
@@ -775,7 +946,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
775
946
  ...admissionFields(admission),
776
947
  inboundProtocol: "responses",
777
948
  };
778
- return runAdmittedHttpTurn(req, async turnAdmissionLease => {
949
+ return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => {
779
950
  let response: Response;
780
951
  try {
781
952
  response = await handleResponsesCompact(req, config, logCtx, turnAdmissionLease);
@@ -784,7 +955,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
784
955
  }
785
956
  addFinalRequestLog(requestId, start, logCtx, response.status,
786
957
  response.status === 499 ? { closeReason: "client_cancel" } : undefined);
787
- return withCors(response, req, config);
958
+ return withCors(response, req, policy);
788
959
  });
789
960
  }
790
961
 
@@ -794,12 +965,12 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
794
965
  ) {
795
966
  disableResponsesRequestTimeout(req, requestServer);
796
967
  if (isDraining()) {
797
- return drainingResponse(req);
968
+ return drainingResponse(req, policy);
798
969
  }
799
- const admission = resolveApiAuth(req, config);
800
- if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, config);
801
- if (!isAllowedRequestOrigin(req, config)) {
802
- return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, config);
970
+ const admission = resolveApiAuth(req, policy);
971
+ if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
972
+ if (!isAllowedRequestOrigin(req, policy)) {
973
+ return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy);
803
974
  }
804
975
  const start = Date.now();
805
976
  const requestId = nextRequestLogId(start);
@@ -809,24 +980,24 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
809
980
  ...admissionFields(admission),
810
981
  };
811
982
  const endpoint = url.pathname.endsWith("/edits") ? "edits" as const : "generations" as const;
812
- return runAdmittedHttpTurn(req, async turnAdmissionLease => {
983
+ return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => {
813
984
  const response = await handleImages(req, config, endpoint, logCtx, turnAdmissionLease);
814
985
  addFinalRequestLog(requestId, start, logCtx, response.status, response.status === 499 ? { closeReason: "client_cancel" } : undefined);
815
- return withCors(response, req, config);
986
+ return withCors(response, req, policy);
816
987
  });
817
988
  }
818
989
 
819
990
  if (req.method === "GET" && url.pathname.startsWith("/v1/opencodex/artifacts/")) {
820
- const admission = resolveApiAuth(req, config);
821
- if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, config);
822
- if (!isAllowedRequestOrigin(req, config)) {
823
- return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, config);
991
+ const admission = resolveApiAuth(req, policy);
992
+ if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
993
+ if (!isAllowedRequestOrigin(req, policy)) {
994
+ return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy);
824
995
  }
825
996
  const id = decodeURIComponent(url.pathname.slice("/v1/opencodex/artifacts/".length));
826
997
  const { resolveArtifactPath } = await import("../images/artifacts");
827
998
  const artifactPath = resolveArtifactPath(id);
828
999
  if (!artifactPath) {
829
- return withCors(formatErrorResponse(404, "not_found", "artifact not found"), req, config);
1000
+ return withCors(formatErrorResponse(404, "not_found", "artifact not found"), req, policy);
830
1001
  }
831
1002
  const file = Bun.file(artifactPath);
832
1003
  const ext = artifactPath.split(".").pop()?.toLowerCase();
@@ -843,18 +1014,18 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
843
1014
  "cache-control": "private, max-age=3600",
844
1015
  "x-content-type-options": "nosniff",
845
1016
  },
846
- }), req, config);
1017
+ }), req, policy);
847
1018
  }
848
1019
 
849
1020
  if (url.pathname === "/v1/alpha/search" && req.method === "POST") {
850
1021
  disableResponsesRequestTimeout(req, requestServer);
851
1022
  if (isDraining()) {
852
- return drainingResponse(req);
1023
+ return drainingResponse(req, policy);
853
1024
  }
854
- const admission = resolveApiAuth(req, config);
855
- if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, config);
856
- if (!isAllowedRequestOrigin(req, config)) {
857
- return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, config);
1025
+ const admission = resolveApiAuth(req, policy);
1026
+ if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
1027
+ if (!isAllowedRequestOrigin(req, policy)) {
1028
+ return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy);
858
1029
  }
859
1030
  const start = Date.now();
860
1031
  const requestId = nextRequestLogId(start);
@@ -863,23 +1034,23 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
863
1034
  provider: "unknown",
864
1035
  ...admissionFields(admission),
865
1036
  };
866
- return runAdmittedHttpTurn(req, async turnAdmissionLease => {
1037
+ return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => {
867
1038
  const response = await handleSearch(req, config, logCtx, turnAdmissionLease);
868
1039
  addFinalRequestLog(requestId, start, logCtx, response.status,
869
1040
  response.status === 499 ? { closeReason: "client_cancel" } : undefined);
870
- return withCors(response, req, config);
1041
+ return withCors(response, req, policy);
871
1042
  });
872
1043
  }
873
1044
 
874
1045
  if (url.pathname === "/v1/responses" && req.method === "POST") {
875
1046
  disableResponsesRequestTimeout(req, requestServer);
876
1047
  if (isDraining()) {
877
- return drainingResponse(req);
1048
+ return drainingResponse(req, policy);
878
1049
  }
879
- const admission = resolveResponsesApiAuth(req, config);
880
- if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, config);
881
- if (!isAllowedRequestOrigin(req, config)) {
882
- return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, config);
1050
+ const admission = resolveResponsesApiAuth(req, policy);
1051
+ if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
1052
+ if (!isAllowedRequestOrigin(req, policy)) {
1053
+ return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy);
883
1054
  }
884
1055
  const start = Date.now();
885
1056
  const requestId = nextRequestLogId(start);
@@ -898,7 +1069,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
898
1069
  logged = true;
899
1070
  addFinalRequestLog(requestId, start, logCtx, status, meta);
900
1071
  };
901
- return runAdmittedHttpTurn(req, async turnAdmissionLease => {
1072
+ return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => {
902
1073
  const response = await handleResponses(req, config, logCtx, {
903
1074
  turnAdmissionLease,
904
1075
  abortSignal: req.signal,
@@ -913,7 +1084,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
913
1084
  finalizeNativePassthroughLog(499, { closeReason: "client_cancel" });
914
1085
  },
915
1086
  });
916
- return withCors(responseWithDeferredRequestLog(response, requestId, start, logCtx), req, config);
1087
+ return withCors(responseWithDeferredRequestLog(response, requestId, start, logCtx), req, policy);
917
1088
  });
918
1089
  }
919
1090
 
@@ -921,29 +1092,29 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
921
1092
  // Claude Code posts `/v1/messages?beta=true` — pathname match ignores the query (003 G9).
922
1093
  if (url.pathname === "/v1/messages/count_tokens" && req.method === "POST") {
923
1094
  if (isDraining()) {
924
- return drainingResponse(req);
1095
+ return drainingResponse(req, policy);
925
1096
  }
926
- const admission = resolveApiAuth(req, config);
1097
+ const admission = resolveApiAuth(req, policy);
927
1098
  if (!admission) {
928
- return withCors(anthropicErrorResponse(401, "opencodex API key required", "authentication_error"), req, config);
1099
+ return withCors(anthropicErrorResponse(401, "opencodex API key required", "authentication_error"), req, policy);
929
1100
  }
930
- if (!isAllowedRequestOrigin(req, config)) {
931
- return withCors(anthropicErrorResponse(403, "cross-origin data-plane request blocked", "permission_error"), req, config);
1101
+ if (!isAllowedRequestOrigin(req, policy)) {
1102
+ return withCors(anthropicErrorResponse(403, "cross-origin data-plane request blocked", "permission_error"), req, policy);
932
1103
  }
933
- return runAdmittedHttpTurn(req, async () => withCors(await handleClaudeCountTokens(req, config), req, config));
1104
+ return runAdmittedHttpTurn(req, policy, async () => withCors(await handleClaudeCountTokens(req, config), req, policy));
934
1105
  }
935
1106
 
936
1107
  if (url.pathname === "/v1/messages" && req.method === "POST") {
937
1108
  disableResponsesRequestTimeout(req, requestServer);
938
1109
  if (isDraining()) {
939
- return drainingResponse(req);
1110
+ return drainingResponse(req, policy);
940
1111
  }
941
- const admission = resolveApiAuth(req, config);
1112
+ const admission = resolveApiAuth(req, policy);
942
1113
  if (!admission) {
943
- return withCors(anthropicErrorResponse(401, "opencodex API key required", "authentication_error"), req, config);
1114
+ return withCors(anthropicErrorResponse(401, "opencodex API key required", "authentication_error"), req, policy);
944
1115
  }
945
- if (!isAllowedRequestOrigin(req, config)) {
946
- return withCors(anthropicErrorResponse(403, "cross-origin data-plane request blocked", "permission_error"), req, config);
1116
+ if (!isAllowedRequestOrigin(req, policy)) {
1117
+ return withCors(anthropicErrorResponse(403, "cross-origin data-plane request blocked", "permission_error"), req, policy);
947
1118
  }
948
1119
  const start = Date.now();
949
1120
  const requestId = nextRequestLogId(start);
@@ -956,7 +1127,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
956
1127
  // Logging is finalized inside handleClaudeMessages (Responses-vocab tap on the
957
1128
  // pre-translation stream + native passthrough callbacks) — do not re-wrap the
958
1129
  // translated Anthropic stream here.
959
- return runAdmittedHttpTurn(req, async turnAdmissionLease => withCors(
1130
+ return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => withCors(
960
1131
  await handleClaudeMessages(req, config, logCtx, { requestId, start, turnAdmissionLease }),
961
1132
  req,
962
1133
  config,
@@ -968,12 +1139,12 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
968
1139
  if (url.pathname === "/v1/chat/completions" && req.method === "POST") {
969
1140
  disableResponsesRequestTimeout(req, requestServer);
970
1141
  if (isDraining()) {
971
- return drainingResponse(req);
1142
+ return drainingResponse(req, policy);
972
1143
  }
973
- const admission = resolveResponsesApiAuth(req, config);
974
- if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, config);
975
- if (!isAllowedRequestOrigin(req, config)) {
976
- return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, config);
1144
+ const admission = resolveResponsesApiAuth(req, policy);
1145
+ if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
1146
+ if (!isAllowedRequestOrigin(req, policy)) {
1147
+ return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy);
977
1148
  }
978
1149
  const start = Date.now();
979
1150
  const requestId = nextRequestLogId(start);
@@ -983,7 +1154,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
983
1154
  ...admissionFields(admission),
984
1155
  inboundProtocol: "chat",
985
1156
  };
986
- return runAdmittedHttpTurn(req, async turnAdmissionLease => withCors(
1157
+ return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => withCors(
987
1158
  await handleChatCompletions(req, config, logCtx, { requestId, start, turnAdmissionLease }),
988
1159
  req,
989
1160
  config,
@@ -999,12 +1170,12 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
999
1170
  ) {
1000
1171
  disableResponsesRequestTimeout(req, requestServer);
1001
1172
  if (isDraining()) {
1002
- return drainingResponse(req);
1173
+ return drainingResponse(req, policy);
1003
1174
  }
1004
- const admission = resolveApiAuth(req, config);
1005
- if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, config);
1006
- if (!isAllowedRequestOrigin(req, config)) {
1007
- return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, config);
1175
+ const admission = resolveApiAuth(req, policy);
1176
+ if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
1177
+ if (!isAllowedRequestOrigin(req, policy)) {
1178
+ return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy);
1008
1179
  }
1009
1180
  const start = Date.now();
1010
1181
  const requestId = nextRequestLogId(start);
@@ -1013,7 +1184,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
1013
1184
  provider: "unknown",
1014
1185
  ...admissionFields(admission),
1015
1186
  };
1016
- return runAdmittedHttpTurn(req, async turnAdmissionLease => {
1187
+ return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => {
1017
1188
  const response = await handleLive(req, config, logCtx, turnAdmissionLease);
1018
1189
  addFinalRequestLog(
1019
1190
  requestId,
@@ -1022,7 +1193,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
1022
1193
  response.status,
1023
1194
  response.status === 499 ? { closeReason: "client_cancel" } : undefined,
1024
1195
  );
1025
- return withCors(response, req, config);
1196
+ return withCors(response, req, policy);
1026
1197
  });
1027
1198
  }
1028
1199
 
@@ -1033,12 +1204,12 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
1033
1204
  : null;
1034
1205
  if (liveSidebandTarget) {
1035
1206
  if (isDraining()) {
1036
- return drainingResponse(req);
1207
+ return drainingResponse(req, policy);
1037
1208
  }
1038
- const admission = resolveApiAuth(req, config);
1039
- if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, config);
1040
- if (!isAllowedRequestOrigin(req, config)) {
1041
- return withCors(formatErrorResponse(403, "origin_rejected", "WebSocket upgrade blocked: non-local Origin"), req, config);
1209
+ const admission = resolveApiAuth(req, policy);
1210
+ if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
1211
+ if (!isAllowedRequestOrigin(req, policy)) {
1212
+ return withCors(formatErrorResponse(403, "origin_rejected", "WebSocket upgrade blocked: non-local Origin"), req, policy);
1042
1213
  }
1043
1214
  const start = Date.now();
1044
1215
  const requestId = nextRequestLogId(start);
@@ -1048,7 +1219,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
1048
1219
  ...admissionFields(admission),
1049
1220
  };
1050
1221
  const turnAdmissionLease = tryAdmitTurn();
1051
- if (!turnAdmissionLease) return serverBusyResponse(req, "active turns");
1222
+ if (!turnAdmissionLease) return serverBusyResponse(req, "active turns", policy);
1052
1223
  let resolved;
1053
1224
  try {
1054
1225
  resolved = await resolveLiveSidebandUpgrade(req, config, logCtx, liveSidebandTarget, turnAdmissionLease);
@@ -1059,10 +1230,10 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
1059
1230
  if (resolved instanceof Response) {
1060
1231
  turnAdmissionLease.release();
1061
1232
  addFinalRequestLog(requestId, start, logCtx, resolved.status);
1062
- return withCors(resolved, req, config);
1233
+ return withCors(resolved, req, policy);
1063
1234
  }
1064
1235
  addFinalRequestLog(requestId, start, logCtx, 101);
1065
- if (server.upgrade(req, {
1236
+ if (requestServer.upgrade(req, {
1066
1237
  data: {
1067
1238
  kind: "live-sideband",
1068
1239
  liveUpstreamUrl: resolved.upstreamWsUrl,
@@ -1073,7 +1244,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
1073
1244
  } satisfies WsData,
1074
1245
  })) return undefined as unknown as Response;
1075
1246
  turnAdmissionLease.release();
1076
- return withCors(formatErrorResponse(426, "upgrade_required", "WebSocket upgrade failed"), req, config);
1247
+ return withCors(formatErrorResponse(426, "upgrade_required", "WebSocket upgrade failed"), req, policy);
1077
1248
  }
1078
1249
 
1079
1250
  // Data-plane guard: unknown /v1/* paths must fail with JSON 404, never fall through to the
@@ -1081,7 +1252,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
1081
1252
  // endpoint clients — memories/*, realtime/* — would surface confusing
1082
1253
  // serde decode errors instead of a clean not-found).
1083
1254
  if (url.pathname.startsWith("/v1/")) {
1084
- return withCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, config);
1255
+ return withCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, policy);
1085
1256
  }
1086
1257
 
1087
1258
  const guiSessionCandidate = req.method === "GET" && (url.pathname === "/" || !url.pathname.includes("."))
@@ -1302,7 +1473,34 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
1302
1473
  ws.data.cancel?.(); // RC2: abort the upstream when the client disconnects
1303
1474
  },
1304
1475
  },
1305
- });
1476
+ } as const;
1477
+
1478
+ server = Bun.serve<WsData>({ ...serveOptions, port: listenPort, hostname: bindHost });
1479
+
1480
+ // Both binds are one startup transaction (#1102). If the loopback bind fails after the
1481
+ // public one succeeded, leaving the public listener up would strand it: the CLI's port
1482
+ // retry would read the failure as a public-port conflict and pick a different port,
1483
+ // accumulating listeners. Roll back and rethrow the original error instead.
1484
+ if (loopbackListenerPort !== null) {
1485
+ try {
1486
+ loopbackServer = Bun.serve<WsData>({
1487
+ ...serveOptions,
1488
+ port: loopbackListenerPort,
1489
+ hostname: "127.0.0.1",
1490
+ });
1491
+ } catch (error) {
1492
+ try {
1493
+ // startServer is synchronous, so this rollback cannot await. Bun begins closing the
1494
+ // listen socket on the call itself; the caller sees the original bind error either
1495
+ // way, and the alternative — leaving the public listener up — is the failure this
1496
+ // rollback exists to prevent.
1497
+ void server.stop(true);
1498
+ } catch {
1499
+ /* the original bind error is the one worth reporting */
1500
+ }
1501
+ throw error;
1502
+ }
1503
+ }
1306
1504
  } catch (error) {
1307
1505
  void nativeMainLifecycle.release();
1308
1506
  throw error;
@@ -1310,18 +1508,26 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
1310
1508
 
1311
1509
  bindNativeMainStartupLifecycle(server, nativeMainLifecycle);
1312
1510
  const nativeStop = server.stop.bind(server);
1511
+ const loopbackListenerRef = loopbackServer;
1313
1512
  Object.defineProperty(server, "stop", {
1314
1513
  configurable: true,
1315
1514
  value: async (closeActiveConnections?: boolean): Promise<void> => {
1316
- try {
1317
- await nativeStop(closeActiveConnections);
1318
- } finally {
1319
- await releaseNativeMainStartupLifecycle(server);
1320
- }
1515
+ // The orchestration lives in `runListenerShutdown` so its two competing properties —
1516
+ // cleanup completes, failure propagates — are testable without a live socket.
1517
+ await runListenerShutdown(
1518
+ [
1519
+ () => nativeStop(closeActiveConnections),
1520
+ ...(loopbackListenerRef
1521
+ ? [() => loopbackListenerRef.stop(closeActiveConnections)]
1522
+ : []),
1523
+ ],
1524
+ () => releaseNativeMainStartupLifecycle(server),
1525
+ );
1321
1526
  },
1322
1527
  });
1323
1528
  setServerRef(server);
1324
1529
  const actualPort = server.port ?? listenPort;
1530
+ boundPort = actualPort;
1325
1531
  setCorsOrigin(actualPort);
1326
1532
 
1327
1533
  console.log(`🚀 opencodex proxy running on http://localhost:${actualPort}`);
@@ -1331,6 +1537,17 @@ export function startServer(port?: number, deps: StartServerDeps = {}) {
1331
1537
  console.log(` GET /api/* → management API`);
1332
1538
  console.log(` GET / → GUI dashboard`);
1333
1539
 
1540
+ if (loopbackServer) {
1541
+ // Loud on every start, not once at enable time. An operator who inherits a config, or
1542
+ // who forgot, has to be able to see that an unauthenticated surface is live without
1543
+ // reading the file.
1544
+ const loopbackPort = loopbackServer.port ?? loopbackListenerPort;
1545
+ console.warn(`⚠️ Unauthenticated loopback listener active on http://127.0.0.1:${loopbackPort}`);
1546
+ console.warn(` Any local process can use it without a credential — it spends account`);
1547
+ console.warn(` quota and paid provider credentials, and can starve authenticated`);
1548
+ console.warn(` remote clients. Not for shared or multi-tenant hosts.`);
1549
+ }
1550
+
1334
1551
  // Prime pool-account quota in the background so the rotation engine has real
1335
1552
  // usage scores from the first routing decision, even when the dashboard is
1336
1553
  // never opened (the common CLI/WSL case). Fire-and-forget: never blocks the