@bitkyc08/opencodex 2.11.1 → 2.12.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 (175) hide show
  1. package/README.md +1 -1
  2. package/bin/ocx.mjs +4 -0
  3. package/gui/dist/assets/index-BNVYzdn0.css +1 -0
  4. package/gui/dist/assets/index-Cw1_mxO8.js +70 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/adapters/command-code.ts +94 -5
  8. package/src/adapters/cursor/live-transport.ts +14 -1
  9. package/src/adapters/google-tool-schema.ts +80 -15
  10. package/src/adapters/google.ts +24 -7
  11. package/src/adapters/openai-chat.ts +233 -305
  12. package/src/adapters/openai-responses.ts +62 -0
  13. package/src/adapters/tool-catalog-nudge.ts +2 -11
  14. package/src/claude/outbound.ts +42 -14
  15. package/src/cli/claude.ts +61 -9
  16. package/src/cli/codex-shim-autorestore.ts +2 -0
  17. package/src/cli/codex-shim-readiness.ts +8 -1
  18. package/src/cli/config-command.ts +4 -1
  19. package/src/cli/doctor.ts +10 -0
  20. package/src/cli/help.ts +15 -0
  21. package/src/cli/index.ts +27 -11
  22. package/src/cli/lab.ts +367 -0
  23. package/src/cli/provider.ts +9 -1
  24. package/src/clients/config-export.ts +100 -3
  25. package/src/codex/app-server-processes.ts +11 -8
  26. package/src/codex/auth-api.ts +92 -28
  27. package/src/codex/auth-collision.ts +5 -3
  28. package/src/codex/catalog/metadata.ts +7 -3
  29. package/src/codex/catalog/provider-fetch.ts +191 -47
  30. package/src/codex/catalog.ts +2 -2
  31. package/src/codex/history-job.ts +83 -18
  32. package/src/codex/history-migration-guardian.ts +9 -16
  33. package/src/codex/history-provider.ts +178 -1
  34. package/src/codex/history-transition.ts +5 -5
  35. package/src/codex/history-worker.ts +22 -2
  36. package/src/codex/native-profile-store.ts +1 -1
  37. package/src/codex/plan.ts +15 -0
  38. package/src/codex/quota.ts +6 -6
  39. package/src/codex/routing.ts +3 -3
  40. package/src/codex/subagent-model-fallback.ts +246 -22
  41. package/src/codex/user-identity.ts +63 -12
  42. package/src/config.ts +265 -32
  43. package/src/images/loop.ts +3 -1
  44. package/src/images/plan.ts +23 -13
  45. package/src/integrations/omp-yaml-source.ts +225 -0
  46. package/src/integrations/ownership.ts +5 -5
  47. package/src/integrations/state.ts +45 -6
  48. package/src/integrations/writer.ts +38 -3
  49. package/src/lab/artifacts/sanitize.ts +586 -0
  50. package/src/lab/artifacts/secure-fs.ts +475 -0
  51. package/src/lab/artifacts/store.ts +310 -0
  52. package/src/lab/conformance/assertion.ts +325 -0
  53. package/src/lab/conformance/digest.ts +22 -0
  54. package/src/lab/conformance/executor.ts +741 -0
  55. package/src/lab/conformance/fixture-provider.ts +27 -0
  56. package/src/lab/conformance/fixtures/live-v1-cases.json +175 -0
  57. package/src/lab/conformance/fixtures/protocol-v1-cases.json +461 -0
  58. package/src/lab/conformance/harness-budget.ts +47 -0
  59. package/src/lab/conformance/index.ts +5 -0
  60. package/src/lab/conformance/jcs.ts +24 -0
  61. package/src/lab/conformance/json-pointer.ts +39 -0
  62. package/src/lab/conformance/manifest.ts +180 -0
  63. package/src/lab/conformance/mcp-stub.ts +179 -0
  64. package/src/lab/conformance/negative-controls.ts +160 -0
  65. package/src/lab/conformance/observation.ts +355 -0
  66. package/src/lab/conformance/runner.ts +57 -0
  67. package/src/lab/conformance/sse-normalize.ts +59 -0
  68. package/src/lab/conformance/suite-manifest.ts +78 -0
  69. package/src/lab/conformance/types.ts +191 -0
  70. package/src/lab/constants.ts +126 -0
  71. package/src/lab/digest.ts +64 -0
  72. package/src/lab/events/errors.ts +9 -0
  73. package/src/lab/events/limits.ts +117 -0
  74. package/src/lab/events/types.ts +228 -0
  75. package/src/lab/events/validate.ts +780 -0
  76. package/src/lab/index.ts +36 -0
  77. package/src/lab/ledger/artifact-refs.ts +127 -0
  78. package/src/lab/ledger/invalidation.ts +136 -0
  79. package/src/lab/ledger/purge.ts +241 -0
  80. package/src/lab/ledger/store.ts +263 -0
  81. package/src/lab/live/credential-lease.ts +53 -0
  82. package/src/lab/live/destination.ts +155 -0
  83. package/src/lab/live/executor.ts +311 -0
  84. package/src/lab/live/inert-tools.ts +56 -0
  85. package/src/lab/live/manifest.ts +85 -0
  86. package/src/lab/live/mcp-loopback.ts +57 -0
  87. package/src/lab/live/runner.ts +19 -0
  88. package/src/lab/live/sandbox.ts +61 -0
  89. package/src/lab/live/suite-manifest.ts +41 -0
  90. package/src/lab/live/transport.ts +116 -0
  91. package/src/lab/live/types.ts +197 -0
  92. package/src/lab/observe/from-conformance.ts +344 -0
  93. package/src/lab/observe/from-live.ts +103 -0
  94. package/src/lab/paths.ts +65 -0
  95. package/src/lab/projection/rebuild.ts +477 -0
  96. package/src/lab/projection/schema.ts +122 -0
  97. package/src/lab/projection/verdicts.ts +438 -0
  98. package/src/lab/projection/verification.ts +332 -0
  99. package/src/lab/query/catalog.ts +72 -0
  100. package/src/lab/query/connection.ts +107 -0
  101. package/src/lab/query/constants.ts +4 -0
  102. package/src/lab/query/cursor.ts +132 -0
  103. package/src/lab/query/dto-map.ts +277 -0
  104. package/src/lab/query/errors.ts +22 -0
  105. package/src/lab/query/index.ts +34 -0
  106. package/src/lab/query/queries.ts +444 -0
  107. package/src/lab/query/types.ts +266 -0
  108. package/src/lab/subject/behavior-fingerprint.ts +77 -0
  109. package/src/lab/subject/installation-salt.ts +81 -0
  110. package/src/lab/subject/route-subject.ts +73 -0
  111. package/src/lib/bounded-body.ts +104 -0
  112. package/src/lib/lab-live-execution-authority.ts +13 -0
  113. package/src/lib/lab-live-host.ts +30 -0
  114. package/src/lib/lab-live-pinned-sender.ts +45 -0
  115. package/src/lib/pinned-http.ts +36 -7
  116. package/src/oauth/index.ts +20 -1
  117. package/src/oauth/local-token-detect.ts +11 -2
  118. package/src/oauth/login-cli.ts +38 -4
  119. package/src/providers/alibaba-region-migration.ts +1 -1
  120. package/src/providers/codex-capacity.ts +7 -4
  121. package/src/providers/command-code-efforts.ts +5 -2
  122. package/src/providers/derive.ts +105 -0
  123. package/src/providers/label.ts +1 -1
  124. package/src/providers/openai-tiers.ts +59 -3
  125. package/src/providers/opencode-zen-rate-limit.ts +102 -0
  126. package/src/providers/quota.ts +67 -21
  127. package/src/providers/registry.ts +118 -5
  128. package/src/router.ts +15 -1
  129. package/src/routing/analytics.ts +8 -7
  130. package/src/routing/evaluator.ts +51 -5
  131. package/src/routing/profile.ts +10 -1
  132. package/src/routing/trace.ts +42 -6
  133. package/src/server/auth-cors.ts +15 -0
  134. package/src/server/background-lifecycle.ts +169 -0
  135. package/src/server/chat-completions.ts +18 -3
  136. package/src/server/claude-messages.ts +1 -0
  137. package/src/server/images.ts +78 -11
  138. package/src/server/index.ts +72 -18
  139. package/src/server/lifecycle.ts +6 -0
  140. package/src/server/live.ts +5 -1
  141. package/src/server/management/agent-settings-routes.ts +41 -24
  142. package/src/server/management/body.ts +6 -0
  143. package/src/server/management/config-routes.ts +51 -8
  144. package/src/server/management/lab-routes.ts +388 -0
  145. package/src/server/management/logs-usage-routes.ts +19 -1
  146. package/src/server/management/model-rows.ts +25 -11
  147. package/src/server/management/native-integration-routes.ts +3 -3
  148. package/src/server/management/provider-routes.ts +5 -0
  149. package/src/server/management/shared.ts +8 -1
  150. package/src/server/management/usage-summary-cache.ts +2 -0
  151. package/src/server/management/vision-sidecar-options.ts +125 -0
  152. package/src/server/management-api.ts +2 -0
  153. package/src/server/request-decompress.ts +106 -5
  154. package/src/server/responses/collaboration.ts +4 -1
  155. package/src/server/responses/core.ts +117 -31
  156. package/src/server/responses-json-events.ts +50 -12
  157. package/src/server/search.ts +23 -7
  158. package/src/server/ws-bridge.ts +3 -1
  159. package/src/service.ts +40 -20
  160. package/src/types.ts +74 -0
  161. package/src/update/job.ts +10 -2
  162. package/src/usage/cost.ts +0 -0
  163. package/src/usage/expected-prices.ts +9 -0
  164. package/src/usage/log.ts +9 -9
  165. package/src/usage/user-cost-overlay-reconciler.ts +313 -0
  166. package/src/usage/user-cost-overlays.ts +314 -0
  167. package/src/vision/describe.ts +2 -2
  168. package/src/vision/eligibility.ts +229 -0
  169. package/src/vision/index.ts +20 -2
  170. package/src/web-search/executor.ts +2 -2
  171. package/src/web-search/index.ts +4 -2
  172. package/src/web-search/loop.ts +3 -1
  173. package/src/web-search/parse.ts +72 -3
  174. package/gui/dist/assets/index-BA1dgo4q.css +0 -1
  175. package/gui/dist/assets/index-DDZpgzKk.js +0 -70
