@bitkyc08/opencodex 2.39.0 → 2.40.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 (144) hide show
  1. package/README.md +49 -21
  2. package/gui/dist/assets/index-BHe2rl_C.js +112 -0
  3. package/gui/dist/assets/index-CJSb3HPe.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/cursor/catalog.ts +140 -7
  7. package/src/adapters/cursor/discovery.ts +79 -87
  8. package/src/adapters/cursor/effort-map.ts +11 -0
  9. package/src/adapters/cursor/request-builder.ts +31 -4
  10. package/src/adapters/cursor.ts +17 -0
  11. package/src/adapters/openai-chat.ts +95 -9
  12. package/src/adapters/openai-responses.ts +73 -9
  13. package/src/bridge.ts +58 -13
  14. package/src/claude/auth-mode.ts +6 -9
  15. package/src/claude/gateway-cache.ts +23 -3
  16. package/src/claude/model-info.ts +13 -2
  17. package/src/cli/access.ts +30 -0
  18. package/src/cli/account-extended.ts +19 -10
  19. package/src/cli/capabilities.ts +40 -1
  20. package/src/cli/claude-agent-startup-sync.ts +3 -0
  21. package/src/cli/claude.ts +134 -36
  22. package/src/cli/connect.ts +226 -0
  23. package/src/cli/dispatch.ts +126 -21
  24. package/src/cli/gui-pair-client.ts +170 -0
  25. package/src/cli/gui.ts +87 -0
  26. package/src/cli/help.ts +4 -1
  27. package/src/cli/index.ts +76 -22
  28. package/src/cli/models-runtime-subcommands.ts +34 -0
  29. package/src/cli/models-runtime.ts +4 -0
  30. package/src/cli/models.ts +2 -1
  31. package/src/cli/opencode.ts +2 -1
  32. package/src/cli/provider-runtime.ts +31 -1
  33. package/src/cli/registry.ts +32 -2
  34. package/src/cli/runtime-api.ts +10 -1
  35. package/src/cli/status.ts +26 -0
  36. package/src/cli/system-command.ts +9 -3
  37. package/src/client/connect.ts +650 -0
  38. package/src/client/hub-client.ts +457 -0
  39. package/src/client/hub-relay.ts +288 -0
  40. package/src/client/machine-api.ts +139 -0
  41. package/src/client/machine-auth.ts +54 -0
  42. package/src/client/machine-listener.ts +143 -0
  43. package/src/client/runtime.ts +93 -0
  44. package/src/client/state.ts +175 -0
  45. package/src/clients/config-export.ts +16 -5
  46. package/src/codex/auth-api.ts +169 -15
  47. package/src/codex/auth-context.ts +61 -6
  48. package/src/codex/catalog/aggregation.ts +17 -13
  49. package/src/codex/catalog/metadata.ts +28 -0
  50. package/src/codex/catalog/provider-fetch.ts +38 -5
  51. package/src/codex/catalog.ts +1 -1
  52. package/src/codex/desired-state.ts +13 -3
  53. package/src/codex/inject.ts +212 -31
  54. package/src/codex/journal.ts +54 -6
  55. package/src/codex/main-account.ts +105 -33
  56. package/src/codex/native-main-claim.ts +23 -1
  57. package/src/codex/native-residue.ts +7 -0
  58. package/src/codex/reset-credit-auto-redeem.ts +237 -0
  59. package/src/combos/request.ts +15 -3
  60. package/src/combos/types.ts +12 -0
  61. package/src/config/paths.ts +15 -1
  62. package/src/config/provider-validation.ts +40 -0
  63. package/src/config.ts +447 -3
  64. package/src/generated/compatibility-version.json +224 -108
  65. package/src/images/artifacts.ts +73 -37
  66. package/src/images/fulfill.ts +6 -1
  67. package/src/images/index.ts +1 -1
  68. package/src/images/plan.ts +16 -2
  69. package/src/images/synthetic-tool.ts +5 -0
  70. package/src/images/xai-client.ts +34 -1
  71. package/src/integrations/cursor-detect.ts +133 -0
  72. package/src/integrations/cursor-seen.ts +31 -0
  73. package/src/lib/errors.ts +18 -0
  74. package/src/lib/gui-pair-capability.ts +104 -0
  75. package/src/lib/lab-live-route-production.ts +2 -1
  76. package/src/lib/process-control.ts +4 -3
  77. package/src/lib/service-secrets.ts +161 -1
  78. package/src/lib/windows-system-proxy.ts +115 -0
  79. package/src/oauth/index.ts +3 -2
  80. package/src/oauth/pool-settings-capability.ts +55 -0
  81. package/src/providers/api-keys.ts +3 -1
  82. package/src/providers/codex-capacity.ts +34 -4
  83. package/src/providers/derive.ts +10 -0
  84. package/src/providers/fastwire.ts +10 -2
  85. package/src/providers/key-store.ts +197 -0
  86. package/src/providers/model-rename-migration.ts +3 -0
  87. package/src/providers/openai-sidecar.ts +3 -3
  88. package/src/providers/quota.ts +91 -42
  89. package/src/providers/registry.ts +33 -3
  90. package/src/reasoning-effort.ts +32 -0
  91. package/src/remote/protocol.ts +109 -0
  92. package/src/responses/citation-markers.ts +101 -0
  93. package/src/responses/parser.ts +43 -3
  94. package/src/responses/spill-store.ts +9 -4
  95. package/src/router.ts +72 -8
  96. package/src/server/auth-cors.ts +37 -1
  97. package/src/server/gui-session.ts +449 -0
  98. package/src/server/gui-static.ts +24 -7
  99. package/src/server/images.ts +237 -10
  100. package/src/server/index.ts +356 -30
  101. package/src/server/management/agent-settings-routes.ts +7 -1
  102. package/src/server/management/api-key-rotation.ts +74 -0
  103. package/src/server/management/combo-routes.ts +18 -6
  104. package/src/server/management/config-routes.ts +22 -3
  105. package/src/server/management/context.ts +5 -1
  106. package/src/server/management/cursor-integration-routes.ts +98 -0
  107. package/src/server/management/logs-usage-routes.ts +2 -1
  108. package/src/server/management/model-routes.ts +79 -1
  109. package/src/server/management/model-rows.ts +23 -1
  110. package/src/server/management/oauth-account-routes.ts +135 -2
  111. package/src/server/management/provider-routes.ts +143 -9
  112. package/src/server/management/route-registry.ts +10 -0
  113. package/src/server/management/session-routes.ts +13 -0
  114. package/src/server/management/system-routes.ts +13 -1
  115. package/src/server/management-api.ts +8 -3
  116. package/src/server/management-auth.ts +124 -82
  117. package/src/server/models-capabilities.ts +124 -0
  118. package/src/server/ports.ts +7 -0
  119. package/src/server/proxy-liveness.ts +7 -0
  120. package/src/server/request-log.ts +11 -5
  121. package/src/server/responses/agent-task-recovery.ts +1 -0
  122. package/src/server/responses/codex-auth-error.ts +3 -1
  123. package/src/server/responses/collaboration.ts +26 -1
  124. package/src/server/responses/combo-stream-preflight.ts +25 -6
  125. package/src/server/responses/compact.ts +32 -12
  126. package/src/server/responses/context-overflow.ts +49 -0
  127. package/src/server/responses/core.ts +196 -12
  128. package/src/server/responses/fetch-helpers.ts +2 -1
  129. package/src/server/responses/outbound-body-guard.ts +110 -0
  130. package/src/server/responses/ws-upstream.ts +87 -6
  131. package/src/server/responses-self-named-namespace-scrub.ts +181 -0
  132. package/src/server/system-env.ts +78 -25
  133. package/src/service.ts +218 -42
  134. package/src/types/config.ts +134 -0
  135. package/src/types/provider.ts +54 -1
  136. package/src/types/request.ts +2 -0
  137. package/src/types.ts +5 -0
  138. package/src/update/job.ts +33 -5
  139. package/src/usage/cost.ts +32 -1
  140. package/src/usage/expected-prices.ts +16 -0
  141. package/src/usage/log.ts +6 -2
  142. package/src/usage/summary.ts +48 -6
  143. package/gui/dist/assets/index-D-lchsPw.js +0 -112
  144. package/gui/dist/assets/index-uvENYLin.css +0 -1
