@bitkyc08/opencodex 2.7.43-preview.20260728 → 2.8.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 (173) hide show
  1. package/README.md +8 -1
  2. package/bin/ocx.mjs +47 -22
  3. package/gui/dist/assets/index-BDjpkcRN.js +67 -0
  4. package/gui/dist/assets/index-BHsKRFh9.css +1 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/AGENTS.md +28 -0
  8. package/src/adapters/anthropic.ts +15 -6
  9. package/src/adapters/cursor/discovery.ts +4 -1
  10. package/src/adapters/cursor/effort-map.ts +3 -0
  11. package/src/adapters/cursor/native-exec-shell.ts +18 -6
  12. package/src/adapters/cursor/protobuf-events.ts +24 -2
  13. package/src/adapters/cursor/protobuf-request.ts +1 -2
  14. package/src/adapters/cursor/tool-definitions.ts +68 -29
  15. package/src/adapters/google-wire-compiler.ts +4 -0
  16. package/src/adapters/google.ts +128 -2
  17. package/src/adapters/identity.ts +12 -2
  18. package/src/adapters/kiro.ts +64 -7
  19. package/src/adapters/mimo-free.ts +2 -0
  20. package/src/adapters/openai-responses.ts +246 -59
  21. package/src/claude/agents-inject.ts +5 -0
  22. package/src/claude/alias.ts +94 -14
  23. package/src/claude/inbound.ts +26 -9
  24. package/src/claude/outbound.ts +6 -3
  25. package/src/cli/account-auth.ts +1 -1
  26. package/src/cli/agent-driven.ts +37 -0
  27. package/src/cli/catalog-prewarm.ts +24 -0
  28. package/src/cli/claude.ts +35 -10
  29. package/src/cli/doctor.ts +71 -19
  30. package/src/cli/help.ts +42 -6
  31. package/src/cli/index.ts +93 -19
  32. package/src/cli/interactive-confirm.ts +133 -0
  33. package/src/cli/opencode.ts +701 -0
  34. package/src/cli/provider-runtime.ts +3 -0
  35. package/src/cli/provider.ts +31 -10
  36. package/src/cli/star-prompt.ts +79 -18
  37. package/src/cli/status.ts +47 -13
  38. package/src/cli/v2.ts +10 -1
  39. package/src/codex/account-id.ts +34 -0
  40. package/src/codex/account-lifecycle.ts +4 -1
  41. package/src/codex/account-namespace-match.ts +63 -0
  42. package/src/codex/account-namespaces.ts +149 -0
  43. package/src/codex/account-pause.ts +20 -0
  44. package/src/codex/account-store.ts +2 -0
  45. package/src/codex/account-usability.ts +6 -1
  46. package/src/codex/app-server-processes.ts +511 -0
  47. package/src/codex/auth-api.ts +293 -34
  48. package/src/codex/auth-collision.ts +2 -1
  49. package/src/codex/auth-context.ts +60 -17
  50. package/src/codex/catalog/bundled.ts +9 -2
  51. package/src/codex/catalog/parsing.ts +42 -2
  52. package/src/codex/catalog/provider-fetch.ts +264 -70
  53. package/src/codex/catalog/sync.ts +45 -8
  54. package/src/codex/catalog.ts +2 -2
  55. package/src/codex/features.ts +524 -5
  56. package/src/codex/history-provider.ts +145 -1
  57. package/src/codex/inject.ts +114 -14
  58. package/src/codex/main-account.ts +2 -8
  59. package/src/codex/pool-rotation.ts +186 -0
  60. package/src/codex/quota.ts +92 -2
  61. package/src/codex/routing.ts +695 -106
  62. package/src/codex/runtime.ts +10 -1
  63. package/src/codex/shim.ts +4 -1
  64. package/src/codex/subagent-defaults.ts +550 -0
  65. package/src/codex/subagent-model-fallback.ts +2 -0
  66. package/src/codex/sync.ts +3 -0
  67. package/src/config.ts +574 -25
  68. package/src/generated/jawcode-model-metadata.ts +12 -12
  69. package/src/github/star-state.ts +191 -0
  70. package/src/images/artifacts.ts +516 -0
  71. package/src/images/fulfill-video.ts +163 -0
  72. package/src/images/fulfill.ts +111 -0
  73. package/src/images/index.ts +4 -0
  74. package/src/images/loop.ts +789 -0
  75. package/src/images/plan.ts +133 -0
  76. package/src/images/synthetic-tool.ts +133 -0
  77. package/src/images/types.ts +41 -0
  78. package/src/images/xai-client.ts +141 -0
  79. package/src/images/xai-video-client.ts +163 -0
  80. package/src/lib/admin-secrets.ts +25 -0
  81. package/src/lib/bun-binary-validator.d.mts +3 -0
  82. package/src/lib/bun-binary-validator.mjs +18 -0
  83. package/src/lib/bun-runtime.ts +6 -20
  84. package/src/lib/config-ownership.ts +327 -0
  85. package/src/lib/crash-guard.ts +2 -0
  86. package/src/lib/destination-policy.ts +132 -7
  87. package/src/lib/pinned-http.ts +151 -0
  88. package/src/lib/process-control.ts +2 -2
  89. package/src/lib/provider-outbound.ts +167 -0
  90. package/src/lib/provider-url.ts +14 -0
  91. package/src/lib/proxy-env.ts +18 -0
  92. package/src/lib/shadow-call.ts +30 -0
  93. package/src/lib/test-home-guard.ts +90 -0
  94. package/src/lib/win-exec.ts +12 -2
  95. package/src/lib/windows-elevation.ts +81 -3
  96. package/src/lib/windows-secret-acl.ts +189 -12
  97. package/src/lib/winsw.ts +2 -0
  98. package/src/oauth/anthropic-routing.ts +570 -0
  99. package/src/oauth/health.ts +6 -0
  100. package/src/oauth/index.ts +310 -75
  101. package/src/oauth/key-providers.ts +38 -8
  102. package/src/oauth/kimi.ts +2 -0
  103. package/src/oauth/kiro-credentials.ts +373 -12
  104. package/src/oauth/kiro.ts +424 -43
  105. package/src/oauth/login-cli.ts +33 -6
  106. package/src/oauth/store.ts +56 -4
  107. package/src/oauth/types.ts +11 -0
  108. package/src/providers/alibaba-region-migration.ts +16 -3
  109. package/src/providers/antigravity-models.ts +3 -0
  110. package/src/providers/api-keys.ts +13 -6
  111. package/src/providers/derive.ts +8 -2
  112. package/src/providers/key-failover.ts +24 -4
  113. package/src/providers/model-discovery.ts +356 -0
  114. package/src/providers/quota.ts +233 -29
  115. package/src/providers/registry.ts +125 -3
  116. package/src/responses/parser.ts +11 -0
  117. package/src/responses/state.ts +22 -8
  118. package/src/responses/tool-groups.ts +19 -0
  119. package/src/router.ts +19 -7
  120. package/src/server/auth-cors.ts +114 -24
  121. package/src/server/claude-messages.ts +8 -1
  122. package/src/server/gui-static.ts +30 -6
  123. package/src/server/images.ts +303 -9
  124. package/src/server/index.ts +77 -9
  125. package/src/server/lifecycle.ts +25 -1
  126. package/src/server/live.ts +75 -25
  127. package/src/server/management/agent-settings-routes.ts +106 -8
  128. package/src/server/management/combo-routes.ts +7 -0
  129. package/src/server/management/config-routes.ts +22 -7
  130. package/src/server/management/context.ts +11 -1
  131. package/src/server/management/logs-usage-routes.ts +167 -3
  132. package/src/server/management/model-routes.ts +46 -13
  133. package/src/server/management/oauth-account-routes.ts +163 -17
  134. package/src/server/management/provider-routes.ts +73 -10
  135. package/src/server/management/shared.ts +2 -2
  136. package/src/server/management/sidebar-routes.ts +39 -0
  137. package/src/server/management/system-restart.ts +172 -0
  138. package/src/server/management/system-routes.ts +33 -10
  139. package/src/server/management-api.ts +5 -3
  140. package/src/server/management-auth.ts +216 -0
  141. package/src/server/proxy-liveness.ts +14 -3
  142. package/src/server/responses/compact.ts +21 -13
  143. package/src/server/responses/core.ts +614 -172
  144. package/src/server/responses/upstream-error.ts +48 -0
  145. package/src/server/responses-image-gen-repair.ts +118 -0
  146. package/src/server/responses-item-id-repair.ts +10 -85
  147. package/src/server/sse-payload-rewrite.ts +116 -0
  148. package/src/server/startup-action-control.ts +30 -14
  149. package/src/server/system-env.ts +28 -10
  150. package/src/service.ts +284 -19
  151. package/src/storage/cleanup-job.ts +57 -0
  152. package/src/storage/cleanup.ts +1504 -28
  153. package/src/storage/policy-job.ts +387 -0
  154. package/src/storage/policy-scheduler.ts +40 -0
  155. package/src/storage/policy-worker.ts +53 -0
  156. package/src/storage/policy.ts +522 -0
  157. package/src/storage/restore-job.ts +253 -0
  158. package/src/storage/restore-worker.ts +52 -0
  159. package/src/storage/storage-mutation-coordinator.ts +109 -0
  160. package/src/storage/worker-lifecycle.ts +81 -0
  161. package/src/tray/windows.ts +34 -4
  162. package/src/types.ts +107 -1
  163. package/src/update/badge.ts +72 -0
  164. package/src/update/index.ts +36 -18
  165. package/src/update/job.ts +111 -16
  166. package/src/update/npm-invocation.d.mts +23 -0
  167. package/src/update/npm-invocation.mjs +94 -0
  168. package/src/usage/debug.ts +2 -0
  169. package/src/usage/expected-prices.ts +6 -5
  170. package/src/usage/log.ts +12 -0
  171. package/src/web-search/loop.ts +57 -16
  172. package/gui/dist/assets/index-CjKFJHSC.js +0 -65
  173. package/gui/dist/assets/index-DfVGuN88.css +0 -1