@@ -25,12 +25,11 @@ import { getCodexHome } from "../codex/paths";
25
25
  import { shouldSyncCodexOnStart } from "../codex/desired-state";
26
26
  import { inspectNativeCodexOwnership } from "../integrations/native/ownership-preflight";
27
27
  import { registerCodexCooldownRecoveryProbeWorker } from "../codex/auth-api";
28
- import { startMemoryWatchdog } from "./memory-watchdog";
29
28
  import {
30
29
  reconcileLiveStateStores,
31
30
  setLiveStateStoreConfig,
32
31
  } from "../lib/state-store-registrations";
33
- import { startStateStoreSweeper } from "../lib/state-store-sweeper";
32
+ import { startUserCostOverlayReconciler } from "../usage/user-cost-overlay-reconciler";
34
33
  import {
35
34
  configureAppOwnedMemoryBudget,
36
35
  enforceAppOwnedMemoryBudget,
@@ -41,9 +40,7 @@ import {
41
40
  registerDefaultAppOwnedMemoryStores,
42
41
  registerDefaultAppOwnedObservedBuffers,
43
42
  } from "../lib/app-owned-memory-stores";
44
- import { setStorageCleanupPolicyLiveSink } from "../storage/policy";
45
- import { setStorageCleanupPolicyJobLiveApply } from "../storage/policy-job";
46
- import { scheduleStorageCleanupStartupRun, startStorageCleanupScheduler } from "../storage/policy-scheduler";
43
+ import { acquireServerBackgroundLifecycle } from "./background-lifecycle";
47
44
  import { runOpenAiTierStartupMigration } from "../providers/openai-tier-startup";
48
45
  import { runAlibabaRegionStartupMigration } from "../providers/alibaba-region-startup";
49
46
  import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers";
@@ -189,11 +186,42 @@ import {
189
186
  import { SYSTEM_RESTART_CAPABILITY_VERSION } from "../lib/system-restart-contract";
190
187
  import { createReadinessGate, type ReadinessGate } from "./readiness";
191
188
 
192
- const MAX_WS_FRAME_BYTES = 50 * 1024 * 1024;
189
+ export const MAX_WS_FRAME_BYTES = 50 * 1024 * 1024;
193
190
  const WEBSOCKET_IDLE_TIMEOUT_SECONDS = 0;
194
191
  const LIVE_SIDEBAND_PENDING_MAX = 32;
192
+ const LIVE_SIDEBAND_PENDING_BYTES_MAX = 1024 * 1024;
195
193
  const LIVE_SIDEBAND_CLOSE_FALLBACK_MS = 1_000;
196
194
 
195
+ export function exceedsLiveSidebandFrameByteLimit(frameBytes: number): boolean {
196
+ return frameBytes > MAX_WS_FRAME_BYTES;
197
+ }
198
+
199
+ export function exceedsLiveSidebandPendingByteLimit(pendingBytes: number, incomingBytes: number): boolean {
200
+ return incomingBytes > LIVE_SIDEBAND_PENDING_BYTES_MAX - pendingBytes;
201
+ }
202
+
203
+ function webSocketFrameBytes(frame: string | ArrayBuffer | ArrayBufferView | Blob | Buffer): number {
204
+ if (typeof frame === "string") return Buffer.byteLength(frame);
205
+ if (frame instanceof ArrayBuffer || ArrayBuffer.isView(frame)) return frame.byteLength;
206
+ return frame.size;
207
+ }
208
+
209
+ export type LiveSidebandPendingEnqueueResult = "queued" | "too-many-frames" | "too-many-bytes";
210
+
211
+ export function enqueueLiveSidebandPendingFrame(
212
+ data: Pick<WsData, "livePending" | "livePendingBytes">,
213
+ frame: string | Buffer,
214
+ frameBytes = webSocketFrameBytes(frame),
215
+ ): LiveSidebandPendingEnqueueResult {
216
+ const pending = data.livePending ?? (data.livePending = []);
217
+ if (pending.length >= LIVE_SIDEBAND_PENDING_MAX) return "too-many-frames";
218
+ const pendingBytes = data.livePendingBytes ?? 0;
219
+ if (exceedsLiveSidebandPendingByteLimit(pendingBytes, frameBytes)) return "too-many-bytes";
220
+ pending.push(frame);
221
+ data.livePendingBytes = pendingBytes + frameBytes;
222
+ return "queued";
223
+ }
224
+
197
225
  type LiveSidebandWebSocketFactory = (
198
226
  url: string,
199
227
  headers: Record<string, string>,
@@ -228,6 +256,7 @@ function finalizeLiveSideband(ws: ServerWebSocket<WsData>, upstream?: WebSocket)
228
256
  }
229
257
  ws.data.liveUpstream = undefined;
230
258
  ws.data.livePending = undefined;
259
+ ws.data.livePendingBytes = undefined;
231
260
  ws.data.cancel = undefined;
232
261
  releaseLiveSidebandAdmission(ws);
233
262
  }
@@ -263,6 +292,7 @@ function closeLiveSideband(ws: ServerWebSocket<WsData>, code = 1000, reason = ""
263
292
  if (ws.data.liveClosing) return;
264
293
  ws.data.liveClosing = true;
265
294
  ws.data.livePending = undefined;
295
+ ws.data.livePendingBytes = undefined;
266
296
  ws.data.cancel = undefined;
267
297
  const upstream = ws.data.liveUpstream;
268
298
  // Bun's `WebSocket` type narrows `readyState` to 0|1|2 even though the DOM
@@ -318,6 +348,7 @@ function attachLiveSidebandUpstream(
318
348
  ws.data.liveOpened = true;
319
349
  const pending = ws.data.livePending ?? [];
320
350
  ws.data.livePending = undefined;
351
+ ws.data.livePendingBytes = undefined;
321
352
  for (const frame of pending) {
322
353
  try {
323
354
  sendUpstreamFrame(upstream, frame);
@@ -330,6 +361,10 @@ function attachLiveSidebandUpstream(
330
361
  upstream.addEventListener("message", (event) => {
331
362
  if (ws.data.liveUpstream !== upstream || ws.data.liveClosing) return;
332
363
  try {
364
+ if (exceedsLiveSidebandFrameByteLimit(webSocketFrameBytes(event.data))) {
365
+ closeLiveSideband(ws, 1009, "message too large");
366
+ return;
367
+ }
333
368
  logLiveSidebandFrame("u2c", event.data);
334
369
  if (typeof event.data === "string") ws.send(event.data);
335
370
  else if (event.data instanceof ArrayBuffer) ws.send(event.data);
@@ -426,6 +461,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
426
461
  applyProxyEnv(config);
427
462
  assertServerAuthConfig(config);
428
463
  const managementAuth = deps.managementAuthState ?? initializeManagementAuthState(config);
464
+ let userCostOverlayReconciler: { stop(): void } | null = null;
429
465
  // Arm synchronously before listen. A pending journal therefore makes __main__ unusable
430
466
  // before any request can resolve its physical credential, while health/management/Pool stay live.
431
467
  // Refresh OAuth provider presets (models/noReasoningModels) from the registry so a proxy update
@@ -486,16 +522,12 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
486
522
  // usage.jsonl already persists every request; rehydrate the in-memory Logs ring so
487
523
  // /api/logs (and the GUI) survive `ocx stop` / `ocx start` process restarts.
488
524
  hydrateRequestLogsFromDisk();
489
- // #314: warn-only RSS observability (unref'd, idempotent — safe under repeated
490
- // startServer(0) in tests). Snapshot surfaces via GET /api/system/memory.
491
- startMemoryWatchdog();
492
525
  registerDefaultAppOwnedMemoryStores();
493
526
  registerDefaultAppOwnedObservedBuffers();
494
527
  registerAppOwnedMemorySweepFallback();
495
528
  configureAppOwnedMemoryBudget(resolveAppOwnedMemoryBudgetBytes(config.appOwnedMemoryBudgetMb));
496
529
  enforceAppOwnedMemoryBudget();
497
530
  registerCodexCooldownRecoveryProbeWorker(config);
498
- startStateStoreSweeper();
499
531
  // Issue #42 Phase 3: opt-in archived auto-cleanup (default OFF). Unref'd hourly
500
532
  // tick for daily/weekly; startup evaluation is fire-and-forget after listen.
501
533
  // Heavy work runs in a Worker via the single-flight job controller.
@@ -503,9 +535,6 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
503
535
  const applyPolicy = (policy: StorageCleanupPolicy) => {
504
536
  config.storageCleanupPolicy = policy;
505
537
  };
506
- setStorageCleanupPolicyLiveSink(applyPolicy);
507
- setStorageCleanupPolicyJobLiveApply(applyPolicy);
508
- startStorageCleanupScheduler();
509
538
 
510
539
  const listenPort = port ?? config.port ?? 10100;
511
540
  setCorsOrigin(listenPort);
@@ -632,7 +661,14 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
632
661
  };
633
662
  let server: Server<WsData>;
634
663
  let loopbackServer: Server<WsData> | null = null;
664
+ let backgroundLifecycle: ReturnType<typeof acquireServerBackgroundLifecycle> | null = null;
635
665
  try {
666
+ backgroundLifecycle = acquireServerBackgroundLifecycle(applyPolicy);
667
+ // External `ocx config set` / direct config.json edits run in other
668
+ // processes; poll the file so Logs/Usage display prices follow them live.
669
+ // Started inside the guarded startup transaction so the catch below can
670
+ // release the owner-scoped lease on any listener failure.
671
+ userCostOverlayReconciler = startUserCostOverlayReconciler({ liveConfig: config });
636
672
  const serveOptions = {
637
673
  idleTimeout: 255,
638
674
  async fetch(req: Request, requestServer: Server<WsData>): Promise<Response> {
@@ -1264,6 +1300,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
1264
1300
  liveUpstreamUrl: resolved.upstreamWsUrl,
1265
1301
  liveUpstreamHeaders: resolved.headers,
1266
1302
  livePending: [],
1303
+ livePendingBytes: 0,
1267
1304
  liveOpened: false,
1268
1305
  liveTurnAdmissionLease: turnAdmissionLease,
1269
1306
  } satisfies WsData,
@@ -1297,6 +1334,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
1297
1334
  return withCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, config);
1298
1335
  },
1299
1336
  websocket: {
1337
+ maxPayloadLength: MAX_WS_FRAME_BYTES,
1300
1338
  idleTimeout: WEBSOCKET_IDLE_TIMEOUT_SECONDS,
1301
1339
  // Responses WebSocket data plane (phase 120.2). Re-frames the same SSE pipeline onto the
1302
1340
  // socket: parse response.create → run handleResponses unchanged → pump its SSE body as WS
@@ -1321,15 +1359,23 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
1321
1359
  message(ws: ServerWebSocket<WsData>, raw: string | Buffer) {
1322
1360
  if (ws.data.kind === "live-sideband") {
1323
1361
  if (ws.data.liveClosing) return;
1362
+ const rawBytes = webSocketFrameBytes(raw);
1363
+ if (exceedsLiveSidebandFrameByteLimit(rawBytes)) {
1364
+ closeLiveSideband(ws, 1009, "message too large");
1365
+ return;
1366
+ }
1324
1367
  logLiveSidebandFrame("c2u", raw);
1325
1368
  const upstream = ws.data.liveUpstream;
1326
1369
  if (!upstream || upstream.readyState === WebSocket.CONNECTING || !ws.data.liveOpened) {
1327
- const pending = ws.data.livePending ?? (ws.data.livePending = []);
1328
- if (pending.length >= LIVE_SIDEBAND_PENDING_MAX) {
1370
+ const enqueueResult = enqueueLiveSidebandPendingFrame(ws.data, raw, rawBytes);
1371
+ if (enqueueResult === "too-many-frames") {
1329
1372
  closeLiveSideband(ws, 1009, "too many pending frames");
1330
1373
  return;
1331
1374
  }
1332
- pending.push(raw);
1375
+ if (enqueueResult === "too-many-bytes") {
1376
+ closeLiveSideband(ws, 1009, "too many pending bytes");
1377
+ return;
1378
+ }
1333
1379
  return;
1334
1380
  }
1335
1381
  if (upstream.readyState !== WebSocket.OPEN) {
@@ -1527,6 +1573,8 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
1527
1573
  }
1528
1574
  }
1529
1575
  } catch (error) {
1576
+ userCostOverlayReconciler?.stop();
1577
+ backgroundLifecycle?.releaseAfterFailedStart();
1530
1578
  void nativeMainLifecycle.release();
1531
1579
  throw error;
1532
1580
  }
@@ -1545,8 +1593,14 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
1545
1593
  ...(loopbackListenerRef
1546
1594
  ? [() => loopbackListenerRef.stop(closeActiveConnections)]
1547
1595
  : []),
1596
+ async () => {
1597
+ userCostOverlayReconciler?.stop();
1598
+ },
1548
1599
  ],
1549
- () => releaseNativeMainStartupLifecycle(server),
1600
+ async () => {
1601
+ await backgroundLifecycle.release();
1602
+ await releaseNativeMainStartupLifecycle(server);
1603
+ },
1550
1604
  );
1551
1605
  },
1552
1606
  });
@@ -1590,7 +1644,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
1590
1644
  }
1591
1645
 
1592
1646
  // Opt-in storage policy (default OFF). Never blocks listen; cancellable on shutdown.
1593
- scheduleStorageCleanupStartupRun();
1647
+ backgroundLifecycle.scheduleStartupRun();
1594
1648
 
1595
1649
  return server;
1596
1650
  }
@@ -447,6 +447,12 @@ export async function drainAndShutdown(
447
447
  // then drain leftovers; failures must not prevent `server.stop`.
448
448
  stopStorageCleanupScheduler();
449
449
  stopStateStoreSweeper();
450
+ // The overlay reconciler is owner-scoped: the startServer stop override
451
+ // releases THIS server's lease through runListenerShutdown →
452
+ // userCostOverlayReconciler.stop(), which also recomputes disk-only
453
+ // preservation for any remaining owners. A process-wide stop here would
454
+ // kill reconciliation for every other server in the process, so drain
455
+ // must not call stopUserCostOverlayReconciler().
450
456
  cancelQueuedStorageWorkerSpawns();
451
457
  const shutdownJoins = await Promise.allSettled([
452
458
  abortStorageCleanupPolicyJobAsync(),
@@ -207,8 +207,12 @@ export function parseLiveSidebandTarget(pathname: string, searchParams: URLSearc
207
207
  */
208
208
  function isLoopbackHost(hostname: string): boolean {
209
209
  const lower = hostname.toLowerCase();
210
+ const ipv4 = lower.split(".");
211
+ const ipv4Loopback = ipv4.length === 4
212
+ && ipv4[0] === "127"
213
+ && ipv4.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255);
210
214
  return lower === "localhost" || lower.endsWith(".localhost")
211
- || lower === "127.0.0.1" || lower.startsWith("127.")
215
+ || ipv4Loopback
212
216
  || lower === "::1" || lower === "[::1]";
213
217
  }
214
218
 
@@ -51,6 +51,11 @@ import {
51
51
  type DebugFlag,
52
52
  } from "../../lib/debug-settings";
53
53
  import type { OcxClaudeCodeConfig, OcxConfig, OcxCustomModel, OcxProviderConfig } from "../../types";
54
+ import {
55
+ visionCandidateRows,
56
+ visionDescriberIsProvablyBlind,
57
+ visionDescriberRejection,
58
+ } from "./vision-sidecar-options";
54
59
  import { drainAndShutdown } from "../lifecycle";
55
60
  import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "../request-log";
56
61
  import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../../usage/cost";
@@ -60,7 +65,7 @@ import { applySystemEnvToggle } from "../system-env";
60
65
 
61
66
  import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels, fetchGrokCandidateModels, buildClaudeDesktopState } from "./shared";
62
67
  import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared";
63
- import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body";
68
+ import { readManagementJsonBody, readOptionalManagementJsonBody, rethrowManagementBodyTooLarge } from "./body";
64
69
 
65
70
  const GROK_APPLY_JOIN_MS = 120_000;
66
71
  export const GROK_APPLY_TERMINAL_MS = 10 * 60_000;
@@ -759,32 +764,22 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
759
764
  }
760
765
  if (url.pathname === "/api/claude-desktop/apply" && req.method === "POST") {
761
766
  try {
762
- const { setIntegrationEnabled, claudeDesktopIntegrationEnabled } = await import("../../codex/desired-state");
763
- const desired = setIntegrationEnabled("claude-desktop", true);
764
- if (!desired.ok) return jsonResponse({ error: desired.message }, desired.retryable ? 409 : 500);
765
- // Disk now says ON; the reused server snapshot must agree, or the native
766
- // GET reports OFF and a later whole-snapshot save undoes this transition.
767
- mirrorDesiredEnabledOntoSnapshot(config, "claude-desktop", true);
768
- // Disk now says ON; the reused server snapshot must agree, or the native
769
- // GET reports OFF and a later whole-snapshot save undoes this transition.
770
767
  // #859: the CLI delegates here so the registry is built in the serving
771
768
  // process. Accept an optional mode; default stays static for back-compat.
772
769
  let mode: "static" | "hybrid" | "discovery" = "static";
773
- const rawBody = await req.text();
774
770
  let parsed: unknown;
775
- if (rawBody.trim()) {
776
- try {
777
- parsed = JSON.parse(rawBody);
778
- } catch {
779
- return jsonResponse({ error: "invalid JSON body" }, 400);
780
- }
781
- const requested = (parsed as { mode?: unknown } | null)?.mode;
782
- if (requested !== undefined) {
783
- if (requested === "static" || requested === "hybrid" || requested === "discovery") {
784
- mode = requested;
785
- } else {
786
- return jsonResponse({ error: "mode must be static, hybrid, or discovery" }, 400);
787
- }
771
+ try {
772
+ parsed = await readOptionalManagementJsonBody(req);
773
+ } catch (error) {
774
+ rethrowManagementBodyTooLarge(error);
775
+ return jsonResponse({ error: "invalid JSON body" }, 400);
776
+ }
777
+ const requested = (parsed as { mode?: unknown } | null)?.mode;
778
+ if (requested !== undefined) {
779
+ if (requested === "static" || requested === "hybrid" || requested === "discovery") {
780
+ mode = requested;
781
+ } else {
782
+ return jsonResponse({ error: "mode must be static, hybrid, or discovery" }, 400);
788
783
  }
789
784
  }
790
785
  // #859: a delegated CLI apply carries the profile it just saved — the
@@ -800,6 +795,12 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
800
795
  return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 400);
801
796
  }
802
797
  }
798
+ const { setIntegrationEnabled, claudeDesktopIntegrationEnabled } = await import("../../codex/desired-state");
799
+ const desired = setIntegrationEnabled("claude-desktop", true);
800
+ if (!desired.ok) return jsonResponse({ error: desired.message }, desired.retryable ? 409 : 500);
801
+ // Disk now says ON; the reused server snapshot must agree, or the native
802
+ // GET reports OFF and a later whole-snapshot save undoes this transition.
803
+ mirrorDesiredEnabledOntoSnapshot(config, "claude-desktop", true);
803
804
  const state = await buildClaudeDesktopState(config, profileOverride);
804
805
  // `setIntegrationEnabled` above wrote desired ON to DISK; it does not touch
805
806
  // this long-lived server snapshot. Saving the snapshot wholesale would carry
@@ -866,6 +867,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
866
867
  }
867
868
  return jsonResponse({ ok: true, saved: true, applied: true, path: result.path, fingerprint: result.fingerprint });
868
869
  } catch (error) {
870
+ rethrowManagementBodyTooLarge(error);
869
871
  return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 400);
870
872
  }
871
873
  }
@@ -1010,6 +1012,19 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
1010
1012
  if (section.model !== undefined && typeof section.model !== "string") {
1011
1013
  return jsonResponse({ error: `${field}.model must be a string` }, 400);
1012
1014
  }
1015
+ // Vision override only: reject a model we can prove is blind. Unknown ids stay
1016
+ // allowed; webSearchSidecar has no vision requirement and is left alone. Shares
1017
+ // one policy module with /api/sidecar-settings so the two gates cannot drift.
1018
+ if (field === "visionSidecar" && typeof section.model === "string" && section.model !== "") {
1019
+ const requested = section.model;
1020
+ const candidates = await visionCandidateRows(config);
1021
+ const hint = section.backend === "anthropic" || section.backend === "openai"
1022
+ ? section.backend
1023
+ : config.claudeCode?.visionSidecar?.backend;
1024
+ if (visionDescriberIsProvablyBlind(config, requested, candidates, hint)) {
1025
+ return jsonResponse(visionDescriberRejection("visionSidecar.model", requested, config, candidates), 400);
1026
+ }
1027
+ }
1013
1028
  }