@@ -21,6 +21,7 @@ import {
21
21
  websocketsEnabled,
22
22
  } from "../config";
23
23
  import { grokDefaultReasoningEffort } from "../grok/effort";
24
+ import { flushConfigDirHardening } from "../config/paths";
24
25
  import { reconcileOAuthProviders } from "../oauth";
25
26
  import { withCatalogWriteSerialization } from "../codex/catalog-write-serialization";
26
27
  import { invalidateCodexModelsCacheWithPermit } from "../codex/catalog/sync";
@@ -32,7 +33,8 @@ import {
32
33
  type NativeCodexOwnership,
33
34
  type OwnershipInspection,
34
35
  } from "../integrations/native/ownership-preflight";
35
- import { registerCodexCooldownRecoveryProbeWorker } from "../codex/auth-api";
36
+ import { createResetCreditWhamClient, registerCodexCooldownRecoveryProbeWorker } from "../codex/auth-api";
37
+ import { activateResetCreditAutoRedeem } from "../codex/reset-credit-auto-redeem";
36
38
  import {
37
39
  reconcileLiveStateStores,
38
40
  setLiveStateStoreConfig,
@@ -155,6 +157,7 @@ import {
155
157
  resolveApiAuth,
156
158
  resolveResponsesApiAuth,
157
159
  requestPolicyView,
160
+ type DataPlaneAdmission,
158
161
  type RequestPolicyView,
159
162
  safeConfigDTO,
160
163
  setCorsOrigin,
@@ -190,6 +193,7 @@ import { handleLive, logLiveSidebandFrame, parseLiveSidebandTarget, resolveLiveS
190
193
  import { handleSearch } from "./search";
191
194
  import { fetchAllModels, handleManagementAPI, VERSION, type ManagementApiDeps } from "./management-api";
192
195
  import {
196
+ createManagementSessionControl,
193
197
  initializeManagementAuthState,
194
198
  issueGuiSession,
195
199
  managementPrincipal,
@@ -204,15 +208,96 @@ import {
204
208
  } from "../lib/local-management-attestation";
205
209
  import { SYSTEM_RESTART_CAPABILITY_VERSION } from "../lib/system-restart-contract";
206
210
  import { LOCAL_PROVIDER_RELOAD_CAPABILITY_VERSION } from "../lib/local-provider-reload-contract";
211
+ import {
212
+ GUI_PAIR_BROWSER_ORIGIN_HEADER,
213
+ GUI_PAIR_CAPABILITY_VERSION,
214
+ GUI_PAIR_PATH,
215
+ } from "../lib/gui-pair-capability";
216
+ import {
217
+ GuiPairingGrantRateLimitError,
218
+ consumeGuiPairingGrant,
219
+ createGuiPairingGrant,
220
+ } from "./gui-session";
207
221
  import { createReadinessGate, type ReadinessGate } from "./readiness";
208
222
  import {
209
223
  createRuntimePackageTreeIntegrityGuard,
210
224
  type PackageTreeIntegrityGuard,
211
225
  } from "../lib/package-tree-integrity";
212
226
  import { detectInstall } from "../update/index";
227
+ import { readyProtocolMetadata } from "../remote/protocol";
228
+ import { modelCapabilityFields } from "./models-capabilities";
229
+ import { recordCursorSeen } from "../integrations/cursor-seen";
213
230
 
214
231
  export const MAX_WS_FRAME_BYTES = 50 * 1024 * 1024;
215
232
  const WEBSOCKET_IDLE_TIMEOUT_SECONDS = 0;
233
+
234
+ // Header-safe by construction: a key id reaches a response header, so anything outside this
235
+ // class could inject a header break or a control character into a response we control.
236
+ const REMOTE_CATALOG_KEY_ID_PATTERN = /^[A-Za-z0-9._-]{1,64}$/;
237
+ const GUI_PAIRING_EXCHANGE_BODY_LIMIT = 4 * 1024;
238
+
239
+ /**
240
+ * Read at most `limit` bytes of a request body, or refuse.
241
+ *
242
+ * Returns null the moment the body is known to exceed `limit`, without retaining the excess.
243
+ * `req.text()` cannot express that: it buffers to completion first, so a caller who omits
244
+ * Content-Length or uses chunked framing decides how much memory the process spends. That
245
+ * matters here because the one caller is an unauthenticated endpoint.
246
+ *
247
+ * limit+1 is the stopping point rather than limit, so a body exactly at the limit is still
248
+ * accepted and only a genuinely over-limit body is rejected.
249
+ */
250
+ async function readBoundedRequestText(req: Request, limit: number): Promise<string | null> {
251
+ const body = req.body;
252
+ if (!body) return "";
253
+ const reader = body.getReader();
254
+ const chunks: Uint8Array[] = [];
255
+ let total = 0;
256
+ try {
257
+ for (;;) {
258
+ const { done, value } = await reader.read();
259
+ if (done) break;
260
+ if (!value || value.byteLength === 0) continue;
261
+ total += value.byteLength;
262
+ if (total > limit) return null;
263
+ chunks.push(value);
264
+ }
265
+ } finally {
266
+ // Cancel rather than only releasing the lock: on the reject path the peer may still be
267
+ // sending, and an uncancelled body keeps that transfer alive.
268
+ await reader.cancel().catch(() => {});
269
+ }
270
+ const joined = new Uint8Array(total);
271
+ let offset = 0;
272
+ for (const chunk of chunks) {
273
+ joined.set(chunk, offset);
274
+ offset += chunk.byteLength;
275
+ }
276
+ return new TextDecoder().decode(joined);
277
+ }
278
+
279
+ /**
280
+ * Name WHICH configured credential was admitted, so a multi-key operator can attribute a
281
+ * catalog read.
282
+ *
283
+ * Scoped to configured keys on purpose: an environment token or a loopback bind has no key
284
+ * to name, and emitting one anyway would invent an attribution that does not exist. 200 only
285
+ * — this route emits no validator and therefore never answers 304.
286
+ *
287
+ * An id that fails the header-safe pattern is omitted rather than sanitized, with one warning
288
+ * that does NOT repeat the id: logging the offending value is how a malformed id becomes a
289
+ * log-injection vector instead of a dropped header.
290
+ */
291
+ function withRemoteCatalogKeyId(response: Response, admission: DataPlaneAdmission): Response {
292
+ if (response.status !== 200 || admission.kind !== "configured") return response;
293
+ if (!REMOTE_CATALOG_KEY_ID_PATTERN.test(admission.keyId)) {
294
+ console.warn("[remote-catalog] configured API key id is not header-safe; omitting x-opencodex-key-id");
295
+ return response;
296
+ }
297
+ response.headers.set("x-opencodex-key-id", admission.keyId);
298
+ return response;
299
+ }
300
+
216
301
  const LIVE_SIDEBAND_PENDING_MAX = 32;
217
302
  const LIVE_SIDEBAND_PENDING_BYTES_MAX = 1024 * 1024;
218
303
  const LIVE_SIDEBAND_CLOSE_FALLBACK_MS = 1_000;
@@ -554,12 +639,16 @@ export function warnAgentTaskRecoveryStartup(config: {
554
639
 
555
640
  export function startServer(port?: number, deps: StartServerDeps = {}): Server<WsData> {
556
641
  const localAttestationSecret = deps.localAttestationSecret ?? createLocalAttestationSecret();
642
+ // Captured before loadConfig() starts the optional ACL flight so stop() drains the same dir
643
+ // even if OPENCODEX_HOME changes underneath a long-lived process.
644
+ const startupConfigDir = getConfigDir();
557
645
  const config = runModelRenameStartupMigration(runAlibabaRegionStartupMigration(runOpenAiTierStartupMigration(loadConfig())));
558
646
  warnAgentTaskRecoveryStartup(config);
559
647
  setLiveStateStoreConfig(config);
560
648
  applyProxyEnv(config);
561
649
  assertServerAuthConfig(config);
562
650
  const managementAuth = deps.managementAuthState ?? initializeManagementAuthState(config);
651
+ const managementSessionControl = createManagementSessionControl(managementAuth);
563
652
  let userCostOverlayReconciler: { stop(): void } | null = null;
564
653
  // Arm synchronously before listen. A pending journal therefore makes __main__ unusable
565
654
  // before any request can resolve its physical credential, while health/management/Pool stay live.
@@ -673,6 +762,10 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
673
762
  // Unauthenticated loopback listener (#1102). Off unless explicitly enabled.
674
763
  const loopbackListener = config.unauthenticatedLoopbackListener;
675
764
  const loopbackListenerPort = loopbackListener?.enabled ? loopbackListener.port : null;
765
+ // Hub management ingress is a third, management-only listener. Its address is intentionally
766
+ // fixed: the kernel loopback bind is the trust boundary that permits Tailscale identity headers.
767
+ const managementIngress = config.runtimeRole === "hub" ? config.hub?.managementIngress : undefined;
768
+ const managementIngressPort = managementIngress?.enabled ? managementIngress.port : null;
676
769
 
677
770
  /**
678
771
  * Which listener a request arrived on, expressed as the only thing that differs: the bind
@@ -694,8 +787,13 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
694
787
  * Routes the unauthenticated loopback listener will serve. Everything else 404s.
695
788
  *
696
789
  * This is an allowlist rather than a filter applied to the public handler, because a filter
697
- * inverts the failure mode: a route added later would be reachable here by default. The four
698
- * entries are exactly what a directly-spawned `codex app-server` needs.
790
+ * inverts the failure mode: a route added later would be reachable here by default. The
791
+ * entries below are exactly what a directly-spawned `codex app-server` needs.
792
+ *
793
+ * `POST /v1/alpha/search` is the native Codex web-search relay. Codex issues it against the
794
+ * same base URL as `/v1/responses`, so leaving it off the list turned every native web search
795
+ * on the direct-spawn host into a 404 (#3192). The handler still runs its own admission, so a
796
+ * loopback caller without a ChatGPT credential is refused inside it rather than by this gate.
699
797
  *
700
798
  * `GET /v1/models` is on the list for a reason that is easy to miss. When catalog
701
799
  * materialization fails or finds no source, `syncCodex` warns and injects with
@@ -709,6 +807,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
709
807
  return req.method === "POST" || req.headers.get("upgrade")?.toLowerCase() === "websocket";
710
808
  }
711
809
  if (path === "/v1/responses/compact") return req.method === "POST";
810
+ if (path === "/v1/alpha/search") return req.method === "POST";
712
811
  if (path === "/v1/models") return req.method === "GET";
713
812
  // Standalone realtime voice sessions (codex-rs thread/realtime/start, WebSocket
714
813
  // transport) — a directly-spawned `codex app-server` needs these for desktop
@@ -720,6 +819,33 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
720
819
  return false;
721
820
  }
722
821
 
822
+ /**
823
+ * Routes the loopback hub-management listener will serve. This is default-deny so adding a
824
+ * data-plane or health route to the public handler cannot silently expose it through Tailscale
825
+ * Serve. A dotted GUI path is admitted only when it resolves to a packaged file; extensionless
826
+ * GETs intentionally retain the existing SPA fallback.
827
+ */
828
+ function managementIngressRouteAllowed(url: URL, req: Request): boolean {
829
+ const rawPath = url.pathname;
830
+ if (req.headers.get("upgrade")?.toLowerCase() === "websocket") return false;
831
+ if (rawPath === "/opencodex-session") return req.method === "GET" || req.method === "POST";
832
+ if (rawPath.startsWith("/api/")) return true;
833
+ if (req.method !== "GET" && req.method !== "HEAD") return false;
834
+ let decodedPath: string;
835
+ try {
836
+ decodedPath = decodeURIComponent(rawPath);
837
+ } catch {
838
+ return false;
839
+ }
840
+ if (
841
+ decodedPath.startsWith("/v1/")
842
+ || decodedPath === "/healthz"
843
+ || decodedPath === "/readyz"
844
+ ) return false;
845
+ if (decodedPath === "/" || !decodedPath.includes(".")) return true;
846
+ return serveGuiFile(rawPath) !== null;
847
+ }
848
+
723
849
  // Codex treats empty / non-JSON 503 bodies as "Unknown error" (#452). Keep Retry-After and
724
850
  // the server_is_overloaded code so clients can back off, but always return a JSON envelope.
725
851
  // These two run BEFORE the auth/origin checks, so they need the receiving listener's policy
@@ -871,6 +997,14 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
871
997
  };
872
998
  let server: Server<WsData>;
873
999
  let loopbackServer: Server<WsData> | null = null;
1000
+ let managementIngressServer: Server<WsData> | null = null;
1001
+
1002
+ type ServerIngress = "public" | "unauthenticated-loopback" | "hub-management";
1003
+ function ingressForServer(requestServer: Server<WsData>): ServerIngress {
1004
+ if (requestServer === loopbackServer) return "unauthenticated-loopback";
1005
+ if (requestServer === managementIngressServer) return "hub-management";
1006
+ return "public";
1007
+ }
874
1008
  let backgroundLifecycle: ReturnType<typeof acquireServerBackgroundLifecycle> | null = null;
875
1009
  try {
876
1010
  backgroundLifecycle = acquireServerBackgroundLifecycle(applyPolicy);
@@ -883,22 +1017,32 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
883
1017
  idleTimeout: 255,
884
1018
  maxRequestBodySize: MAX_DECOMPRESSED_BODY_BYTES,
885
1019
  async fetch(req: Request, requestServer: Server<WsData>): Promise<Response> {
1020
+ const ingress = ingressForServer(requestServer);
886
1021
  // The unauthenticated loopback listener (#1102) serves a fixed allowlist and nothing
887
1022
  // else. Rejecting here, before any handler runs, is what keeps the surface from growing
888
1023
  // silently when a route is added below.
889
- if (requestServer === loopbackServer && !loopbackRouteAllowed(new URL(req.url), req)) {
1024
+ if (ingress === "unauthenticated-loopback" && !loopbackRouteAllowed(new URL(req.url), req)) {
890
1025
  return withCors(
891
1026
  formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${new URL(req.url).pathname}`),
892
1027
  req,
893
1028
  loopbackPolicy(),
894
1029
  );
895
1030
  }
1031
+ // Tailscale Serve terminates only on this separately bound loopback socket. Reject before
1032
+ // dispatch so no data, readiness, health, WebSocket, or unknown-static handler can run.
1033
+ if (ingress === "hub-management" && !managementIngressRouteAllowed(new URL(req.url), req)) {
1034
+ return withCors(
1035
+ formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${new URL(req.url).pathname}`),
1036
+ req,
1037
+ config,
1038
+ );
1039
+ }
896
1040
  // Auth and CORS decisions below read `policy`, not `config`. For the public listener the
897
1041
  // two are the same object, so its behaviour is unchanged; for the loopback listener the
898
1042
  // view substitutes 127.0.0.1 as the bind address, which is what routes it through the
899
1043
  // same code path a plain loopback bind has always taken — Host-header check included.
900
1044
  // Routing, provider selection and response bodies keep using `config`.
901
- const policy: RequestPolicyView = requestServer === loopbackServer ? loopbackPolicy() : config;
1045
+ const policy: RequestPolicyView = ingress === "unauthenticated-loopback" ? loopbackPolicy() : config;
902
1046
  const url = new URL(req.url);
903
1047
  markActivity(`${req.method} ${url.pathname}`);
904
1048
 
@@ -1008,6 +1152,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
1008
1152
  port: healthPort,
1009
1153
  restartCapability: SYSTEM_RESTART_CAPABILITY_VERSION,
1010
1154
  providerReloadCapability: LOCAL_PROVIDER_RELOAD_CAPABILITY_VERSION,
1155
+ guiPairCapability: GUI_PAIR_CAPABILITY_VERSION,
1011
1156
  }, 200, req, policy);
1012
1157
  const challenge = req.headers.get(LOCAL_ATTESTATION_CHALLENGE_HEADER);
1013
1158
  if (challenge) {
@@ -1041,6 +1186,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
1041
1186
  pid: process.pid,
1042
1187
  port: boundPort ?? listenPort,
1043
1188
  status,
1189
+ ...readyProtocolMetadata(config, req),
1044
1190
  };
1045
1191
  if (status === "ready") {
1046
1192
  return jsonResponse(body, 200, req, policy);
@@ -1065,7 +1211,29 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
1065
1211
  // gate used. Consent-bearing routes need this: request headers are forgeable
1066
1212
  // by anything holding the admin token, the credential is not.
1067
1213
  const principal = managementPrincipal(req, managementAuth, config, localManagementAuth) ?? undefined;
1068
- const mgmtResponse = await handleManagementAPI(req, url, config, deps.managementApi, principal);
1214
+ if (url.pathname === GUI_PAIR_PATH) {
1215
+ if (req.method !== "POST" || principal !== "gui-pair-capability" || !managementAuth.available) {
1216
+ return withManagementCors(Response.json({ error: "GUI pairing capability required" }, { status: 403 }), req, config);
1217
+ }
1218
+ try {
1219
+ const grant = createGuiPairingGrant(
1220
+ req.headers.get(GUI_PAIR_BROWSER_ORIGIN_HEADER) ?? "",
1221
+ config,
1222
+ managementAuth,
1223
+ );
1224
+ return withManagementCors(Response.json(grant, {
1225
+ status: 201,
1226
+ headers: { "Cache-Control": "no-store" },
1227
+ }), req, config);
1228
+ } catch (error) {
1229
+ const status = error instanceof GuiPairingGrantRateLimitError ? 429 : 403;
1230
+ return withManagementCors(Response.json({ error: "GUI pairing grant refused" }, {
1231
+ status,
1232
+ ...(status === 429 ? { headers: { "Retry-After": "60" } } : {}),
1233
+ }), req, config);
1234
+ }
1235
+ }
1236
+ const mgmtResponse = await handleManagementAPI(req, url, config, deps.managementApi, principal, managementSessionControl);
1069
1237
  if (mgmtResponse) return withManagementCors(mgmtResponse, req, config);
1070
1238
  return withManagementCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, config);
1071
1239
  }
@@ -1121,23 +1289,34 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
1121
1289
  }
1122
1290
  const headers: Record<string, string> = {
1123
1291
  "content-type": "application/json",
1124
- // Identity-varying content behind a credential: never let a shared cache keep it.
1125
- "cache-control": "private, no-cache",
1292
+ // Identity-varying content behind a credential: never let a shared cache keep it,
1293
+ // and never hand out a validator it could revalidate with. `no-cache` alone does
1294
+ // not prevent storage — it forces revalidation, and the revalidation is exactly
1295
+ // what would cross identities here, because this body varies by key type and key
1296
+ // id while the ETag would be derived from bytes alone. A store keyed on URL plus
1297
+ // validator could then serve one credential's representation to another. Proving
1298
+ // an identity-partitioned cache key across every intermediary in the path is a
1299
+ // much larger commitment than the bandwidth a 304 saves on this payload, so this
1300
+ // route declines the trade: no-store, no ETag, no 304.
1301
+ //
1302
+ // GET /api/catalog keeps its validator. That route is management-authenticated
1303
+ // and loopback-scoped, and its representation does not vary by data-key identity.
1304
+ "cache-control": "no-store",
1126
1305
  };
1127
- if (serialized.etag) headers.ETag = serialized.etag;
1128
1306
  const version = await persistedCodexVersion();
1129
1307
  if (version) headers["x-opencodex-codex-version"] = version;
1130
- // Conditional GET: a client that already holds these bytes re-validates cheaply.
1131
- const ifNoneMatch = req.headers.get("if-none-match")?.trim();
1132
- if (serialized.etag && ifNoneMatch && ifNoneMatch === serialized.etag) {
1133
- return withCors(new Response(null, { status: 304, headers }), req, policy);
1134
- }
1308
+ // No conditional handling: with no validator emitted, an If-None-Match on this route
1309
+ // can only have been guessed or copied from elsewhere, and honoring it would
1310
+ // reintroduce the cross-identity path above. Every request gets the full body.
1135
1311
  if (serialized.bytes !== undefined) headers["content-length"] = String(serialized.bytes);
1136
1312
  // HEAD returns identical status and headers with no body.
1137
- return withCors(
1138
- new Response(req.method === "HEAD" ? null : serialized.body, { status: 200, headers }),
1139
- req,
1140
- policy,
1313
+ return withRemoteCatalogKeyId(
1314
+ withCors(
1315
+ new Response(req.method === "HEAD" ? null : serialized.body, { status: 200, headers }),
1316
+ req,
1317
+ policy,
1318
+ ),
1319
+ admission,
1141
1320
  );
1142
1321
  }
1143
1322
 
@@ -1153,6 +1332,9 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
1153
1332
  if (!isAllowedRequestOrigin(req, policy)) {
1154
1333
  return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy);
1155
1334
  }
1335
+ // The Integrations page reports whether a Cursor client has reached this proxy; the
1336
+ // recorder keeps only a bounded User-Agent value and a timestamp, in memory.
1337
+ recordCursorSeen(req.headers);
1156
1338
  let goModels;
1157
1339
  let modelEntitlements;
1158
1340
  try {
@@ -1172,7 +1354,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
1172
1354
  }
1173
1355
  throw error;
1174
1356
  }
1175
- const { accountBoundNativeOpenAiSlugsBySelector, applyNativeVisibility, buildCatalogEntries, configuredNativeAliasSlugs, desktopAllowlistSuppressedNativeSlugs, disabledNativeSlugs, exactComboCatalogSlugs, loadCatalogTemplate, NATIVE_OPENAI_MODELS, nativeContextLimits, nativeOpenAiSlugs, nativeReasoningEfforts, nativeDefaultReasoningEffort, orderForSubagents, filterCatalogVisibleModels, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, uniqueCatalogModelsForRawPublicList, visibleCodexAccountSelectors, visibleNativeSlugs, desktopVisibleNativeSlugs } = await import("../codex/catalog");
1357
+ const { accountBoundNativeOpenAiSlugsBySelector, applyNativeVisibility, buildCatalogEntries, configuredNativeAliasSlugs, desktopAllowlistSuppressedNativeSlugs, disabledNativeSlugs, exactComboCatalogSlugs, loadCatalogTemplate, NATIVE_OPENAI_MODELS, nativeContextLimits, nativeInputModalities, nativeOpenAiContextWindow, nativeOpenAiContextTier, nativeOpenAiSlugs, nativeReasoningEfforts, nativeDefaultReasoningEffort, orderForSubagents, filterCatalogVisibleModels, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, uniqueCatalogModelsForRawPublicList, visibleCodexAccountSelectors, visibleNativeSlugs, desktopVisibleNativeSlugs } = await import("../codex/catalog");
1176
1358
  const { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } = await import("../codex/catalog/native-models");
1177
1359
  const includeNativeOpenAi = shouldIncludeNativeOpenAi(config);
1178
1360
  const includeAccountBoundNativeOpenAi = shouldIncludeAccountBoundNativeOpenAi(config);
@@ -1258,7 +1440,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
1258
1440
  : idsParam === "desktop"
1259
1441
  ? "desktop3p" as const
1260
1442
  : (/^claude-code\//i.test(req.headers.get("user-agent") ?? "") ? "readable" as const : "desktop3p" as const);
1261
- const data = buildAnthropicModelInfos(desktopNativeSlugs, goOrdered, resolveAutoContext(config.claudeCode), idStyle, activeDesktop3pAlias, nativeContextLimits(config));
1443
+ const data = buildAnthropicModelInfos(desktopNativeSlugs, goOrdered, resolveAutoContext(config.claudeCode), idStyle, activeDesktop3pAlias, nativeContextLimits(config), config.fastMode);
1262
1444
  return jsonResponse({ data }, 200, req, policy);
1263
1445
  }
1264
1446
  if (url.searchParams.has("client_version")) {
@@ -1326,6 +1508,16 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
1326
1508
  reasoning_efforts: efforts.map(effort => grokEffortOption(effort, effort === defaultEffort)),
1327
1509
  };
1328
1510
  };
1511
+ // Cursor's local-agent runtime (Private Inference build) reads api_types + capabilities
1512
+ // to enable its effort control; every other consumer ignores them. See
1513
+ // src/server/models-capabilities.ts.
1514
+ const nativeLimits = nativeContextLimits(config);
1515
+ const nativeContextInput = (metadataId: string) => {
1516
+ const tier = nativeOpenAiContextTier(metadataId, nativeLimits);
1517
+ return tier
1518
+ ? { contextWindow: tier.defaultWindow, longContextWindow: tier.longWindow }
1519
+ : { contextWindow: nativeOpenAiContextWindow(metadataId, nativeLimits) };
1520
+ };
1329
1521
  const nativeModelRow = (id: string, metadataId = id) => ({
1330
1522
  id,
1331
1523
  object: "model",
@@ -1335,7 +1527,24 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
1335
1527
  nativeReasoningEfforts(metadataId),
1336
1528
  nativeDefaultReasoningEffort(metadataId),
1337
1529
  ),
1530
+ ...modelCapabilityFields({
1531
+ reasoningEfforts: nativeReasoningEfforts(metadataId),
1532
+ // Cursor "Max Mode": advertise the family's default/long pair (272k/922k for
1533
+ // GPT-5.6) so the client can pick per request; without a tier, the effective
1534
+ // window is the only value.
1535
+ ...nativeContextInput(metadataId),
1536
+ inputModalities: nativeInputModalities(metadataId),
1537
+ }),
1338
1538
  });
