@yansigit/opencodex 2.36.1 → 2.36.2-dev.20260829.52

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 (77) hide show
  1. package/README.md +9 -4
  2. package/gui/dist/assets/{ApiKeys-CsmNyf4I.js → ApiKeys-GpvAosG-.js} +1 -1
  3. package/gui/dist/assets/{Claude-B4sHsGTD.js → Claude-Biju6rfh.js} +1 -1
  4. package/gui/dist/assets/{CodexSet-BuAQ4YQn.js → CodexSet-_EkE9hNC.js} +1 -1
  5. package/gui/dist/assets/{FileIntegrationPage-BC65sEaS.js → FileIntegrationPage-BL55KJCW.js} +1 -1
  6. package/gui/dist/assets/{Grok-DNJWlISf.js → Grok-Dp3sOS5B.js} +1 -1
  7. package/gui/dist/assets/{Integrations-vGJshFZK.js → Integrations-Bx0V4Dx1.js} +2 -2
  8. package/gui/dist/assets/{IntegrationsOverview-DYMkDFHE.js → IntegrationsOverview-CT41lCh4.js} +1 -1
  9. package/gui/dist/assets/{Logs-Coc7_RPP.js → Logs-BxS_u7Hr.js} +1 -1
  10. package/gui/dist/assets/{Models-CKVT8Meb.js → Models-DvaBTkUM.js} +1 -1
  11. package/gui/dist/assets/{NumberStepper-Dug4wR8n.js → NumberStepper-97eq4OKR.js} +1 -1
  12. package/gui/dist/assets/Providers-CB9I0Vv1.js +1 -0
  13. package/gui/dist/assets/{RestoreDialog-BpuvVU2w.js → RestoreDialog-C9sltYZD.js} +1 -1
  14. package/gui/dist/assets/{Startup-DAUQqNEN.js → Startup-BvFv8yC2.js} +1 -1
  15. package/gui/dist/assets/{Storage-DfP95SlJ.js → Storage-CKa2mqSI.js} +1 -1
  16. package/gui/dist/assets/{Subagents-CMNsZLfk.js → Subagents-B4oMNOVh.js} +1 -1
  17. package/gui/dist/assets/{Usage-ESFRfBMO.js → Usage-gaSKtbMw.js} +1 -1
  18. package/gui/dist/assets/{codex-stale-banner-W4FhikN7.js → codex-stale-banner-B5VQPO3t.js} +1 -1
  19. package/gui/dist/assets/{data-surface-B8PX8rjm.js → data-surface-B0APtUxm.js} +1 -1
  20. package/gui/dist/assets/{data-surface-CETFXqiA.js → data-surface-CH5yKPY1.js} +1 -1
  21. package/gui/dist/assets/index-C8flH881.css +1 -0
  22. package/gui/dist/assets/{index-DHQHBRhb.js → index-DWxg-UlU.js} +3 -3
  23. package/gui/dist/assets/{model-display-OBTJhQwT.js → model-display-vDl3mo07.js} +1 -1
  24. package/gui/dist/assets/{provider-payload-CJRJUn7w.js → provider-payload-C3Xp5N0y.js} +1 -1
  25. package/gui/dist/assets/{section-tabs-B8sLMiz6.js → section-tabs-Bpm5lFcZ.js} +1 -1
  26. package/gui/dist/assets/shared-BHpPx-nZ.js +69 -0
  27. package/gui/dist/index.html +3 -3
  28. package/package.json +1 -1
  29. package/src/adapters/base.ts +2 -0
  30. package/src/adapters/command-code.ts +2 -1
  31. package/src/adapters/google-http.ts +34 -14
  32. package/src/adapters/google.ts +4 -1
  33. package/src/cli/index.ts +9 -1
  34. package/src/codex/inject.ts +14 -9
  35. package/src/config/process-state.ts +15 -0
  36. package/src/config.ts +17 -0
  37. package/src/generated/compatibility-version.json +61 -41
  38. package/src/lib/bun-stream-caps.ts +2 -1
  39. package/src/lib/pinned-http.ts +7 -1
  40. package/src/lib/process-control.ts +3 -2
  41. package/src/lib/provider-outbound.ts +77 -27
  42. package/src/lib/provider-runtime-fetch.ts +23 -0
  43. package/src/lib/provider-tls-profile.ts +97 -11
  44. package/src/lib/server-tls.ts +47 -0
  45. package/src/lib/test-provider-fetch.ts +6 -0
  46. package/src/lib/test-server-start.ts +5 -0
  47. package/src/lib/upstream-retry.ts +39 -25
  48. package/src/oauth/anthropic.ts +4 -3
  49. package/src/oauth/chatgpt.ts +7 -3
  50. package/src/oauth/command-code.ts +3 -2
  51. package/src/oauth/cursor.ts +3 -2
  52. package/src/oauth/github-copilot.ts +7 -5
  53. package/src/oauth/google-antigravity.ts +5 -4
  54. package/src/oauth/key-providers.ts +11 -13
  55. package/src/oauth/kimi.ts +7 -6
  56. package/src/oauth/kiro.ts +3 -2
  57. package/src/oauth/nous.ts +5 -5
  58. package/src/oauth/transport.ts +77 -0
  59. package/src/oauth/xai.ts +5 -4
  60. package/src/providers/replit/probe.ts +8 -3
  61. package/src/providers/xai-transport.ts +16 -2
  62. package/src/server/auth-cors.ts +15 -1
  63. package/src/server/chat-native.ts +9 -3
  64. package/src/server/index.ts +63 -23
  65. package/src/server/management/config-routes.ts +117 -15
  66. package/src/server/management/context.ts +4 -0
  67. package/src/server/management/provider-routes.ts +1 -0
  68. package/src/server/responses/compact.ts +5 -2
  69. package/src/server/responses/core.ts +16 -19
  70. package/src/server/responses/fetch-helpers.ts +15 -7
  71. package/src/server/ws-bridge.ts +33 -4
  72. package/src/types/config.ts +9 -0
  73. package/src/types/provider.ts +2 -0
  74. package/src/types.ts +1 -0
  75. package/gui/dist/assets/Providers-DyaCHRWL.js +0 -1
  76. package/gui/dist/assets/index-pLho_LBF.css +0 -1
  77. package/gui/dist/assets/shared-DI18-uF7.js +0 -69