@@ -20,10 +20,14 @@ import {
20
20
  import { reconcileOAuthProviders } from "../oauth";
21
21
  import { invalidateCodexModelsCache } from "../codex/catalog";
22
22
  import { startMemoryWatchdog } from "./memory-watchdog";
23
+ import { setStorageCleanupPolicyLiveSink } from "../storage/policy";
24
+ import { setStorageCleanupPolicyJobLiveApply } from "../storage/policy-job";
25
+ import { scheduleStorageCleanupStartupRun, startStorageCleanupScheduler } from "../storage/policy-scheduler";
23
26
  import { runOpenAiTierStartupMigration } from "../providers/openai-tier-startup";
24
27
  import { runAlibabaRegionStartupMigration } from "../providers/alibaba-region-startup";
25
28
  import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers";
26
29
  import { providerCodexAccountMode } from "../providers/registry";
30
+ import type { StorageCleanupPolicy } from "../types";
27
31
  import {
28
32
  CodexAccountCooldownError,
29
33
  cooldownErrorMessage,
@@ -52,6 +56,8 @@ export {
52
56
  drainAndShutdown,
53
57
  getActiveTurnCount,
54
58
  isDraining,
59
+ isRecyclingForExit,
60
+ markRecyclingForExit,
55
61
  registerTurn,
56
62
  trackStreamLifetime,
57
63
  unregisterTurn,
@@ -98,8 +104,10 @@ export {
98
104
  import {
99
105
  assertServerAuthConfig,
100
106
  corsHeaders,
107
+ managementCorsHeaders,
101
108
  hasValidApiAuth,
102
109
  isAllowedRequestOrigin,
110
+ isAllowedManagementOrigin,
103
111
  isApiAuthRequired,
104
112
  isLoopbackHostname,
105
113
  jsonResponse,
@@ -108,6 +116,7 @@ import {
108
116
  safeConfigDTO,
109
117
  setCorsOrigin,
110
118
  withCors,
119
+ withManagementCors,
111
120
  } from "./auth-cors";
112
121
  export {
113
122
  assertServerAuthConfig,
@@ -129,6 +138,7 @@ import { handleImages } from "./images";
129
138
  import { handleLive, logLiveSidebandFrame, parseLiveSidebandTarget, resolveLiveSidebandUpgrade } from "./live";
130
139
  import { handleSearch } from "./search";
131
140
  import { fetchAllModels, handleManagementAPI, VERSION } from "./management-api";
141
+ import { initializeManagementAuthState, issueGuiSession, requireManagementAuth } from "./management-auth";
132
142
 
133
143
  const MAX_WS_FRAME_BYTES = 50 * 1024 * 1024;
134
144
  const WEBSOCKET_IDLE_TIMEOUT_SECONDS = 0;
@@ -220,14 +230,16 @@ function attachLiveSidebandUpstream(ws: ServerWebSocket<WsData>): void {
220
230
  // Source invariant for tests/passthrough-abort.test.ts after the pure module split:
221
231
  // if (isEventStream && upstreamResponse.body) {
222
232
  // const repairConfig = route.provider.responsesItemIdRepair;
223
- // #314 gated shape (win32-no-repair only; default OFF on the bundled known-bad runtime):
233
+ // const needsClientRewrite = imageGenCallAliases.size > 0
234
+ // #314 gated shape (win32-no-client-rewrite only; default OFF on the bundled known-bad runtime):
224
235
  // decideEagerRelay(config.streamMode ?? "auto")
225
236
  // relaySseEagerBounded(upstreamResponse.body, turnAc,
237
+ // new Response(eagerBody,
226
238
  // Default shape (tee + background inspection):
227
239
  // upstreamResponse.body.tee()
228
240
  // const repairedBody = hasResponsesItemIdRepair(repairConfig)
229
241
  // process.platform === "win32"
230
- // && !hasResponsesItemIdRepair(repairConfig)
242
+ // && !needsClientRewrite
231
243
  // ? nativeBody
232
244
  // relaySseWithFailedTail(repairedBody, upstream)
233
245
  // new Response(clientBody
@@ -242,6 +254,7 @@ export function startServer(port?: number) {
242
254
  const config = runAlibabaRegionStartupMigration(runOpenAiTierStartupMigration(loadConfig()));
243
255
  applyProxyEnv(config);
244
256
  assertServerAuthConfig(config);
257
+ const managementAuth = initializeManagementAuthState(config);
245
258
  // Refresh OAuth provider presets (models/noReasoningModels) from the registry so a proxy update
246
259
  // adding/dropping models reaches existing configs on start — not just fresh installs.
247
260
  reconcileOAuthProviders(config);
@@ -288,6 +301,16 @@ export function startServer(port?: number) {
288
301
  // #314: warn-only RSS observability (unref'd, idempotent — safe under repeated
289
302
  // startServer(0) in tests). Snapshot surfaces via GET /api/system/memory.
290
303
  startMemoryWatchdog();
304
+ // Issue #42 Phase 3: opt-in archived auto-cleanup (default OFF). Unref'd hourly
305
+ // tick for daily/weekly; startup evaluation is fire-and-forget after listen.
306
+ // Heavy work runs in a Worker via the single-flight job controller.
307
+ // Keep live config.policy in sync when background runs advance nextRun/lastRun.
308
+ const applyPolicy = (policy: StorageCleanupPolicy) => {
309
+ config.storageCleanupPolicy = policy;
310
+ };
311
+ setStorageCleanupPolicyLiveSink(applyPolicy);
312
+ setStorageCleanupPolicyJobLiveApply(applyPolicy);
313
+ startStorageCleanupScheduler();
291
314
 
292
315
  const listenPort = port ?? config.port ?? 10100;
293
316
  setCorsOrigin(listenPort);
@@ -296,7 +319,8 @@ export function startServer(port?: number) {
296
319
  // resolves localhost→127.0.0.1): on Windows `localhost` resolves ::1-first, but the injected URL
297
320
  // is 127.0.0.1, so binding literal "localhost" would reintroduce the F4 refusal. Wildcards
298
321
  // (0.0.0.0/::) and specific hosts are left untouched so intentional exposure is preserved.
299
- const bindHost = /^localhost$/i.test(config.hostname ?? "") ? "127.0.0.1" : (config.hostname ?? "127.0.0.1");
322
+ const configuredHost = config.hostname?.trim();
323
+ const bindHost = !configuredHost || /^localhost$/i.test(configuredHost) ? "127.0.0.1" : configuredHost;
300
324
 
301
325
  // Codex treats empty / non-JSON 503 bodies as "Unknown error" (#452). Keep Retry-After and
302
326
  // the server_is_overloaded code so clients can back off, but always return a JSON envelope.
@@ -319,10 +343,17 @@ export function startServer(port?: number) {
319
343
  markActivity(`${req.method} ${url.pathname}`);
320
344
 
321
345
  if (req.method === "OPTIONS") {
322
- if (!isAllowedRequestOrigin(req, config)) {
346
+ const managementPreflight = url.pathname.startsWith("/api/");
347
+ const allowed = managementPreflight
348
+ ? isAllowedManagementOrigin(req, config)
349
+ : isAllowedRequestOrigin(req, config);
350
+ if (!allowed) {
323
351
  return new Response(null, { status: 403, headers: corsHeaders() });
324
352
  }
325
- return new Response(null, { status: 204, headers: corsHeaders(req, config) });
353
+ return new Response(null, {
354
+ status: 204,
355
+ headers: managementPreflight ? managementCorsHeaders(req, config) : corsHeaders(req, config),
356
+ });
326
357
  }
327
358
 
328
359
  // Responses WebSocket (phase 120.2). Codex upgrades the same /v1/responses path; auth is
@@ -358,10 +389,11 @@ export function startServer(port?: number) {
358
389
  }
359
390
 
360
391
  if (url.pathname.startsWith("/api/")) {
361
- const apiAuthError = requireApiAuth(req, config, "management");
362
- if (apiAuthError) return withCors(apiAuthError, req, config);
392
+ const apiAuthError = requireManagementAuth(req, managementAuth, config);
393
+ if (apiAuthError) return withManagementCors(apiAuthError, req, config);
363
394
  const mgmtResponse = await handleManagementAPI(req, url, config);
364
- if (mgmtResponse) return withCors(mgmtResponse, req, config);
395
+ if (mgmtResponse) return withManagementCors(mgmtResponse, req, config);
396
+ return withManagementCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, config);
365
397
  }
366
398
 
367
399
  if (url.pathname === "/v1/models" && req.method === "GET") {
@@ -484,6 +516,36 @@ export function startServer(port?: number) {
484
516
  return withCors(response, req, config);
485
517
  }
486
518
 
519
+ if (req.method === "GET" && url.pathname.startsWith("/v1/opencodex/artifacts/")) {
520
+ const apiAuthError = requireApiAuth(req, config, "data-plane");
521
+ if (apiAuthError) return withCors(apiAuthError, req, config);
522
+ if (!isAllowedRequestOrigin(req, config)) {
523
+ return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, config);
524
+ }
525
+ const id = decodeURIComponent(url.pathname.slice("/v1/opencodex/artifacts/".length));
526
+ const { resolveArtifactPath } = await import("../images/artifacts");
527
+ const artifactPath = resolveArtifactPath(id);
528
+ if (!artifactPath) {
529
+ return withCors(formatErrorResponse(404, "not_found", "artifact not found"), req, config);
530
+ }
531
+ const file = Bun.file(artifactPath);
532
+ const ext = artifactPath.split(".").pop()?.toLowerCase();
533
+ const contentType =
534
+ ext === "png" ? "image/png"
535
+ : ext === "jpg" || ext === "jpeg" ? "image/jpeg"
536
+ : ext === "webp" ? "image/webp"
537
+ : ext === "gif" ? "image/gif"
538
+ : "application/octet-stream";
539
+ return withCors(new Response(file, {
540
+ status: 200,
541
+ headers: {
542
+ "content-type": contentType,
543
+ "cache-control": "private, max-age=3600",
544
+ "x-content-type-options": "nosniff",
545
+ },
546
+ }), req, config);
547
+ }
548
+
487
549
  if (url.pathname === "/v1/alpha/search" && req.method === "POST") {
488
550
  disableResponsesRequestTimeout(req, requestServer);
489
551
  if (isDraining()) {
@@ -675,7 +737,10 @@ export function startServer(port?: number) {
675
737
  return withCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, config);
676
738
  }
677
739
 
678
- const guiFile = serveGuiFile(url.pathname);
740
+ const guiSessionCandidate = req.method === "GET" && (url.pathname === "/" || !url.pathname.includes("."))
741
+ ? issueGuiSession(req, config, managementAuth)
742
+ : null;
743
+ const guiFile = serveGuiFile(url.pathname, undefined, guiSessionCandidate ?? undefined);
679
744
  if (guiFile) return guiFile;
680
745
  if (url.pathname === "/" && req.method === "GET") {
681
746
  return jsonResponse(rootFallbackPayload());
@@ -876,5 +941,8 @@ export function startServer(port?: number) {
876
941
  .catch(() => {});
877
942
  }
878
943
 
944
+ // Opt-in storage policy (default OFF). Never blocks listen; cancellable on shutdown.
945
+ scheduleStorageCleanupStartupRun();
946
+
879
947
  return server;
880
948
  }
@@ -1,4 +1,10 @@
1
1
  import { flushResponseState } from "../responses/state";
2
+ import { setStorageCleanupPolicyLiveSink } from "../storage/policy";
3
+ import {
4
+ abortStorageCleanupPolicyJob,
5
+ setStorageCleanupPolicyJobLiveApply,
6
+ } from "../storage/policy-job";
7
+ import { stopStorageCleanupScheduler } from "../storage/policy-scheduler";
2
8
 
3
9
  // ---------------------------------------------------------------------------
4
10
  // Active turn tracking + graceful shutdown drain
@@ -6,6 +12,7 @@ import { flushResponseState } from "../responses/state";
6
12
 
7
13
  const activeTurns = new Set<AbortController>();
8
14
  let draining = false;
15
+ let recyclingForExit = false;
9
16
  let _serverRef: ReturnType<typeof Bun.serve> | undefined;
10
17
 
11
18
  export function setServerRef(server: ReturnType<typeof Bun.serve> | undefined): void { _serverRef = server; }
@@ -14,6 +21,18 @@ export function registerTurn(ac: AbortController): void { activeTurns.add(ac); }
14
21
  export function unregisterTurn(ac: AbortController): void { activeTurns.delete(ac); }
15
22
  export function isDraining(): boolean { return draining; }
16
23
  export function getActiveTurnCount(): number { return activeTurns.size; }
24
+ /** Live listen port of the Bun server, when started. */
25
+ export function getServerListenPort(): number | undefined {
26
+ const port = _serverRef?.port;
27
+ return typeof port === "number" && port > 0 ? port : undefined;
28
+ }
29
+ /**
30
+ * Mark this process as a recycle (dashboard drain-and-restart). Exit cleanup
31
+ * must keep Codex/Grok/system-env injection so the replacement process inherits
32
+ * a working fence — unlike an intentional `ocx stop` teardown.
33
+ */
34
+ export function markRecyclingForExit(): void { recyclingForExit = true; }
35
+ export function isRecyclingForExit(): boolean { return recyclingForExit; }
17
36
 
18
37
  export function trackStreamLifetime(
19
38
  body: ReadableStream<Uint8Array>,
@@ -67,7 +86,12 @@ export async function drainAndShutdown(
67
86
  }
68
87
  // Debounced replay-state snapshot may still be pending; flush so the last completed turn's
69
88
  // previous_response_id chain survives the restart this shutdown is usually part of.
70
- flushResponseState();
89
+ await flushResponseState();
90
+ // Tear down opt-in storage policy timers / worker / live-config sink so they cannot fire after stop.
91
+ stopStorageCleanupScheduler();
92
+ abortStorageCleanupPolicyJob();
93
+ setStorageCleanupPolicyLiveSink(null);
94
+ setStorageCleanupPolicyJobLiveApply(null);
71
95
  s?.stop(true);
72
96
  draining = false;
73
97
  }
@@ -197,42 +197,92 @@ export function parseLiveSidebandTarget(pathname: string, searchParams: URLSearc
197
197
  return null;
198
198
  }
199
199
 
200
+ /**
201
+ * True for the loopback hosts plaintext development servers listen on.
202
+ * `URL.hostname` keeps the brackets on IPv6, so both forms are accepted.
203
+ */
204
+ function isLoopbackHost(hostname: string): boolean {
205
+ const lower = hostname.toLowerCase();
206
+ return lower === "localhost" || lower.endsWith(".localhost")
207
+ || lower === "127.0.0.1" || lower.startsWith("127.")
208
+ || lower === "::1" || lower === "[::1]";
209
+ }
210
+
211
+ /**
212
+ * Normalize the sideband base to end in exactly `/v1`, with no query, fragment,
213
+ * or userinfo. Any failure closes to the canonical Realtime API root — never to
214
+ * the input — because this string decides where upstream bearer credentials and
215
+ * user audio are sent.
216
+ *
217
+ * Bounds, all fail-closed:
218
+ * - scheme must be https/wss, or http/ws with a loopback host (the local
219
+ * development case this knob exists for);
220
+ * - URL userinfo is rejected (URL#toString would forward it verbatim);
221
+ * - unparseable input is rejected.
222
+ *
223
+ * Endpoint-form overrides are recognized the way upstream recognizes them
224
+ * (codex-rs realtime_websocket/methods.rs:994): a terminal `/realtime`,
225
+ * `/realtime/calls/<id>`, or `/live/<id>` is stripped so the root can be
226
+ * re-derived. A path prefix survives (`https://host/api/v1` keeps `/api`).
227
+ */
228
+ function normalizeSidebandRoot(baseUrl: string): string {
229
+ let parsed: URL;
230
+ try {
231
+ parsed = new URL(baseUrl);
232
+ } catch {
233
+ return LIVE_SIDEBAND_API_ROOT;
234
+ }
235
+ const secure = parsed.protocol === "https:" || parsed.protocol === "wss:";
236
+ const plaintext = parsed.protocol === "http:" || parsed.protocol === "ws:";
237
+ if ((!secure && !plaintext) || (plaintext && !isLoopbackHost(parsed.hostname)) || parsed.username || parsed.password) {
238
+ return LIVE_SIDEBAND_API_ROOT;
239
+ }
240
+ parsed.search = "";
241
+ parsed.hash = "";
242
+ const path = parsed.pathname
243
+ .replace(/\/+$/, "")
244
+ .replace(/\/realtime(?:\/calls\/[^/]+)?$/, "")
245
+ .replace(/\/live\/[^/]+$/, "")
246
+ .replace(/\/v1$/, "");
247
+ parsed.pathname = `${path}/v1`;
248
+ return parsed.toString().replace(/\/$/, "");
249
+ }
250
+
251
+ /**
252
+ * Resolve the sideband base. Upstream policy (codex-rs 438c9e98d): the sideband
253
+ * join is NOT derived from the selected model provider — precedence is exactly
254
+ * the explicit override when configured, otherwise the canonical Realtime API
255
+ * root. The provider base URL deliberately plays no part; a user who needs a
256
+ * non-canonical host sets the override, the same escape hatch upstream ships as
257
+ * `experimental_realtime_ws_base_url`.
258
+ */
259
+ function sidebandBaseRoot(overrideBaseUrl?: string): string {
260
+ return normalizeSidebandRoot(overrideBaseUrl?.trim() || LIVE_SIDEBAND_API_ROOT);
261
+ }
262
+
200
263
  /**
201
264
  * Build the upstream sideband WebSocket URL for a resolved OpenAI/ChatGPT provider.
202
265
  * Mirrors openai/codex `websocket_url_from_api_url_for_call` + `normalize_realtime_path`.
266
+ *
267
+ * Deliberate deviation: the realtime-query style keeps `intent=quicksilver`,
268
+ * which upstream does not send. That URL is live against real OpenAI
269
+ * infrastructure for every canonical voice user and this parameter is known to
270
+ * work; dropping it is future work gated on a live smoke test. Parity here is
271
+ * scoped to the host, override precedence, and provider-query exclusion.
203
272
  */
204
273
  export function buildLiveSidebandUpstreamWsUrl(
205
- providerBaseUrl: string,
206
- usesBackendShape: boolean,
207
274
  target: LiveSidebandTarget,
275
+ overrideBaseUrl?: string,
208
276
  ): string {
209
- const root = providerBaseUrl.replace(/\/$/, "");
210
- if (usesBackendShape) {
211
- // ChatGPT backend-api call-create, but the sideband join lives on the public API host
212
- // (matches openai/codex, which builds the sideband from the ApiKey provider default).
213
- if (target.style === "frameless-path") {
214
- return httpsToWss(`${LIVE_SIDEBAND_API_ROOT}/live/${target.callId}`);
215
- }
216
- if (target.style === "realtime-calls-path") {
217
- return httpsToWss(`${LIVE_SIDEBAND_API_ROOT}/realtime/calls/${target.callId}`);
218
- }
219
- return httpsToWss(
220
- `${LIVE_SIDEBAND_API_ROOT}/realtime?intent=quicksilver&call_id=${encodeURIComponent(target.callId)}`,
221
- );
222
- }
277
+ const sidebandRoot = sidebandBaseRoot(overrideBaseUrl);
223
278
  if (target.style === "frameless-path") {
224
- // Frameless: normalize to .../live then append /{callId}.
225
- const apiRoot = root.replace(/\/v1\/?$/, "");
226
- return httpsToWss(`${apiRoot}/v1/live/${target.callId}`);
279
+ return httpsToWss(`${sidebandRoot}/live/${target.callId}`);
227
280
  }
228
281
  if (target.style === "realtime-calls-path") {
229
- const apiRoot = root.replace(/\/v1\/?$/, "");
230
- return httpsToWss(`${apiRoot}/v1/realtime/calls/${target.callId}`);
282
+ return httpsToWss(`${sidebandRoot}/realtime/calls/${target.callId}`);
231
283
  }
232
- // Realtime v1/v2: /v1/realtime?intent=quicksilver&call_id=
233
- const apiRoot = root.replace(/\/v1\/?$/, "");
234
284
  return httpsToWss(
235
- `${apiRoot}/v1/realtime?intent=quicksilver&call_id=${encodeURIComponent(target.callId)}`,
285
+ `${sidebandRoot}/realtime?intent=quicksilver&call_id=${encodeURIComponent(target.callId)}`,
236
286
  );
237
287
  }
238
288
 
@@ -542,7 +592,7 @@ export async function resolveLiveSidebandUpgrade(
542
592
  if (relay instanceof Response) return relay;
543
593
  return {
544
594
  headers: relay.headers,
545
- upstreamWsUrl: buildLiveSidebandUpstreamWsUrl(relay.providerBaseUrl, relay.usesBackendShape, target),
595
+ upstreamWsUrl: buildLiveSidebandUpstreamWsUrl(target, config.experimentalRealtimeWsBaseUrl),
546
596
  recordOutcome: relay.recordOutcome,
547
597
  };
548
598
  }
@@ -11,6 +11,7 @@ import {
11
11
  providerBaseUrlConfigError,
12
12
  providerHeadersConfigError,
13
13
  saveConfigPreservingClaudeCode,
14
+ subagentDefaultSyncEffective,
14
15
  } from "../../config";
15
16
  import {
16
17
  clearLoginState,
@@ -103,22 +104,43 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
103
104
  // itself never writes config — this endpoint is the only server-side mutation
104
105
  // surface for the flag.
105
106
  if (url.pathname === "/api/v2" && req.method === "GET") {
106
- const { isMultiAgentV2Enabled, hasAgentsMaxThreads, getLogicalMaxThreads } = await import("../../codex/features");
107
+ const {
108
+ isMultiAgentV2Enabled, hasAgentsMaxThreads, getLogicalMaxThreads,
109
+ getAgentsEnabled, getAgentsMaxDepth, getSubagentDeveloperInstructions,
110
+ } = await import("../../codex/features");
107
111
  const enabled = isMultiAgentV2Enabled();
108
112
  return jsonResponse({
109
113
  enabled,
110
114
  agentsMaxThreadsConflict: enabled && hasAgentsMaxThreads(),
111
115
  maxConcurrentThreadsPerSession: getLogicalMaxThreads(),
112
116
  multiAgentMode: config.multiAgentMode ?? "default",
117
+ agentsEnabled: getAgentsEnabled(),
118
+ agentsMaxDepth: getAgentsMaxDepth(),
119
+ subagentDeveloperInstructions: getSubagentDeveloperInstructions(),
120
+ // max_depth is V1-only upstream; this is the global-flag statement, derived
121
+ // server-side so no client can present it as an effective V2 limit.
122
+ agentsMaxDepthAppliesWhenV2Disabled: !enabled,
113
123
  });
114
124
  }
115
125
  if (url.pathname === "/api/v2" && req.method === "PUT") {
116
- let body: { enabled?: unknown; maxConcurrentThreadsPerSession?: unknown; multiAgentMode?: unknown };
126
+ let body: {
127
+ enabled?: unknown;
128
+ maxConcurrentThreadsPerSession?: unknown;
129
+ multiAgentMode?: unknown;
130
+ agentsEnabled?: unknown;
131
+ agentsMaxDepth?: unknown;
132
+ subagentDeveloperInstructions?: unknown;
133
+ };
117
134
  try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
118
135
  const wantsFlag = body.enabled !== undefined;
119
136
  const wantsThreads = body.maxConcurrentThreadsPerSession !== undefined;
120
137
  const wantsMode = body.multiAgentMode !== undefined;
121
- if (!wantsFlag && !wantsThreads && !wantsMode) return jsonResponse({ error: "body must set enabled, multiAgentMode, and/or maxConcurrentThreadsPerSession" }, 400);
138
+ const wantsAgentsEnabled = body.agentsEnabled !== undefined;
139
+ const wantsMaxDepth = body.agentsMaxDepth !== undefined;
140
+ const wantsSubagentInstructions = body.subagentDeveloperInstructions !== undefined;
141
+ if (!wantsFlag && !wantsThreads && !wantsMode && !wantsAgentsEnabled && !wantsMaxDepth && !wantsSubagentInstructions) {
142
+ return jsonResponse({ error: "body must set enabled, multiAgentMode, maxConcurrentThreadsPerSession, agentsEnabled, agentsMaxDepth, and/or subagentDeveloperInstructions" }, 400);
143
+ }
122
144
  if (wantsFlag && typeof body.enabled !== "boolean") return jsonResponse({ error: "body.enabled must be a boolean" }, 400);
123
145
  if (wantsMode && body.multiAgentMode !== "v1" && body.multiAgentMode !== "default" && body.multiAgentMode !== "v2") {
124
146
  return jsonResponse({ error: "body.multiAgentMode must be 'v1', 'default', or 'v2'" }, 400);
@@ -126,12 +148,31 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
126
148
  if (wantsThreads && (typeof body.maxConcurrentThreadsPerSession !== "number" || !Number.isInteger(body.maxConcurrentThreadsPerSession) || body.maxConcurrentThreadsPerSession < 1)) {
127
149
  return jsonResponse({ error: "body.maxConcurrentThreadsPerSession must be an integer >= 1" }, 400);
128
150
  }
151
+ // Validate every new field BEFORE any write, so each 400 leaves config untouched.
152
+ // null unsets the key; "" is a meaningful value for instructions and must not be
153
+ // collapsed by a falsy check. The i32 preflight mirrors the upstream Option<i32>
154
+ // contract — out-of-range would otherwise surface as a mid-sequence write failure.
155
+ if (wantsAgentsEnabled && body.agentsEnabled !== null && typeof body.agentsEnabled !== "boolean") {
156
+ return jsonResponse({ error: "body.agentsEnabled must be a boolean or null" }, 400);
157
+ }
158
+ if (wantsMaxDepth && body.agentsMaxDepth !== null
159
+ && (typeof body.agentsMaxDepth !== "number" || !Number.isInteger(body.agentsMaxDepth)
160
+ || body.agentsMaxDepth < -2_147_483_648 || body.agentsMaxDepth > 2_147_483_647)) {
161
+ return jsonResponse({ error: "body.agentsMaxDepth must be an integer within signed i32 range, or null" }, 400);
162
+ }
163
+ if (wantsSubagentInstructions && body.subagentDeveloperInstructions !== null && typeof body.subagentDeveloperInstructions !== "string") {
164
+ return jsonResponse({ error: "body.subagentDeveloperInstructions must be a string or null" }, 400);
165
+ }
129
166
  const mode = wantsMode ? body.multiAgentMode as "v1" | "default" | "v2" : undefined;
130
167
  const modeFlag = mode === "v2" ? true : mode === "v1" ? false : undefined;
131
168
  if (wantsFlag && modeFlag !== undefined && body.enabled !== modeFlag) {
132
169
  return jsonResponse({ error: `body.enabled conflicts with multiAgentMode '${mode}'` }, 400);
133
170
  }
134
- const { isMultiAgentV2Enabled, hasAgentsMaxThreads, getLogicalMaxThreads, transitionMultiAgentV2 } = await import("../../codex/features");
171
+ const {
172
+ isMultiAgentV2Enabled, hasAgentsMaxThreads, getLogicalMaxThreads, transitionMultiAgentV2,
173
+ getAgentsEnabled, getAgentsMaxDepth, getSubagentDeveloperInstructions,
174
+ setAgentsEnabled, setAgentsMaxDepth, setSubagentDeveloperInstructions,
175
+ } = await import("../../codex/features");
135
176
  const warnings: string[] = [];
136
177
  const requestedFlag = wantsFlag ? body.enabled as boolean : modeFlag;
137
178
  if (requestedFlag !== undefined || wantsThreads) {
@@ -158,6 +199,36 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
158
199
  saveConfigPreservingClaudeCode(config);
159
200
  warnings.push(`Multi-agent mode set to '${mode}'. Applies to new sessions.`);
160
201
  }
202
+ // New-key scalar writes: each writer is individually atomic, so apply them in
203
+ // sequence after the transition. A failure here is a persistence failure (the
204
+ // writers' ok:false result or a throw from the underlying atomic write helper),
205
+ // reported as 502 naming the failed key plus the writes that already landed.
206
+ // NOTE: do not name that helper literally here — tests/grok-writer-boundary.test.ts
207
+ // asserts this route file contains no direct write primitive, and matches on the
208
+ // symbol name even inside a comment.
209
+ const scalarWrites: Array<{ field: string; run: () => { ok: true; changed: boolean } | { ok: false; error: string } }> = [];
210
+ if (wantsAgentsEnabled) scalarWrites.push({ field: "agentsEnabled", run: () => setAgentsEnabled(body.agentsEnabled as boolean | null) });
211
+ if (wantsMaxDepth) scalarWrites.push({ field: "agentsMaxDepth", run: () => setAgentsMaxDepth(body.agentsMaxDepth as number | null) });
212
+ if (wantsSubagentInstructions) scalarWrites.push({ field: "subagentDeveloperInstructions", run: () => setSubagentDeveloperInstructions(body.subagentDeveloperInstructions as string | null) });
213
+ const landed: string[] = [];
214
+ for (const write of scalarWrites) {
215
+ try {
216
+ const result = write.run();
217
+ if (!result.ok) {
218
+ return jsonResponse({ error: `writing ${write.field} failed: ${result.error}${landed.length > 0 ? ` (already applied: ${landed.join(", ")})` : ""}` }, 502);
219
+ }
220
+ landed.push(write.field);
221
+ } catch (err) {
222
+ const message = err instanceof Error ? err.message : String(err);
223
+ return jsonResponse({ error: `writing ${write.field} failed: ${message}${landed.length > 0 ? ` (already applied: ${landed.join(", ")})` : ""}` }, 502);
224
+ }
225
+ }
226
+ // Derived from fresh post-write readers (readConfigText is uncached): upstream
227
+ // lets an enabled multi_agent_v2 feature override [agents].enabled = false, so
228
+ // warn rather than reject — silently accepting would imply multi-agent is off.
229
+ if (getAgentsEnabled() === false && isMultiAgentV2Enabled()) {
230
+ warnings.push("agents.enabled = false has no effect while features.multi_agent_v2 is enabled; upstream keeps V2 active.");
231
+ }
161
232
  await refreshCodexCatalogBestEffort();
162
233
  if (requestedFlag !== undefined) warnings.push("Applies to new sessions; restart the Codex app or wait out its picker cache to see the ladder change.");
163
234
  const enabled = isMultiAgentV2Enabled();
@@ -167,6 +238,10 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
167
238
  agentsMaxThreadsConflict: enabled && hasAgentsMaxThreads(),
168
239
  maxConcurrentThreadsPerSession: getLogicalMaxThreads(),
169
240
  multiAgentMode: config.multiAgentMode ?? "default",
241
+ agentsEnabled: getAgentsEnabled(),
242
+ agentsMaxDepth: getAgentsMaxDepth(),
243
+ subagentDeveloperInstructions: getSubagentDeveloperInstructions(),
244
+ agentsMaxDepthAppliesWhenV2Disabled: !enabled,
170
245
  warnings,
171
246
  });
172
247
  }
@@ -190,6 +265,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
190
265
  )));
191
266
  return jsonResponse({
192
267
  multiAgentGuidanceEnabled: multiAgentGuidanceEnabled(config),
268
+ syncCodexSubagentDefaults: subagentDefaultSyncEffective(config),
193
269
  model: config.injectionModel ?? null,
194
270
  effort: config.injectionEffort ?? null,
195
271
  prompt: config.injectionPrompt ?? null,
@@ -207,6 +283,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
207
283
  }
208
284
  const body = parsedBody as {
209
285
  multiAgentGuidanceEnabled?: unknown;
286
+ syncCodexSubagentDefaults?: unknown;
210
287
  model?: unknown;
211
288
  effort?: unknown;
212
289
  prompt?: unknown;
@@ -214,6 +291,9 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
214
291
  const { isCodexReasoningEffort } = await import("../../reasoning-effort");
215
292
 
216
293
  let nextEnabled = config.multiAgentGuidanceEnabled;
294
+ // Start from the effective state reported by GET. A stale hand-edited
295
+ // `true` without a model must not spring back on during a model-only PUT.
296
+ let nextSyncCodexSubagentDefaults = subagentDefaultSyncEffective(config);
217
297
  let nextModel = config.injectionModel;
218
298
  let nextEffort = config.injectionEffort;
219
299
  let nextPrompt = config.injectionPrompt;
@@ -224,10 +304,16 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
224
304
  }
225
305
  nextEnabled = body.multiAgentGuidanceEnabled;
226
306
  }
307
+ if ("syncCodexSubagentDefaults" in body) {
308
+ if (typeof body.syncCodexSubagentDefaults !== "boolean") {
309
+ return jsonResponse({ error: "syncCodexSubagentDefaults must be a boolean" }, 400);
310
+ }
311
+ nextSyncCodexSubagentDefaults = body.syncCodexSubagentDefaults;
312
+ }
227
313
  if ("model" in body) {
228
314
  if (body.model === null || body.model === "") nextModel = undefined;
229
- else if (typeof body.model === "string" && body.model.length > 0) nextModel = body.model;
230
- else return jsonResponse({ error: "model must be a non-empty string or null" }, 400);
315
+ else if (typeof body.model === "string" && body.model.trim().length > 0) nextModel = body.model;
316
+ else return jsonResponse({ error: "model must be a nonblank string or null" }, 400);
231
317
  }
232
318
  if ("effort" in body) {
233
319
  if (body.effort === null || body.effort === "") nextEffort = undefined;
@@ -242,10 +328,21 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
242
328
  else if (body.prompt === null || body.prompt === "") nextPrompt = undefined;
243
329
  else return jsonResponse({ error: "prompt must be a string or null" }, 400);
244
330
  }
245
- // Clearing the model always clears the effort (it is meaningless alone).
246
- if (!nextModel) nextEffort = undefined;
331
+ // Clearing the model always clears model-dependent settings before sync/effort gates.
332
+ if (!nextModel) {
333
+ nextEffort = undefined;
334
+ nextSyncCodexSubagentDefaults = false;
335
+ }
336
+ if (body.syncCodexSubagentDefaults === true && !nextModel?.trim()) {
337
+ return jsonResponse({ error: "syncCodexSubagentDefaults requires an injection model" }, 400);
338
+ }
339
+ if (nextSyncCodexSubagentDefaults && nextEffort !== undefined && !isCodexReasoningEffort(nextEffort)) {
340
+ return jsonResponse({ error: "syncCodexSubagentDefaults requires a supported Codex reasoning effort" }, 400);
341
+ }
247
342
 
248
343
  config.multiAgentGuidanceEnabled = nextEnabled;
344
+ if (nextSyncCodexSubagentDefaults) config.syncCodexSubagentDefaults = true;
345
+ else delete config.syncCodexSubagentDefaults;
249
346
  if (nextModel) config.injectionModel = nextModel;
250
347
  else delete config.injectionModel;
251
348
  if (nextEffort) config.injectionEffort = nextEffort;
@@ -257,6 +354,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
257
354
  return jsonResponse({
258
355
  ok: true,
259
356
  multiAgentGuidanceEnabled: multiAgentGuidanceEnabled(config),
357
+ syncCodexSubagentDefaults: subagentDefaultSyncEffective(config),
260
358
  model: config.injectionModel ?? null,
261
359
  effort: config.injectionEffort ?? null,
262
360
  prompt: config.injectionPrompt ?? null,
@@ -29,6 +29,10 @@ import { providerCodexAccountMode } from "../../providers/registry";
29
29
  import { routedSlug, slugEquals } from "../../providers/slug-codec";
30
30
  import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota";
31
31
  import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
32
+ import {
33
+ CODEX_ACCOUNT_NAMESPACE_COMBO_ALIAS_COLLISION_ERROR,
34
+ codexAccountNamespaceForModel,
35
+ } from "../../codex/account-namespace-match";
32
36
  import { clearThreadAccountMap } from "../../codex/routing";
33
37
  import { primeCodexPoolQuotas } from "../../codex/auth-api";
34
38
  import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap";
@@ -123,6 +127,9 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise<Respons
123
127
  const previous = config.combos?.[sourceId];
124
128
  const oldPublicModel = previous ? comboPublicModelId(sourceId, previous) : null;
125
129
  const newPublicModel = comboPublicModelId(id, normalized);
130
+ if (codexAccountNamespaceForModel(config.codexAccountNamespaces, newPublicModel)) {
131
+ return jsonResponse({ error: CODEX_ACCOUNT_NAMESPACE_COMBO_ALIAS_COLLISION_ERROR }, 409);
132
+ }
126
133
  const nextCombos = { ...(config.combos ?? {}) };
127
134
  if (renameFrom) delete nextCombos[renameFrom];
128
135
  nextCombos[id] = stored;