1539
+ // Resolved once per request, not per model: the global fast switch offers the fast
1540
+ // identity to clients that have no Fast toggle of their own. Null when the switch is
1541
+ // off, so the row mapper does no work and loads no adapter module.
1542
+ const cursorFastIdForListing = config.fastMode === true
1543
+ ? await (async () => {
1544
+ const { cursorFastIdFor } = await import("../adapters/cursor/catalog");
1545
+ return (modelId: string, provider = "cursor") => provider === "cursor" ? cursorFastIdFor(modelId) : undefined;
1546
+ })()
1547
+ : null;
1339
1548
  // Selector-active discovery follows the same complete supported set as the Codex catalog
1340
1549
  // for both bare and qualified rows. Without selectors, the live catalog continues to own
1341
1550
  // bare availability.
@@ -1360,7 +1569,11 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
1360
1569
  ...visibleNatives.map(id => nativeModelRow(id)),
1361
1570
  ...visibleAccountNatives.map(({ id, metadataId }) => nativeModelRow(id, metadataId)),
1362
1571
  ...await Promise.all(uniqueCatalogModelsForRawPublicList(goOrdered).map(async m => {
1363
- const publicId = m.alias ?? `${m.provider}/${m.id}`;
1572
+ // Same rule as the anthropic branch: with the global fast switch on, a client
1573
+ // that has no Fast toggle is offered the fast identity directly. An operator
1574
+ // alias is an explicit decision and still wins.
1575
+ const fastModelId = cursorFastIdForListing?.(m.id, m.provider);
1576
+ const publicId = m.alias ?? `${m.provider}/${fastModelId ?? m.id}`;
1364
1577
  const isCombo = m.provider === "combo" && exactComboSlugs.has(publicId);
1365
1578
  const provider = config.providers[m.provider];
1366
1579
  const effective = provider
@@ -1381,6 +1594,13 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
1381
1594
  ...(isCombo ? { is_combo: true } : {}),
1382
1595
  ...(effective ? { alias_of: `${provider?.alias || m.provider}/${effective.alias}` } : {}),
1383
1596
  ...grokEffortFields(m.reasoningEfforts ?? [], m.defaultReasoningEffort),
1597
+ ...modelCapabilityFields({
1598
+ reasoningEfforts: m.reasoningEfforts,
1599
+ // contextWindow is already the post-cap effective value; contextCap is the raw
1600
+ // operator knob and over-reports models whose real window sits below it.
1601
+ contextWindow: m.contextWindow,
1602
+ inputModalities: m.inputModalities,
1603
+ }),
1384
1604
  };
1385
1605
  })),