1014
1029
  const next = { ...(config.claudeCode ?? {}) };
1015
1030
  for (const field of ["webSearchSidecar", "visionSidecar"] as const) {
@@ -1118,11 +1133,12 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
1118
1133
  else delete next.tierModels;
1119
1134
  }
1120
1135
  }
1136
+ let nextFastMode = config.fastMode;
1121
1137
  if (body.fastMode !== undefined) {
1122
1138
  if (body.fastMode !== true && body.fastMode !== false && body.fastMode !== null) {
1123
1139
  return jsonResponse({ error: "fastMode must be true, false, or null" }, 400);
1124
1140
  }
1125
- config.fastMode = body.fastMode === null ? undefined : body.fastMode;
1141
+ nextFastMode = body.fastMode === null ? undefined : body.fastMode;
1126
1142
  }
1127
1143
  for (const field of ["model", "smallFastModel"] as const) {
1128
1144
  const value = body[field];
@@ -1149,6 +1165,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
1149
1165
  else delete next.modelMap;
1150
1166
  }
1151
1167
  }
1168
+ if (body.fastMode !== undefined) config.fastMode = nextFastMode;
1152
1169
  config.claudeCode = next;
1153
1170
  // Stamp the migration sentinel on EVERY persist of this block. The migration reads
1154
1171
  // "a claudeCode block with no authMode" as a pre-upgrade subscriber and pins it to