@@ -60,7 +60,7 @@ import { runAlibabaRegionStartupMigration } from "../providers/alibaba-region-st
60
60
  import { runModelRenameStartupMigration } from "../providers/model-rename-startup";
61
61
  import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers";
62
62
  import { providerCodexAccountMode } from "../providers/registry";
63
- import type { StorageCleanupPolicy } from "../types";
63
+ import type { OcxConfig, StorageCleanupPolicy } from "../types";
64
64
  import { MAX_DECOMPRESSED_BODY_BYTES } from "./request-decompress";
65
65
  import {
66
66
  CodexAccountCooldownError,
@@ -158,6 +158,7 @@ import {
158
158
  jsonResponse,
159
159
  admissionFields,
160
160
  resolveApiAuth,
161
+ resolveDataPlaneAdmissionSecret,
161
162
  resolveResponsesApiAuth,
162
163
  requestPolicyView,
163
164
  type RequestPolicyView,
@@ -166,6 +167,10 @@ import {
166
167
  withCors,
167
168
  withManagementCors,
168
169
  } from "./auth-cors";
170
+ import {
171
+ managementBodyTooLargeResponse,
172
+ readManagementJsonBody,
173
+ } from "./management/body";
169
174
  export {
170
175
  assertServerAuthConfig,
171
176
  corsHeaders,
@@ -210,6 +215,8 @@ import {
210
215
  import { SYSTEM_RESTART_CAPABILITY_VERSION } from "../lib/system-restart-contract";
211
216
  import { LOCAL_PROVIDER_RELOAD_CAPABILITY_VERSION } from "../lib/local-provider-reload-contract";
212
217
  import { createReadinessGate, type ReadinessGate } from "./readiness";
218
+ import { allowPlaintextRemoteForTests } from "../lib/test-server-start";
219
+ import { canonicalServerOrigin } from "../lib/server-tls";
213
220
  import {
214
221
  createRuntimePackageTreeIntegrityGuard,
215
222
  type PackageTreeIntegrityGuard,
@@ -217,18 +224,22 @@ import {
217
224
  import { detectInstall } from "../update/index";
218
225
  import { runAiStudioNativeLogin } from "../oauth/aistudio-native-daemon";
219
226
 
220
- function isAiStudioSessionOrigin(origin: string | null): boolean {
221
- return !!origin && (origin.startsWith("chrome-extension://") || origin === "https://aistudio.google.com");
227
+ function isAiStudioSessionOrigin(origin: string | null, config: Pick<OcxConfig, "corsAllowOrigins">): boolean {
228
+ return !!origin && (
229
+ origin === "https://aistudio.google.com"
230
+ || (origin.startsWith("chrome-extension://") && config.corsAllowOrigins?.includes(origin) === true)
231
+ );
222
232
  }
223
233
 
224
- function withAiStudioSessionCors(resp: Response, req: Request): Response {
234
+ function withAiStudioSessionCors(resp: Response, req: Request, config: RequestPolicyView): Response {
225
235
  const origin = req.headers.get("Origin");
226
- if (isAiStudioSessionOrigin(origin) && origin) resp.headers.set("Access-Control-Allow-Origin", origin);
236
+ if (isAiStudioSessionOrigin(origin, config) && origin) resp.headers.set("Access-Control-Allow-Origin", origin);
227
237
  return resp;
228
238
  }
229
239
 
230
240
  export const MAX_WS_FRAME_BYTES = 50 * 1024 * 1024;
231
- const WEBSOCKET_IDLE_TIMEOUT_SECONDS = 0;
241
+ const WEBSOCKET_IDLE_TIMEOUT_SECONDS = 255;
242
+ const WEBSOCKET_BACKPRESSURE_LIMIT = 1024 * 1024;
232
243
  const LIVE_SIDEBAND_PENDING_MAX = 32;
233
244
  const LIVE_SIDEBAND_PENDING_BYTES_MAX = 1024 * 1024;
234
245
  const LIVE_SIDEBAND_CLOSE_FALLBACK_MS = 1_000;
@@ -407,11 +418,16 @@ function attachLiveSidebandUpstream(
407
418
  return;
408
419
  }
409
420
  logLiveSidebandFrame("u2c", event.data);
410
- if (typeof event.data === "string") ws.send(event.data);
411
- else if (event.data instanceof ArrayBuffer) ws.send(event.data);
412
- else if (ArrayBuffer.isView(event.data)) {
413
- ws.send(event.data.buffer.slice(event.data.byteOffset, event.data.byteOffset + event.data.byteLength));
414
- } else ws.send(event.data as Buffer);
421
+ const result = typeof event.data === "string"
422
+ ? ws.send(event.data)
423
+ : event.data instanceof ArrayBuffer
424
+ ? ws.send(event.data)
425
+ : ArrayBuffer.isView(event.data)
426
+ ? ws.send(event.data.buffer.slice(event.data.byteOffset, event.data.byteOffset + event.data.byteLength))
427
+ : ws.send(event.data as Buffer);
428
+ // The upstream cannot be paused; shed the socket as soon as Bun reports
429
+ // backpressure or a dropped send rather than building an application queue.
430
+ if (result <= 0) closeLiveSideband(ws, 1009, "backpressure limit");
415
431
  } catch {
416
432
  closeLiveSideband(ws, 1011, "client send failed");
417
433
  }
@@ -554,7 +570,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
554
570
  warnAgentTaskRecoveryStartup(config);
555
571
  setLiveStateStoreConfig(config);
556
572
  applyProxyEnv(config);
557
- assertServerAuthConfig(config);
573
+ assertServerAuthConfig(config, { allowPlaintextRemoteForTests: allowPlaintextRemoteForTests() });
558
574
  const managementAuth = deps.managementAuthState ?? initializeManagementAuthState(config);
559
575
  let userCostOverlayReconciler: { stop(): void } | null = null;
560
576
  // Arm synchronously before listen. A pending journal therefore makes __main__ unusable
@@ -865,6 +881,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
865
881
  // release the owner-scoped lease on any listener failure.
866
882
  userCostOverlayReconciler = startUserCostOverlayReconciler({ liveConfig: config });
867
883
  const serveOptions = {
884
+ ...(config.tls ? { tls: { cert: Bun.file(config.tls.certFile), key: Bun.file(config.tls.keyFile) } } : {}),
868
885
  idleTimeout: 255,
869
886
  maxRequestBodySize: MAX_DECOMPRESSED_BODY_BYTES,
870
887
  async fetch(req: Request, requestServer: Server<WsData>): Promise<Response> {
@@ -931,13 +948,13 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
931
948
  }
932
949
  if (url.pathname === "/api/aistudio/session") {
933
950
  const origin = req.headers.get("Origin");
934
- if (isAiStudioSessionOrigin(origin)) {
951
+ if (isAiStudioSessionOrigin(origin, policy)) {
935
952
  return new Response(null, {
936
953
  status: 204,
937
954
  headers: {
938
955
  "Access-Control-Allow-Origin": origin as string,
939
956
  "Access-Control-Allow-Methods": "POST, OPTIONS",
940
- "Access-Control-Allow-Headers": "Content-Type, Authorization, X-OpenCodex-API-Key",
957
+ "Access-Control-Allow-Headers": "Content-Type, X-OpenCodex-API-Key",
941
958
  Vary: "Origin, Access-Control-Request-Headers",
942
959
  },
943
960
  });
@@ -1005,16 +1022,19 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
1005
1022
  }
1006
1023
 
1007
1024
  if (url.pathname === "/api/aistudio/session" && req.method === "POST") {
1008
- const admission = resolveApiAuth(req, policy);
1025
+ const dedicated = req.headers.get("x-opencodex-api-key")?.trim() ?? "";
1026
+ const admission = dedicated
1027
+ ? resolveDataPlaneAdmissionSecret(dedicated, config, "dedicated")
1028
+ : null;
1009
1029
  if (!admission) {
1010
- return withAiStudioSessionCors(withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy), req);
1030
+ return withAiStudioSessionCors(withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy), req, policy);
1011
1031
  }
1012
1032
  const origin = req.headers.get("Origin");
1013
- if (!isAllowedRequestOrigin(req, policy) && !isAiStudioSessionOrigin(origin)) {
1014
- return withAiStudioSessionCors(withCors(formatErrorResponse(403, "origin_rejected", "cross-origin request blocked"), req, policy), req);
1033
+ if (!isAiStudioSessionOrigin(origin, policy)) {
1034
+ return withAiStudioSessionCors(withCors(formatErrorResponse(403, "origin_rejected", "cross-origin request blocked"), req, policy), req, policy);
1015
1035
  }
1016
1036
  try {
1017
- const bodyJson = await req.json() as any;
1037
+ const bodyJson = await readManagementJsonBody(req) as any;
1018
1038
  const { saveAiStudioSession, saveAiStudioSessionFromToken } = await import("../oauth/aistudio-session-sync");
1019
1039
  if (typeof bodyJson.token === "string" && bodyJson.token) {
1020
1040
  saveAiStudioSessionFromToken(bodyJson.token);
@@ -1025,11 +1045,16 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
1025
1045
  cookies: bodyJson.cookies,
1026
1046
  });
1027
1047
  } else {
1028
- return withAiStudioSessionCors(withCors(jsonResponse({ error: "invalid session payload" }, 400), req, policy), req);
1048
+ return withAiStudioSessionCors(withCors(jsonResponse({ error: "invalid session payload" }, 400), req, policy), req, policy);
1029
1049
  }
1030
- return withAiStudioSessionCors(withCors(jsonResponse({ ok: true, message: "AI Studio session updated successfully" }), req, policy), req);
1050
+ return withAiStudioSessionCors(withCors(jsonResponse({ ok: true, message: "AI Studio session updated successfully" }), req, policy), req, policy);
1031
1051
  } catch (error) {
1032
- return withAiStudioSessionCors(withCors(jsonResponse({ error: String(error) }, 400), req, policy), req);
1052
+ const tooLarge = managementBodyTooLargeResponse(error, req, config);
1053
+ return withAiStudioSessionCors(
1054
+ withCors(tooLarge ?? jsonResponse({ error: "invalid session payload" }, 400), req, policy),
1055
+ req,
1056
+ policy,
1057
+ );
1033
1058
  }
1034
1059
  }
1035
1060
 
@@ -1749,6 +1774,8 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
1749
1774
  websocket: {
1750
1775
  maxPayloadLength: MAX_WS_FRAME_BYTES,
1751
1776
  idleTimeout: WEBSOCKET_IDLE_TIMEOUT_SECONDS,
1777
+ backpressureLimit: WEBSOCKET_BACKPRESSURE_LIMIT,
1778
+ closeOnBackpressureLimit: true,
1752
1779
  // Responses WebSocket data plane (phase 120.2). Re-frames the same SSE pipeline onto the
1753
1780
  // socket: parse response.create → run handleResponses unchanged → pump its SSE body as WS
1754
1781
  // Text frames. response.processed is a no-op ack. close() aborts the upstream (RC2 parity).
@@ -1769,6 +1796,9 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
1769
1796
  ws.data.admissionLease.bind(ws);
1770
1797
  registerCodexWebSocket(ws);
1771
1798
  },
1799
+ drain(ws: ServerWebSocket<WsData>) {
1800
+ ws.data.wsDrain?.();
1801
+ },
1772
1802
  message(ws: ServerWebSocket<WsData>, raw: string | Buffer) {
1773
1803
  if (ws.data.kind === "live-sideband") {
1774
1804
  if (ws.data.liveClosing) return;
@@ -1907,6 +1937,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
1907
1937
  },
1908
1938
  });
1909
1939
  await sendResponseToWebSocket(ws, response, isCurrent, {
1940
+ abortSignal: turnAbort.signal,
1910
1941
  onSsePayload: payload => inspectResponseLogSsePayload(logCtx, payload),
1911
1942
  onTerminal: status => {
1912
1943
  terminalRecorder?.(status, logCtx.terminalHttpStatus);
@@ -1957,6 +1988,8 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
1957
1988
  ws.data.admissionLease?.release();
1958
1989
  ws.data.admissionLease = undefined;
1959
1990
  ws.data.cancel?.(); // RC2: abort the upstream when the client disconnects
1991
+ ws.data.wsDrainWaiter?.resolve();
1992
+ ws.data.wsDrainWaiter = undefined;
1960
1993
  },
1961
1994
  },
1962
1995
  } as const;
@@ -1971,6 +2004,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
1971
2004
  try {
1972
2005
  loopbackServer = Bun.serve<WsData>({
1973
2006
  ...serveOptions,
2007
+ tls: undefined,
1974
2008
  port: loopbackListenerPort,
1975
2009
  hostname: "127.0.0.1",
1976
2010
  });
@@ -2022,9 +2056,15 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
2022
2056
  setServerRef(server);
2023
2057
  const actualPort = server.port ?? listenPort;
2024
2058
  boundPort = actualPort;
2059
+ managementApi.activeServerOrigin = canonicalServerOrigin(config, actualPort);
2060
+ managementApi.activeServerConfig = {
2061
+ hostname: config.hostname,
2062
+ port: actualPort,
2063
+ tls: config.tls ? { ...config.tls } : undefined,
2064
+ };
2025
2065
  setCorsOrigin(actualPort);
2026
2066
 
2027
- console.log(`🚀 opencodex proxy running on http://localhost:${actualPort}`);
2067
+ console.log(`🚀 opencodex proxy running on ${managementApi.activeServerOrigin}`);
2028
2068
  console.log(` POST /v1/responses → provider translation`);
2029
2069
  console.log(` POST /v1/chat/completions → OpenAI-compatible clients`);
2030
2070
  console.log(` GET /healthz → health check`);
@@ -11,7 +11,9 @@ import {
11
11
  multiAgentGuidanceEnabled,
12
12
  providerBaseUrlConfigError,
13
13
  providerHeadersConfigError,
14
+ validateConfigCandidate,
14
15
  } from "../../config";
16
+ import { canonicalServerOrigin, serverTlsConfigError } from "../../lib/server-tls";
15
17
  import {
16
18
  clearLoginState,
17
19
  getLoginStatus,
@@ -91,7 +93,7 @@ import { drainAndShutdown } from "../lifecycle";
91
93
  import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "../request-log";
92
94
  import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../../usage/cost";
93
95
  import type { PersistedUsageAttempt } from "../../usage/log";
94
- import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors";
96
+ import { assertServerAuthConfig, configuredApiAuthToken, isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors";
95
97
  import { withProviderServiceTierDTO } from "./provider-capability-config";
96
98
  import { applySystemEnvToggle } from "../system-env";
97
99
  import { getCachedStartupHealth, invalidateStartupHealthCache } from "../startup-health-cache";
@@ -131,6 +133,40 @@ async function sidecarVisionResponseSettings(config: OcxConfig): Promise<{
131
133
  return { model, reasoning, models };
132
134
  }
133
135
 
136
+ function aiStudioExtensionOrigin(config: Pick<OcxConfig, "corsAllowOrigins">): string | null {
137
+ return config.corsAllowOrigins?.find(origin => origin.startsWith("chrome-extension://")) ?? null;
138
+ }
139
+
140
+ function serverSettings(
141
+ config: OcxConfig,
142
+ activeOrigin?: string,
143
+ activeConfig?: Pick<OcxConfig, "hostname" | "port" | "tls">,
144
+ ) {
145
+ const active = activeOrigin ?? canonicalServerOrigin(config, config.port);
146
+ const configuredOrigin = canonicalServerOrigin(config, config.port);
147
+ const configuredListener = {
148
+ hostname: config.hostname ?? "127.0.0.1",
149
+ port: config.port,
150
+ tls: config.tls ?? null,
151
+ };
152
+ const activeListener = activeConfig && {
153
+ hostname: activeConfig.hostname ?? "127.0.0.1",
154
+ port: activeConfig.port,
155
+ tls: activeConfig.tls ?? null,
156
+ };
157
+ return {
158
+ configured: {
159
+ ...configuredListener,
160
+ aiStudioOrigin: aiStudioExtensionOrigin(config),
161
+ },
162
+ activeOrigin: active,
163
+ credentialConfigured: !!configuredApiAuthToken(config)
164
+ || (config.apiKeys ?? []).some(entry => !!entry.key.trim()),
165
+ restartRequired: active !== configuredOrigin
166
+ || !!activeListener && JSON.stringify(activeListener) !== JSON.stringify(configuredListener),
167
+ };
168
+ }
169
+
134
170
  /** One client's outcome from a fan-out sync. Absent from the list means "left alone". */
135
171
  interface ClientIntegrationSyncOutcome {
136
172
  readonly client: "grok" | "claude-desktop" | "mcode";
@@ -304,6 +340,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
304
340
  // Absent means the historical auto-open, so the GUI can render the toggle
305
341
  // without having to know that `undefined` and `true` mean the same thing.
306
342
  oauthOpenBrowser: config.oauthOpenBrowser !== false,
343
+ server: serverSettings(config, deps.activeServerOrigin, deps.activeServerConfig),
307
344
  startupHealth: await readStartupHealth(config),
308
345
  codexRuntime: {
309
346
  path: displayCodexRuntimePath(resolved.runtime.command),
@@ -390,14 +427,16 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
390
427
  codexAccountPickerEnabled?: unknown;
391
428
  oauthOpenBrowser?: unknown;
392
429
  showCodexSparkQuota?: unknown;
430
+ server?: unknown;
393
431
  };
394
432
  if (body.codexAutoStart === undefined
395
433
  && body.streamMode === undefined
396
434
  && body.appOwnedMemoryBudgetMb === undefined
397
435
  && body.codexAccountPickerEnabled === undefined
398
436
  && body.oauthOpenBrowser === undefined
399
- && body.showCodexSparkQuota === undefined) {
400
- return jsonResponse({ error: "provide codexAutoStart, streamMode, appOwnedMemoryBudgetMb, codexAccountPickerEnabled, oauthOpenBrowser, or showCodexSparkQuota" }, 400);
437
+ && body.showCodexSparkQuota === undefined
438
+ && body.server === undefined) {
439
+ return jsonResponse({ error: "provide a supported settings field" }, 400);
401
440
  }
402
441
  if (body.codexAutoStart !== undefined && typeof body.codexAutoStart !== "boolean") {
403
442
  return jsonResponse({ error: "codexAutoStart boolean is required" }, 400);
@@ -415,6 +454,30 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
415
454
  if (body.showCodexSparkQuota !== undefined && typeof body.showCodexSparkQuota !== "boolean") {
416
455
  return jsonResponse({ error: "showCodexSparkQuota boolean is required" }, 400);
417
456
  }
457
+ let nextServer: {
458
+ hostname: string;
459
+ port: number;
460
+ tls: OcxConfig["tls"];
461
+ aiStudioOrigin: string | null;
462
+ } | undefined;
463
+ if (body.server !== undefined) {
464
+ if (!isPlainRecord(body.server)) return jsonResponse({ error: "server must be an object" }, 400);
465
+ const raw = body.server;
466
+ if (typeof raw.hostname !== "string" || !raw.hostname.trim()) return jsonResponse({ error: "server.hostname must be a non-empty string" }, 400);
467
+ if (typeof raw.port !== "number" || !Number.isInteger(raw.port) || raw.port < 0 || raw.port > 65535) return jsonResponse({ error: "server.port must be an integer from 0 to 65535" }, 400);
468
+ const tlsError = raw.tls === null ? null : serverTlsConfigError(raw.tls);
469
+ if (tlsError) return jsonResponse({ error: tlsError }, 400);
470
+ if (raw.aiStudioOrigin !== null && (
471
+ typeof raw.aiStudioOrigin !== "string"
472
+ || !/^chrome-extension:\/\/[a-z]{32}$/.test(raw.aiStudioOrigin)
473
+ )) return jsonResponse({ error: "server.aiStudioOrigin must be an exact chrome-extension origin or null" }, 400);
474
+ nextServer = {
475
+ hostname: raw.hostname.trim(),
476
+ port: raw.port,
477
+ tls: raw.tls === null ? undefined : raw.tls as OcxConfig["tls"],
478
+ aiStudioOrigin: raw.aiStudioOrigin,
479
+ };
480
+ }
418
481
  if (body.appOwnedMemoryBudgetMb !== undefined && (
419
482
  typeof body.appOwnedMemoryBudgetMb !== "number"
420
483
  || !Number.isInteger(body.appOwnedMemoryBudgetMb)
@@ -438,36 +501,67 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
438
501
  hasOauthOpenBrowser: Object.hasOwn(config, "oauthOpenBrowser"),
439
502
  showCodexSparkQuota: config.showCodexSparkQuota,
440
503
  hasShowCodexSparkQuota: Object.hasOwn(config, "showCodexSparkQuota"),
504
+ hostname: config.hostname,
505
+ hasHostname: Object.hasOwn(config, "hostname"),
506
+ port: config.port,
507
+ tls: config.tls,
508
+ hasTls: Object.hasOwn(config, "tls"),
509
+ corsAllowOrigins: config.corsAllowOrigins,
510
+ hasCorsAllowOrigins: Object.hasOwn(config, "corsAllowOrigins"),
441
511
  };
442
512
  const pickerWasEnabled = codexAccountPickerEnabled(config);
443
- let pickerIsEnabled = pickerWasEnabled;
444
- try {
513
+ const applySettings = (target: OcxConfig): boolean => {
445
514
  if (typeof body.codexAutoStart === "boolean") {
446
- config.codexAutoStart = body.codexAutoStart;
515
+ target.codexAutoStart = body.codexAutoStart;
447
516
  }
448
517
  if (body.streamMode !== undefined) {
449
518
  if (body.streamMode === "auto") {
450
- deleteConfigTopLevelKey(config, "streamMode");
519
+ deleteConfigTopLevelKey(target, "streamMode");
451
520
  } else {
452
- config.streamMode = body.streamMode as "legacy-tee" | "eager-relay";
521
+ target.streamMode = body.streamMode as "legacy-tee" | "eager-relay";
453
522
  }
454
523
  }
455
524
  if (typeof body.appOwnedMemoryBudgetMb === "number") {
456
- config.appOwnedMemoryBudgetMb = body.appOwnedMemoryBudgetMb;
525
+ target.appOwnedMemoryBudgetMb = body.appOwnedMemoryBudgetMb;
457
526
  }
458
527
  if (body.codexAccountPickerEnabled === true) {
459
- config.codexAccountPickerEnabled = true;
460
- initializeDefaultCodexAccountNamespaces(config);
528
+ target.codexAccountPickerEnabled = true;
529
+ initializeDefaultCodexAccountNamespaces(target);
461
530
  } else if (body.codexAccountPickerEnabled === false) {
462
- config.codexAccountPickerEnabled = false;
531
+ target.codexAccountPickerEnabled = false;
463
532
  }
464
533
  if (typeof body.oauthOpenBrowser === "boolean") {
465
- config.oauthOpenBrowser = body.oauthOpenBrowser;
534
+ target.oauthOpenBrowser = body.oauthOpenBrowser;
466
535
  }
467
536
  if (typeof body.showCodexSparkQuota === "boolean") {
468
- config.showCodexSparkQuota = body.showCodexSparkQuota;
537
+ target.showCodexSparkQuota = body.showCodexSparkQuota;
538
+ }
539
+ if (nextServer) {
540
+ target.hostname = nextServer.hostname;
541
+ target.port = nextServer.port;
542
+ if (nextServer.tls) target.tls = nextServer.tls;
543
+ else deleteConfigTopLevelKey(target, "tls");
544
+ const origins = (target.corsAllowOrigins ?? []).filter(origin => !origin.startsWith("chrome-extension://"));
545
+ if (nextServer.aiStudioOrigin) origins.push(nextServer.aiStudioOrigin);
546
+ if (origins.length) target.corsAllowOrigins = origins;
547
+ else deleteConfigTopLevelKey(target, "corsAllowOrigins");
469
548
  }
470
- pickerIsEnabled = codexAccountPickerEnabled(config);
549
+ return codexAccountPickerEnabled(target);
550
+ };
551
+ if (nextServer) {
552
+ const candidate = structuredClone(config);
553
+ applySettings(candidate);
554
+ const validation = validateConfigCandidate(candidate);
555
+ if (!validation.ok) return jsonResponse({ error: validation.error }, 400);
556
+ try {
557
+ assertServerAuthConfig(candidate);
558
+ } catch (error) {
559
+ return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 400);
560
+ }
561
+ }
562
+ let pickerIsEnabled = pickerWasEnabled;
563
+ try {
564
+ pickerIsEnabled = applySettings(config);
471
565
  saveManagementConfig(deps, config);
472
566
  } catch (error) {
473
567
  if (previousSettings.hasCodexAutoStart) config.codexAutoStart = previousSettings.codexAutoStart;
@@ -489,6 +583,13 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
489
583
  if (previousSettings.hasShowCodexSparkQuota) {
490
584
  config.showCodexSparkQuota = previousSettings.showCodexSparkQuota;
491
585
  } else deleteConfigTopLevelKey(config, "showCodexSparkQuota");
586
+ if (previousSettings.hasHostname) config.hostname = previousSettings.hostname;
587
+ else deleteConfigTopLevelKey(config, "hostname");
588
+ config.port = previousSettings.port;
589
+ if (previousSettings.hasTls) config.tls = previousSettings.tls;
590
+ else deleteConfigTopLevelKey(config, "tls");
591
+ if (previousSettings.hasCorsAllowOrigins) config.corsAllowOrigins = previousSettings.corsAllowOrigins;
592
+ else deleteConfigTopLevelKey(config, "corsAllowOrigins");
492
593
  throw error;
493
594
  }
494
595
  if (typeof body.appOwnedMemoryBudgetMb === "number") {
@@ -512,6 +613,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
512
613
  catalogRefreshPending,
513
614
  showCodexSparkQuota: config.showCodexSparkQuota === true,
514
615
  startupHealth: await readStartupHealth(config),
616
+ server: serverSettings(config, deps.activeServerOrigin, deps.activeServerConfig),
515
617
  });
516
618
  }
517
619
 
@@ -18,6 +18,10 @@ import type {
18
18
  } from "../../codex/app-server-restart-service";
19
19
 
20
20
  export interface ManagementApiDeps {
21
+ /** Canonical origin captured from the running listener; management writes must not change it. */
22
+ activeServerOrigin?: string;
23
+ /** Listener settings captured at bind time so same-origin certificate/path edits still require restart. */
24
+ activeServerConfig?: Pick<OcxConfig, "hostname" | "port" | "tls">;
21
25
  /** Platform seam for capability projections; does not alter host-level startup behavior. */
22
26
  platform?: NodeJS.Platform;
23
27
  toggleCodexMultiAgentV2?: (enabled: boolean) => void;
@@ -677,6 +677,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
677
677
  // Presence only (#959 review): header names and values never leave the process.
678
678
  hasHeaders: !!p.headers && Object.keys(p.headers).length > 0,
679
679
  allowPrivateNetwork: p.allowPrivateNetwork === true,
680
+ replayTransientFailures: p.replayTransientFailures === true,
680
681
  liveModels: p.liveModels !== false,
681
682
  requestPacing: p.requestPacing,
682
683
  models: p.models ?? [],
@@ -58,7 +58,6 @@ import {
58
58
  type CodexUpstreamOutcome,
59
59
  } from "../../codex/routing";
60
60
  import {
61
- fetchWithResetRetry,
62
61
  fetchWithTransientRetry,
63
62
  applyUpstreamRecoveryInit,
64
63
  type UpstreamSendRecovery,
@@ -533,7 +532,11 @@ export async function handleResponsesCompact(
533
532
  });
534
533
  return recovery === "single"
535
534
  ? doFetch()
536
- : fetchWithTransientRetry(doFetch, { abortSignal: req.signal, label: safeHostLabel(compactUrl) });
535
+ : fetchWithTransientRetry(doFetch, {
536
+ abortSignal: req.signal,
537
+ label: safeHostLabel(compactUrl),
538
+ replayTransientFailures: sendProvider.replayTransientFailures,
539
+ });
537
540
  };
538
541
 
539
542
  // The account each outcome belongs to. Reassigned only when the alternate send below
@@ -196,7 +196,6 @@ import {
196
196
  import { codexAuthContextLogLabel } from "../../codex/account-label";
197
197
  import {
198
198
  applyUpstreamRecoveryInit,
199
- fetchWithResetRetry,
200
199
  fetchWithTransientRetry,
201
200
  prepareSameTarget429Wait,
202
201
  sleepWithAbort,
@@ -3702,6 +3701,7 @@ async function handleResponsesInner(
3702
3701
  linkAbortSignal(upstream, options.abortSignal);
3703
3702
  const connectMs = config.connectTimeoutMs ?? 200_000;
3704
3703
  let upstreamResponse: Response;
3704
+ const replayBudget = route.provider.replayTransientFailures ? { remaining: 2 } : undefined;
3705
3705
  const transportFailureResponse = (err: unknown): Response => {
3706
3706
  upstream.abort();
3707
3707
  if (options.abortSignal?.aborted) {
@@ -3768,7 +3768,7 @@ async function handleResponsesInner(
3768
3768
  return res;
3769
3769
  });
3770
3770
  },
3771
- { abortSignal: upstream.signal, label: safeHostLabel(request.url) },
3771
+ { abortSignal: upstream.signal, label: safeHostLabel(request.url), replayTransientFailures: route.provider.replayTransientFailures, replayBudget },
3772
3772
  );
3773
3773
  } catch (err) {
3774
3774
  return transportFailureResponse(err);
@@ -3843,7 +3843,7 @@ async function handleResponsesInner(
3843
3843
  return response;
3844
3844
  });
3845
3845
  },
3846
- { abortSignal: upstream.signal, label: safeHostLabel(request.url) },
3846
+ { abortSignal: upstream.signal, label: safeHostLabel(request.url), replayTransientFailures: route.provider.replayTransientFailures, replayBudget },
3847
3847
  );
3848
3848
  } catch (err) {
3849
3849
  return { failed: transportFailureResponse(err) };
@@ -3959,7 +3959,7 @@ async function handleResponsesInner(
3959
3959
  return res;
3960
3960
  });
3961
3961
  },
3962
- { abortSignal: upstream.signal, label: safeHostLabel(request.url) },
3962
+ { abortSignal: upstream.signal, label: safeHostLabel(request.url), replayTransientFailures: route.provider.replayTransientFailures, replayBudget },
3963
3963
  );
3964
3964
  } catch (err) {
3965
3965
  return transportFailureResponse(err);
@@ -4021,7 +4021,7 @@ async function handleResponsesInner(
4021
4021
  return res;
4022
4022
  });
4023
4023
  },
4024
- { abortSignal: upstream.signal, label: safeHostLabel(request.url) },
4024
+ { abortSignal: upstream.signal, label: safeHostLabel(request.url), replayTransientFailures: route.provider.replayTransientFailures, replayBudget },
4025
4025
  );
4026
4026
  } catch (err) {
4027
4027
  return transportFailureResponse(err);
@@ -5353,6 +5353,7 @@ async function handleResponsesInner(
5353
5353
  */
5354
5354
  const invalidateSameTargetRequest = (): void => { transportToken += 1; };
5355
5355
  let upstreamResponse: Response;
5356
+ const replayBudget = route.provider.replayTransientFailures ? { remaining: 2 } : undefined;
5356
5357
  try {
5357
5358
  if (activeAdapter.fetchResponse) {
5358
5359
  noteDiagnosticAttempt(logCtx.activeAttempt, inputTokenEstimate, undefined, activeAdapter.name);
@@ -5361,20 +5362,17 @@ async function handleResponsesInner(
5361
5362
  abortSignal: upstream.signal,
5362
5363
  timeoutMs: connectMs,
5363
5364
  stream: parsed.stream,
5364
- executor: providerFetch(route.provider, options.codexWsRuntimeIdentity, {
5365
+ executor: providerFetch(route.provider, options.codexWsRuntimeIdentity, {
5365
5366
  providerName: route.providerName,
5366
5367
  modelId: route.modelId,
5367
5368
  pacingSlotAcquired: true,
5368
- }),
5369
+ }),
5370
+ replayBudget,
5369
5371
  ...(antigravityAccountId ? { accountId: antigravityAccountId } : {}),
5370
5372
  });
5371
5373
  } else {
5372
- // #1851 scope guard: transient-5xx retry on this generic adapter path is opt-in for
5373
- // direct Google AI Studio only (Vertex/Antigravity use fetchResponse above). Other
5374
- // adapters keep reset-only retry so combo failover still hops on the first 5xx
5375
- // instead of burning ~1.2s of same-target retries per hop.
5376
- const fetchWithRetryPolicy = route.provider.adapter === "google" ? fetchWithTransientRetry : fetchWithResetRetry;
5377
- upstreamResponse = await fetchWithRetryPolicy(
5374
+ // One coordinator owns reset and transient-5xx replay for every generic adapter.
5375
+ upstreamResponse = await fetchWithTransientRetry(
5378
5376
  recovery => {
5379
5377
  noteDiagnosticAttempt(logCtx.activeAttempt, inputTokenEstimate, recovery, activeAdapter.name);
5380
5378
  return fetchWithHeaderTimeout(builtInitialRequest.url, applyUpstreamRecoveryInit({
@@ -5387,7 +5385,7 @@ async function handleResponsesInner(
5387
5385
  modelId: route.modelId,
5388
5386
  }));
5389
5387
  },
5390
- { abortSignal: upstream.signal, label: safeHostLabel(builtInitialRequest.url) },
5388
+ { abortSignal: upstream.signal, label: safeHostLabel(builtInitialRequest.url), replayTransientFailures: route.provider.replayTransientFailures, replayBudget },
5391
5389
  );
5392
5390
  }
5393
5391
  } catch (err) {
@@ -6055,13 +6053,12 @@ async function handleResponsesInner(
6055
6053
  modelId: nextParsed.modelId,
6056
6054
  pacingSlotAcquired: true,
6057
6055
  }),
6056
+ replayBudget,
6058
6057
  ...(antigravityAccountId ? { accountId: antigravityAccountId } : {}),
6059
6058
  });
6060
6059
  }
6061
- // Same #1851 scope guard as the initial send: transient-5xx retry only for direct
6062
- // Google AI Studio; every other adapter keeps reset-only semantics here.
6063
- const fetchContinuationWithRetryPolicy = route.provider.adapter === "google" ? fetchWithTransientRetry : fetchWithResetRetry;
6064
- return await fetchContinuationWithRetryPolicy(
6060
+ // Continuations consume the same generation-scoped transient replay allowance.
6061
+ return await fetchWithTransientRetry(
6065
6062
  recovery => {
6066
6063
  noteDiagnosticAttempt(logCtx.activeAttempt, continuationEstimate, recovery ?? replayKind, activeAdapter.name);
6067
6064
  return fetchWithHeaderTimeout(
@@ -6080,7 +6077,7 @@ async function handleResponsesInner(
6080
6077
  }),
6081
6078
  );
6082
6079
  },
6083
- { abortSignal: upstream.signal, label: safeHostLabel(builtContinuationRequest.url) },
6080
+ { abortSignal: upstream.signal, label: safeHostLabel(builtContinuationRequest.url), replayTransientFailures: route.provider.replayTransientFailures, replayBudget },
6084
6081
  );
6085
6082
  } finally {
6086
6083
  builtContinuationRequest.releaseBodyObservation?.();