1386
1606
  ];
@@ -1729,15 +1949,77 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
1729
1949
  return withCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, policy);
1730
1950
  }
1731
1951
 
1952
+ if (url.pathname === "/opencodex-session") {
1953
+ if (req.method === "GET") {
1954
+ const session = issueGuiSession(req, config, managementAuth, {
1955
+ trustedTailscaleIngress: ingress === "hub-management",
1956
+ });
1957
+ return session
1958
+ ? withManagementCors(serveSessionBootstrap(session), req, config)
1959
+ : withManagementCors(new Response(null, { status: 401, headers: { "Cache-Control": "no-store" } }), req, config);
1960
+ }
1961
+ if (req.method === "POST") {
1962
+ // This endpoint is reachable WITHOUT a credential — that is the point of a pairing
1963
+ // exchange — so the body limit has to hold against a caller who controls the
1964
+ // framing. A declared Content-Length is a claim, not a bound: omit the header and
1965
+ // `Number(null ?? "0")` is 0, send `Transfer-Encoding: chunked` and there is no
1966
+ // header at all. Both used to pass the pre-check and land in `req.text()`, which
1967
+ // buffers whatever arrives. The post-check then measured a string the process had
1968
+ // already been forced to hold.
1969
+ //
1970
+ // So the declared length is only a cheap early reject, and the real bound is
1971
+ // applied while reading: stop at limit+1 bytes and never accumulate more.
1972
+ const declaredLength = Number(req.headers.get("content-length") ?? "0");
1973
+ if (!Number.isFinite(declaredLength) || declaredLength > GUI_PAIRING_EXCHANGE_BODY_LIMIT) {
1974
+ return withManagementCors(Response.json({ error: "pairing exchange body too large" }, { status: 413, headers: { "Cache-Control": "no-store" } }), req, config);
1975
+ }
1976
+ const bounded = await readBoundedRequestText(req, GUI_PAIRING_EXCHANGE_BODY_LIMIT);
1977
+ if (bounded === null) {
1978
+ return withManagementCors(Response.json({ error: "pairing exchange body too large" }, { status: 413, headers: { "Cache-Control": "no-store" } }), req, config);
1979
+ }
1980
+ const text = bounded;
1981
+ let body: unknown;
1982
+ try {
1983
+ body = JSON.parse(text);
1984
+ } catch {
1985
+ return withManagementCors(Response.json({ error: "invalid pairing exchange body" }, { status: 400, headers: { "Cache-Control": "no-store" } }), req, config);
1986
+ }
1987
+ if (!body || typeof body !== "object" || Array.isArray(body)
1988
+ || Object.keys(body as Record<string, unknown>).length !== 1
1989
+ || typeof (body as Record<string, unknown>).grant !== "string") {
1990
+ return withManagementCors(Response.json({ error: "invalid pairing exchange body" }, { status: 400, headers: { "Cache-Control": "no-store" } }), req, config);
1991
+ }
1992
+ const pairing = managementAuth.available
1993
+ ? consumeGuiPairingGrant(req, body, config, managementAuth, Date.now(), {
1994
+ ingress: ingress === "hub-management" ? "hub-management" : "public",
1995
+ peerAddress: requestServer.requestIP(req)?.address ?? null,
1996
+ tailscaleUser: ingress === "hub-management" ? req.headers.get("Tailscale-User-Login") : null,
1997
+ browserOrigin: req.headers.get("Origin") ?? "",
1998
+ })
1999
+ : null;
2000
+ if (pairing && "allowed" in pairing) {
2001
+ return withManagementCors(Response.json({ error: "pairing exchange refused" }, {
2002
+ status: 429,
2003
+ headers: { "Cache-Control": "no-store", "Retry-After": String(pairing.retryAfterSeconds) },
2004
+ }), req, config);
2005
+ }
2006
+ return pairing
2007
+ ? withManagementCors(serveSessionBootstrap(pairing), req, config)
2008
+ : withManagementCors(new Response(null, { status: 401, headers: { "Cache-Control": "no-store" } }), req, config);
2009
+ }
2010
+ return withCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, policy);
2011
+ }
1732
2012
  const guiSessionCandidate = req.method === "GET" && (url.pathname === "/" || !url.pathname.includes("."))