@@ -11,6 +11,12 @@ export function readManagementJsonBody<T = unknown>(req: Request): Promise<T> {
11
11
  return readBoundedJsonRequestBody(req, MANAGEMENT_JSON_BODY_MAX_BYTES) as Promise<T>;
12
12
  }
13
13
 
14
+ export function readOptionalManagementJsonBody<T = unknown>(req: Request): Promise<T> {
15
+ return readBoundedJsonRequestBody(req, MANAGEMENT_JSON_BODY_MAX_BYTES, undefined, {
16
+ emptyBodyFallback: {},
17
+ }) as Promise<T>;
18
+ }
19
+
14
20
  export function managementBodyTooLargeResponse(
15
21
  error: unknown,
16
22
  req: Request,
@@ -54,6 +54,13 @@ import { stripCodexRuntimeProviderFields } from "../../codex/auth-context";
54
54
  import { getProviderRegistryEntry } from "../../providers/registry";
55
55
  import { VISION_REASONING_EFFORTS, isVisionReasoningEffort } from "../../reasoning-effort";
56
56
  import { normalizeVisionReasoningForModel } from "../../vision/reasoning";
57
+ import { findAnthropicVisionProvider, resolveEffectiveVisionModel, resolveVisionBackend } from "../../vision";
58
+ import {
59
+ visionCandidateRows,
60
+ visionDescriberIsProvablyBlind,
61
+ visionDescriberRejection,
62
+ visionModelOptionsFor,
63
+ } from "./vision-sidecar-options";
57
64
  import { getDebugLogEntries } from "../../lib/debug-log-buffer";
58
65
  import { getInjectionDebugLogEntries } from "../../lib/injection-debug-log";
59
66
  import {
@@ -80,6 +87,27 @@ import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, C
80
87
  import type { ManagementContext } from "./context";
81
88
  import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body";
82
89
 
90
+ async function sidecarVisionResponseSettings(config: OcxConfig): Promise<{
91
+ model: string;
92
+ reasoning: string;
93
+ models: Awaited<ReturnType<typeof visionModelOptionsFor>>;
94
+ }> {
95
+ const vs = config.visionSidecar ?? {};
96
+ // Match the runtime's one selected Anthropic executor for both backend fallback
97
+ // and catalog reachability; resolving it once prevents the two projections drifting.
98
+ const anthropicSidecar = findAnthropicVisionProvider(config);
99
+ const backend = resolveVisionBackend(vs.backend, anthropicSidecar);
100
+ const model = resolveEffectiveVisionModel(config, backend);
101
+ const reasoning = normalizeVisionReasoningForModel(model, vs.reasoning) ?? "low";
102
+ const models = await visionModelOptionsFor(config, anthropicSidecar);
103
+ // Display-only grandfather: a persisted id stays selectable, but the write gate
104
+ // remains stricter and rejects a model that is positively proven blind.
105
+ if (!models.some(option => option.value === model)) {
106
+ models.unshift({ value: model, label: model, backend });
107
+ }
108
+ return { model, reasoning, models };
109
+ }
110
+
83
111
  export async function handleConfigRoutes(ctx: ManagementContext): Promise<Response | null> {
84
112
  const { req, url, config, deps, convergeCodexCatalog, syncClaudeAgentDefsBestEffort } = ctx;
85
113
  if (url.pathname === "/api/config" && req.method === "GET") {
@@ -380,16 +408,16 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
380
408
  if (url.pathname === "/api/sidecar-settings" && req.method === "GET") {
381
409
  const ws = config.webSearchSidecar ?? {};
382
410
  const vs = config.visionSidecar ?? {};
383
- const visionModel = vs.model || "gpt-5.4-mini";
384
- const visionReasoning = normalizeVisionReasoningForModel(visionModel, vs.reasoning) ?? "low";
411
+ const vision = await sidecarVisionResponseSettings(config);
385
412
  return jsonResponse({
386
413
  webSearch: { model: ws.model ?? "gpt-5.6-luna", backend: ws.backend },
387
414
  vision: {
388
- model: visionModel,
415
+ model: vision.model,
389
416
  backend: vs.backend,
390
- reasoning: visionReasoning,
417
+ reasoning: vision.reasoning,
391
418
  maxDescriptionsPerTurn: vs.maxDescriptionsPerTurn,
392
419
  },
420
+ visionModels: vision.models,
393
421
  });
394
422
  }
395
423
 
@@ -422,6 +450,21 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
422
450
  if (body.vision?.reasoning !== undefined && !isVisionReasoningEffort(body.vision.reasoning)) {
423
451
  return jsonResponse({ error: `vision.reasoning must be ${VISION_REASONING_EFFORTS.join(", ")}` }, 400);
424
452
  }
453
+ // Reject ONLY a model we can prove is blind. An id nothing knows about stays
454
+ // allowed: the operator may be ahead of our catalog, and the runtime never
455
+ // required catalog membership (`tests/vision-reasoning-contract.test.ts`
456
+ // pins `custom-vision` → 200). The catalog is read ONCE and reused for the
457
+ // rejection body, so a 400 cannot cost two provider fetches.
458
+ if (body.vision && typeof body.vision.model === "string" && body.vision.model !== "") {
459
+ const requested = body.vision.model;
460
+ const candidates = await visionCandidateRows(config);
461
+ const hint = body.vision.backend === "anthropic" || body.vision.backend === "openai"
462
+ ? body.vision.backend
463
+ : config.visionSidecar?.backend;
464
+ if (visionDescriberIsProvablyBlind(config, requested, candidates, hint)) {
465
+ return jsonResponse(visionDescriberRejection("vision.model", requested, config, candidates), 400);
466
+ }
467
+ }
425
468
 
426
469
  let normalizedVisionReasoning: ReturnType<typeof normalizeVisionReasoningForModel>;
427
470
  let visionReasoningTouched = false;
@@ -469,17 +512,17 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
469
512
  saveConfigPreservingClaudeCode(config);
470
513
  const ws = config.webSearchSidecar ?? {};
471
514
  const vs = config.visionSidecar ?? {};
472
- const visionModel = vs.model || "gpt-5.4-mini";
473
- const visionReasoning = normalizeVisionReasoningForModel(visionModel, vs.reasoning) ?? "low";
515
+ const vision = await sidecarVisionResponseSettings(config);
474
516
  return jsonResponse({
475
517
  ok: true,
476
518
  webSearch: { model: ws.model ?? "gpt-5.6-luna", backend: ws.backend },
477
519
  vision: {
478
- model: visionModel,
520
+ model: vision.model,
479
521
  backend: vs.backend,
480
- reasoning: visionReasoning,
522
+ reasoning: vision.reasoning,
481
523
  maxDescriptionsPerTurn: vs.maxDescriptionsPerTurn,
482
524
  },
525
+ visionModels: vision.models,
483
526
  });
484
527
  }
485
528