1733
- ? issueGuiSession(req, config, managementAuth)
2013
+ ? issueGuiSession(req, config, managementAuth, {
2014
+ trustedTailscaleIngress: ingress === "hub-management",
2015
+ })
1734
2016
  : null;
1735
- // Dedicated bootstrap path: answer without requiring a packaged GUI build, so the
1736
- // Vite dev server can mint an origin-bound loopback session on a fresh checkout.
1737
- if (url.pathname === "/opencodex-session" && guiSessionCandidate) {
1738
- return serveSessionBootstrap(guiSessionCandidate);
1739
- }
1740
- const guiFile = serveGuiFile(url.pathname, undefined, guiSessionCandidate ?? undefined);
2017
+ const guiFile = serveGuiFile(
2018
+ url.pathname,
2019
+ undefined,
2020
+ guiSessionCandidate ?? undefined,
2021
+ config.runtimeRole ?? "standalone",
2022
+ );
1741
2023
  if (guiFile) return guiFile;
1742
2024
  if (url.pathname === "/" && req.method === "GET") {
1743
2025
  return jsonResponse(rootFallbackPayload());
@@ -1985,6 +2267,23 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
1985
2267
  throw error;
1986
2268
  }
1987
2269
  }
2270
+ if (managementIngressPort !== null) {
2271
+ try {
2272
+ managementIngressServer = Bun.serve<WsData>({
2273
+ ...serveOptions,
2274
+ port: managementIngressPort,
2275
+ hostname: "127.0.0.1",
2276
+ });
2277
+ } catch (error) {
2278
+ // Preserve the management bind failure while synchronously initiating rollback of every
2279
+ // listener already opened in this startup transaction. startServer must not become async.
2280
+ for (const bound of [loopbackServer, server]) {
2281
+ if (!bound) continue;
2282
+ try { void bound.stop(true); } catch { /* report the original bind error */ }
2283
+ }
2284
+ throw error;
2285
+ }
2286
+ }
1988
2287
  } catch (error) {
1989
2288
  userCostOverlayReconciler?.stop();
1990
2289
  backgroundLifecycle?.releaseAfterFailedStart();
@@ -1995,6 +2294,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
1995
2294
  bindNativeMainStartupLifecycle(server, nativeMainLifecycle);
1996
2295
  const nativeStop = server.stop.bind(server);
1997
2296
  const loopbackListenerRef = loopbackServer;
2297
+ const managementIngressRef = managementIngressServer;
1998
2298
  Object.defineProperty(server, "stop", {
1999
2299
  configurable: true,
2000
2300
  value: async (closeActiveConnections?: boolean): Promise<void> => {
@@ -2006,13 +2306,24 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
2006
2306
  ...(loopbackListenerRef
2007
2307
  ? [() => loopbackListenerRef.stop(closeActiveConnections)]
2008
2308
  : []),
2309
+ ...(managementIngressRef
2310
+ ? [() => managementIngressRef.stop(closeActiveConnections)]
2311
+ : []),
2009
2312
  async () => {
2010
2313
  userCostOverlayReconciler?.stop();
2011
2314
  },
2012
2315
  ],
2013
2316
  async () => {
2014
- await backgroundLifecycle.release();
2015
- await releaseNativeMainStartupLifecycle(server);
2317
+ try {
2318
+ await backgroundLifecycle.release();
2319
+ await releaseNativeMainStartupLifecycle(server);
2320
+ } finally {
2321
+ // icacls.exe from hardenConfigDir() holds the config dir open; a caller that
2322
+ // removes the dir right after stop() settles would hit EPERM/EBUSY on Windows
2323
+ // otherwise. Runs even when an earlier release rejected — that rejection still
2324
+ // propagates, but not before the child is drained.
2325
+ await flushConfigDirHardening(startupConfigDir);
2326
+ }
2016
2327
  },
2017
2328
  );
2018
2329
  },
@@ -2040,6 +2351,12 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
2040
2351
  console.warn(` remote clients. Not for shared or multi-tenant hosts.`);
2041
2352
  }
2042
2353
 
2354
+ if (managementIngressServer) {
2355
+ const managementPort = managementIngressServer.port ?? managementIngressPort;
2356
+ console.log(`🔒 Hub management ingress active on http://127.0.0.1:${managementPort}`);
2357
+ console.log(` GUI and /api/* only; data, health, readiness, and WebSockets are disabled.`);
2358
+ }
2359
+
2043
2360
  // Prime pool-account quota in the background so the rotation engine has real
2044
2361
  // usage scores from the first routing decision, even when the dashboard is
2045
2362
  // never opened (the common CLI/WSL case). Fire-and-forget: never blocks the
@@ -2077,5 +2394,14 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
2077
2394
  activateLab(config, labConfigDir);
2078
2395
  }
2079
2396
 
2397
+ // Reset-credit auto-redemption (#822) is opt-in; a default install constructs nothing here.
2398
+ // Activation is synchronous (timer registration only); network work happens on the timer.
2399
+ if (config.resetCreditAutoRedeem?.enabled === true) {
2400
+ activateResetCreditAutoRedeem(config, {
2401
+ accountId: MAIN_CODEX_ACCOUNT_ID,
2402
+ ...createResetCreditWhamClient(config, MAIN_CODEX_ACCOUNT_ID),
2403
+ });
2404
+ }
2405
+
2080
2406
  return server;
2081
2407
  }
@@ -1041,6 +1041,11 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
1041
1041
  ...models.filter(m => !isDisabled(m.provider, m.id)).map(m => `${m.provider}/${m.id}`),
1042
1042
  ];
1043
1043
  const aliases: { id: string; display_name: string }[] = [];
1044
+ // Resolved once, not per model: with the global fast switch on, Claude Code discovers the
1045
+ // fast identity, so the dashboard must list the same id rather than the umbrella one.
1046
+ const cursorFastIdFor = config.fastMode === true
1047
+ ? (await import("../../adapters/cursor/catalog")).cursorFastIdFor
1048
+ : undefined;
1044
1049
  for (const slug of listCatalogNativeSlugs()) {
1045
1050
  // Readable CLI-surface alias with hash fallback (devlog 050 / audit 051 #2) —
1046
1051
  // the same shared helper the /v1/models ?ids=cli path uses.
@@ -1048,7 +1053,8 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
1048
1053
  }
1049
1054
  for (const m of models) {
1050
1055
  if (isDisabled(m.provider, m.id)) continue;
1051
- aliases.push({ id: claudeCodeAlias(m.provider, m.id), display_name: `${m.id} (${m.provider})` });
1056
+ const listedId = (m.provider === "cursor" ? cursorFastIdFor?.(m.id) : undefined) ?? m.id;
1057
+ aliases.push({ id: claudeCodeAlias(m.provider, listedId), display_name: `${listedId} (${m.provider})` });
1052
1058
  }
1053
1059
  const contextWindows = buildClaudeContextWindows([...visibleNativeSlugs(config)], models, nativeContextLimits(config));
1054
1060
  const webSearchOverride = config.claudeCode?.webSearchSidecar;