@bitkyc08/opencodex 2.7.43 → 2.8.2-preview.20260731

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 (82) hide show
  1. package/bin/ocx.mjs +34 -8
  2. package/gui/dist/assets/index-BHsKRFh9.css +1 -0
  3. package/gui/dist/assets/index-GC0Vlu1Z.js +67 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/anthropic.ts +42 -7
  7. package/src/adapters/cursor/discovery.ts +4 -1
  8. package/src/adapters/cursor/effort-map.ts +3 -0
  9. package/src/adapters/kiro.ts +15 -1
  10. package/src/adapters/openai-chat.ts +55 -4
  11. package/src/claude/alias.ts +94 -14
  12. package/src/claude/outbound.ts +6 -3
  13. package/src/cli/catalog-prewarm.ts +24 -0
  14. package/src/cli/claude-desktop.ts +2 -2
  15. package/src/cli/claude.ts +32 -7
  16. package/src/cli/doctor.ts +48 -1
  17. package/src/cli/index.ts +5 -0
  18. package/src/cli/init.ts +129 -102
  19. package/src/cli/interactive-confirm.ts +5 -1
  20. package/src/cli/star-prompt.ts +26 -4
  21. package/src/cli/v2.ts +10 -1
  22. package/src/codex/account-store.ts +2 -0
  23. package/src/codex/catalog/bundled.ts +9 -2
  24. package/src/codex/catalog/metadata.ts +6 -0
  25. package/src/codex/catalog/parsing.ts +26 -1
  26. package/src/codex/catalog/provider-fetch.ts +240 -82
  27. package/src/codex/catalog/sync.ts +27 -5
  28. package/src/codex/catalog.ts +3 -3
  29. package/src/codex/features.ts +524 -5
  30. package/src/codex/quota.ts +77 -2
  31. package/src/codex/runtime.ts +10 -1
  32. package/src/config.ts +8 -0
  33. package/src/generated/jawcode-model-metadata.ts +12 -12
  34. package/src/github/star-state.ts +191 -0
  35. package/src/lib/bun-binary-validator.d.mts +3 -0
  36. package/src/lib/bun-binary-validator.mjs +18 -0
  37. package/src/lib/bun-runtime.ts +6 -20
  38. package/src/lib/destination-policy.ts +21 -3
  39. package/src/lib/provider-outbound.ts +8 -2
  40. package/src/lib/shadow-call.ts +30 -0
  41. package/src/lib/test-home-guard.ts +90 -0
  42. package/src/lib/win-exec.ts +12 -2
  43. package/src/lib/winsw.ts +6 -0
  44. package/src/oauth/index.ts +29 -5
  45. package/src/oauth/key-providers.ts +21 -2
  46. package/src/oauth/kiro-credentials.ts +129 -9
  47. package/src/oauth/kiro.ts +15 -3
  48. package/src/oauth/login-cli.ts +1 -1
  49. package/src/oauth/store.ts +2 -0
  50. package/src/providers/derive.ts +2 -2
  51. package/src/providers/free-directory.ts +4 -1
  52. package/src/providers/model-discovery.ts +356 -0
  53. package/src/providers/registry.ts +114 -0
  54. package/src/router.ts +5 -3
  55. package/src/server/auth-cors.ts +4 -2
  56. package/src/server/index.ts +3 -3
  57. package/src/server/live.ts +75 -25
  58. package/src/server/management/agent-settings-routes.ts +82 -8
  59. package/src/server/management/config-routes.ts +24 -7
  60. package/src/server/management/context.ts +11 -1
  61. package/src/server/management/model-routes.ts +61 -14
  62. package/src/server/management/provider-routes.ts +44 -9
  63. package/src/server/management/shared.ts +18 -5
  64. package/src/server/management/sidebar-routes.ts +39 -0
  65. package/src/server/management-api.ts +3 -1
  66. package/src/server/proxy-liveness.ts +9 -2
  67. package/src/server/responses/core.ts +31 -20
  68. package/src/server/responses/upstream-error.ts +48 -0
  69. package/src/server/startup-action-control.ts +30 -14
  70. package/src/service.ts +395 -31
  71. package/src/storage/policy-job.ts +26 -5
  72. package/src/storage/restore-job.ts +16 -5
  73. package/src/storage/worker-lifecycle.ts +81 -0
  74. package/src/tray/windows.ts +86 -13
  75. package/src/types.ts +16 -0
  76. package/src/update/badge.ts +72 -0
  77. package/src/update/job.ts +8 -4
  78. package/src/usage/expected-prices.ts +6 -5
  79. package/src/usage/log.ts +8 -0
  80. package/src/web-search/loop.ts +57 -16
  81. package/gui/dist/assets/index-Czw-jpTU.css +0 -1
  82. package/gui/dist/assets/index-cmds12BG.js +0 -67
@@ -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
  }
@@ -80,12 +80,12 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
80
80
  if (config.claudeCode?.desktopAutoApply === false) return;
81
81
  if (!config.claudeCode?.desktopProfile) return;
82
82
  const { writeDesktop3pConfig } = await import("../../claude/desktop-3p");
83
- const { visibleNativeSlugs, filterCatalogVisibleModels } = await import("../../codex/catalog");
83
+ const { filterCatalogVisibleModels, desktopVisibleNativeSlugs } = await import("../../codex/catalog");
84
84
  const allModels = await fetchAllModels(config);
85
85
  const routed = filterCatalogVisibleModels(allModels, config).map(m => ({ provider: m.provider, id: m.id, contextWindow: m.contextWindow }));
86
86
  const result = writeDesktop3pConfig(
87
87
  config.port ?? 10100,
88
- [...visibleNativeSlugs(config)],
88
+ [...desktopVisibleNativeSlugs(config)],
89
89
  routed,
90
90
  config.apiKeys?.[0]?.key,
91
91
  "static",
@@ -104,22 +104,43 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
104
104
  // itself never writes config — this endpoint is the only server-side mutation
105
105
  // surface for the flag.
106
106
  if (url.pathname === "/api/v2" && req.method === "GET") {
107
- const { isMultiAgentV2Enabled, hasAgentsMaxThreads, getLogicalMaxThreads } = await import("../../codex/features");
107
+ const {
108
+ isMultiAgentV2Enabled, hasAgentsMaxThreads, getLogicalMaxThreads,
109
+ getAgentsEnabled, getAgentsMaxDepth, getSubagentDeveloperInstructions,
110
+ } = await import("../../codex/features");
108
111
  const enabled = isMultiAgentV2Enabled();
109
112
  return jsonResponse({
110
113
  enabled,
111
114
  agentsMaxThreadsConflict: enabled && hasAgentsMaxThreads(),
112
115
  maxConcurrentThreadsPerSession: getLogicalMaxThreads(),
113
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,
114
123
  });
115
124
  }
116
125
  if (url.pathname === "/api/v2" && req.method === "PUT") {
117
- 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
+ };
118
134
  try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
119
135
  const wantsFlag = body.enabled !== undefined;
120
136
  const wantsThreads = body.maxConcurrentThreadsPerSession !== undefined;
121
137
  const wantsMode = body.multiAgentMode !== undefined;
122
- 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
+ }
123
144
  if (wantsFlag && typeof body.enabled !== "boolean") return jsonResponse({ error: "body.enabled must be a boolean" }, 400);
124
145
  if (wantsMode && body.multiAgentMode !== "v1" && body.multiAgentMode !== "default" && body.multiAgentMode !== "v2") {
125
146
  return jsonResponse({ error: "body.multiAgentMode must be 'v1', 'default', or 'v2'" }, 400);
@@ -127,12 +148,31 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
127
148
  if (wantsThreads && (typeof body.maxConcurrentThreadsPerSession !== "number" || !Number.isInteger(body.maxConcurrentThreadsPerSession) || body.maxConcurrentThreadsPerSession < 1)) {
128
149
  return jsonResponse({ error: "body.maxConcurrentThreadsPerSession must be an integer >= 1" }, 400);
129
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
+ }
130
166
  const mode = wantsMode ? body.multiAgentMode as "v1" | "default" | "v2" : undefined;
131
167
  const modeFlag = mode === "v2" ? true : mode === "v1" ? false : undefined;
132
168
  if (wantsFlag && modeFlag !== undefined && body.enabled !== modeFlag) {
133
169
  return jsonResponse({ error: `body.enabled conflicts with multiAgentMode '${mode}'` }, 400);
134
170
  }
135
- 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");
136
176
  const warnings: string[] = [];
137
177
  const requestedFlag = wantsFlag ? body.enabled as boolean : modeFlag;
138
178
  if (requestedFlag !== undefined || wantsThreads) {
@@ -159,6 +199,36 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
159
199
  saveConfigPreservingClaudeCode(config);
160
200
  warnings.push(`Multi-agent mode set to '${mode}'. Applies to new sessions.`);
161
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
+ }
162
232
  await refreshCodexCatalogBestEffort();
163
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.");
164
234
  const enabled = isMultiAgentV2Enabled();
@@ -168,6 +238,10 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
168
238
  agentsMaxThreadsConflict: enabled && hasAgentsMaxThreads(),
169
239
  maxConcurrentThreadsPerSession: getLogicalMaxThreads(),
170
240
  multiAgentMode: config.multiAgentMode ?? "default",
241
+ agentsEnabled: getAgentsEnabled(),
242
+ agentsMaxDepth: getAgentsMaxDepth(),
243
+ subagentDeveloperInstructions: getSubagentDeveloperInstructions(),
244
+ agentsMaxDepthAppliesWhenV2Disabled: !enabled,
171
245
  warnings,
172
246
  });
173
247
  }
@@ -533,7 +607,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
533
607
  config.claudeCode = { ...(config.claudeCode ?? {}), desktopProfile: state.profile };
534
608
  saveConfigPreservingClaudeCode(config);
535
609
  const { writeDesktop3pConfig } = await import("../../claude/desktop-3p");
536
- const { visibleNativeSlugs } = await import("../../codex/catalog");
610
+ const { desktopVisibleNativeSlugs } = await import("../../codex/catalog");
537
611
  const routed = state.models
538
612
  .filter(model => model.available && !model.route.startsWith("native/"))
539
613
  .map(model => {
@@ -542,7 +616,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
542
616
  });
543
617
  const result = writeDesktop3pConfig(
544
618
  Number(url.port) || config.port,
545
- [...visibleNativeSlugs(config)],
619
+ [...desktopVisibleNativeSlugs(config)],
546
620
  routed,
547
621
  config.apiKeys?.[0]?.key,
548
622
  "static",
@@ -24,6 +24,7 @@ import {
24
24
  import { removeCredential } from "../../oauth/store";
25
25
  import { providerDestinationResolvedError } from "../../lib/destination-policy";
26
26
  import { isStreamMode } from "../../lib/bun-stream-caps";
27
+ import { shadowSourceModels } from "../../lib/shadow-call";
27
28
  import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers";
28
29
  import { deriveProviderPresets } from "../../providers/derive";
29
30
  import { providerCodexAccountMode } from "../../providers/registry";
@@ -110,6 +111,11 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
110
111
  );
111
112
  }
112
113
  return jsonResponse({
114
+ // The dashboard renders request-log timestamps. Without this it formats them in the
115
+ // BROWSER's zone, so a KST proxy viewed from a UTC browser reports every request nine
116
+ // hours off (#725). Carried on settings rather than /api/logs because that route's
117
+ // array response has four consumers that would have to change with it.
118
+ timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
113
119
  codexAutoStart: codexAutoStartEnabled(config),
114
120
  port: config.port,
115
121
  hostname: config.hostname ?? "127.0.0.1",
@@ -140,16 +146,20 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
140
146
  }
141
147
 
142
148
  if (url.pathname === "/api/startup-action" && req.method === "POST") {
143
- let body: { action?: unknown };
149
+ let body: { action?: unknown; repair?: unknown };
144
150
  try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
145
151
  if (!body || !["install-service", "install-shim"].includes(String(body.action))) {
146
152
  return jsonResponse({ error: "action must be install-service or install-shim" }, 400);
147
153
  }
154
+ if (body.repair !== undefined && typeof body.repair !== "boolean") {
155
+ return jsonResponse({ error: "repair must be a boolean when provided" }, 400);
156
+ }
148
157
  try {
149
158
  const action = body.action as StartupInstallAction;
150
- const result = await (deps.runStartupInstallAction ?? runStartupInstallAction)(action);
159
+ const repair = body.repair === true;
160
+ const result = await (deps.runStartupInstallAction ?? runStartupInstallAction)(action, { repair });
151
161
  invalidateStartupHealthCache();
152
- return jsonResponse({ ok: true, action, message: result.message });
162
+ return jsonResponse({ ok: true, action, repair, message: result.message });
153
163
  } catch (error) {
154
164
  return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 500);
155
165
  }
@@ -227,8 +237,6 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
227
237
  const { syncModelsToCodex } = await import("../../codex/sync");
228
238
  const { attachStaleAppServerHint } = await import("../../codex/app-server-processes");
229
239
  const result = await syncModelsToCodex(undefined, config, null);
230
- // Hint only after a real catalog/cache write — never enumerate processes here
231
- // (WMIC/PowerShell would block Bun's event loop on every dashboard sync).
232
240
  return jsonResponse({
233
241
  ...attachStaleAppServerHint(result),
234
242
  ...(result.ok ? {} : { error: result.message }),
@@ -352,7 +360,11 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
352
360
 
353
361
  if (url.pathname === "/api/shadow-call-settings" && req.method === "GET") {
354
362
  const sci = config.shadowCallIntercept ?? {};
355
- return jsonResponse({ enabled: sci.enabled === true, model: sci.model ?? "" });
363
+ return jsonResponse({
364
+ enabled: sci.enabled === true,
365
+ model: sci.model ?? "",
366
+ sourceModels: shadowSourceModels(sci.sourceModels),
367
+ });
356
368
  }
357
369
 
358
370
  if (url.pathname === "/api/shadow-call-settings" && req.method === "PUT") {
@@ -374,7 +386,12 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
374
386
  }
375
387
  saveConfigPreservingClaudeCode(config);
376
388
  const sci = config.shadowCallIntercept;
377
- return jsonResponse({ ok: true, enabled: sci.enabled === true, model: sci.model ?? "" });
389
+ return jsonResponse({
390
+ ok: true,
391
+ enabled: sci.enabled === true,
392
+ model: sci.model ?? "",
393
+ sourceModels: shadowSourceModels(sci.sourceModels),
394
+ });
378
395
  }
379
396
  return null;
380
397
  }
@@ -4,10 +4,20 @@ import type { StartupInstallAction } from "../startup-action-control";
4
4
  export interface ManagementApiDeps {
5
5
  toggleCodexMultiAgentV2?: (enabled: boolean) => void;
6
6
  refreshCodexCatalog?: () => Promise<void>;
7
+ /**
8
+ * Persistence seam for route-level tests. Production leaves this unset and uses
9
+ * `saveConfigPreservingClaudeCode`; tests that pass an in-memory fixture config
10
+ * MUST inject a no-op/spy so the fixture can never overwrite the user's real
11
+ * OPENCODEX_HOME (incident: devlog 260730.../070).
12
+ */
13
+ saveConfigPreservingClaudeCode?: (config: OcxConfig) => void;
7
14
  clearThreadAccountMap?: () => void;
8
15
  clearProviderQuotaCache?: () => void;
9
16
  primeCodexPoolQuotas?: (config: OcxConfig, reason: string) => Promise<void> | void;
10
- runStartupInstallAction?: (action: StartupInstallAction) => Promise<{ message: string }>;
17
+ runStartupInstallAction?: (
18
+ action: StartupInstallAction,
19
+ options?: { repair?: boolean },
20
+ ) => Promise<{ message: string }>;
11
21
  }
12
22
 
13
23
 
@@ -1,5 +1,34 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { readFileSync } from "node:fs";
3
+
4
+ /**
5
+ * Codex parses a catalog entry's `input_modalities` as a closed enum, and one out-of-enum
6
+ * value makes it reject the ENTIRE catalog file — plugins, apps and MCP servers all stop
7
+ * loading over one model's metadata (#759).
8
+ *
9
+ * The catalog writer normalizes on the way out, but a rejected value stored here would still
10
+ * be handed back to the GUI and CLI as if it were real, and the offline `ocx models add` path
11
+ * already refuses it. Validate at ingress so all three paths agree.
12
+ */
13
+ const ALLOWED_INPUT_MODALITIES = new Set(["text", "image", "audio"]);
14
+
15
+ function readInputModalities(raw: unknown): { values?: string[]; error?: string } {
16
+ if (raw === undefined) return {};
17
+ if (!Array.isArray(raw)) return { error: "inputModalities must be an array" };
18
+ // Reject non-strings rather than filtering them out. Dropping them silently accepted a
19
+ // malformed POST and, worse, let a PUT of `[42]` clear the stored modalities while
20
+ // answering 200 — the opposite of the contract this validator exists to state. An empty
21
+ // array stays valid: that is how `ocx models edit --modalities -` clears the field.
22
+ const rejected: string[] = [];
23
+ for (const value of raw) {
24
+ if (typeof value !== "string") return { error: "inputModalities must contain only strings" };
25
+ if (!ALLOWED_INPUT_MODALITIES.has(value)) rejected.push(value);
26
+ }
27
+ if (rejected.length > 0) {
28
+ return { error: `unsupported input modality: ${rejected.join(", ")} (allowed: text, image, audio)` };
29
+ }
30
+ return { values: raw as string[] };
31
+ }
3
32
  import type { CatalogModel } from "../../codex/catalog";
4
33
  import { catalogModelSlug, disabledNativeSlugs, invalidateCodexModelsCache, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog";
5
34
  import { getProviderLiveModelCount } from "../../codex/model-cache";
@@ -54,7 +83,7 @@ import { drainAndShutdown } from "../lifecycle";
54
83
  import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "../request-log";
55
84
  import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../../usage/cost";
56
85
  import type { PersistedUsageAttempt } from "../../usage/log";
57
- import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors";
86
+ import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO, corsHeaders } from "../auth-cors";
58
87
  import { applySystemEnvToggle } from "../system-env";
59
88
 
60
89
  import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels } from "./shared";
@@ -63,6 +92,25 @@ import type { ManagementContext } from "./context";
63
92
 
64
93
  export async function handleModelRoutes(ctx: ManagementContext): Promise<Response | null> {
65
94
  const { req, url, config, deps, refreshCodexCatalogBestEffort, syncClaudeAgentDefsBestEffort } = ctx;
95
+ // A handler persists the exact config object passed in. Production defaults to
96
+ // the real store; tests that pass an in-memory fixture inject a no-op/spy. Do not
97
+ // bypass this seam with a dynamic config import — doing so replaced a user's
98
+ // ~/.opencodex/config.json with the `existing-uuid` test fixture.
99
+ const persistConfig = deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode;
100
+
101
+ if (url.pathname === "/api/catalog" && req.method === "GET") {
102
+ const { readCatalog, readCodexCatalogPath } = await import("../../codex/catalog");
103
+ const catalog = readCatalog(readCodexCatalogPath());
104
+ if (!catalog) return jsonResponse({ error: "catalog not found" }, 404, req, config);
105
+ const headers: Record<string, string> = {
106
+ "Content-Type": "application/json",
107
+ ...corsHeaders(req, config),
108
+ };
109
+ const { loadPersistedCodexRuntime } = await import("../../codex/runtime");
110
+ const version = loadPersistedCodexRuntime()?.selectedVersion;
111
+ if (version) headers["x-opencodex-codex-version"] = version;
112
+ return new Response(JSON.stringify(catalog), { status: 200, headers });
113
+ }
66
114
 
67
115
  if (url.pathname === "/api/models" && req.method === "GET") {
68
116
  const models = await fetchAllModels(config);
@@ -123,8 +171,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
123
171
  try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
124
172
  const disabled = Array.isArray(body.models) ? body.models.filter((m): m is string => typeof m === "string") : [];
125
173
  config.disabledModels = disabled;
126
- const { saveConfigPreservingClaudeCode: save } = await import("../../config");
127
- save(config);
174
+ persistConfig(config);
128
175
  await refreshCodexCatalogBestEffort();
129
176
  return jsonResponse({ ok: true, disabled });
130
177
  }
@@ -223,7 +270,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
223
270
  }
224
271
 
225
272
  config.disabledModels = disabled;
226
- saveConfigPreservingClaudeCode(config);
273
+ persistConfig(config);
227
274
  await refreshCodexCatalogBestEffort();
228
275
  return jsonResponse({ ok: true, scope, provider, enabled: body.enabled, disabled });
229
276
  }
@@ -244,7 +291,9 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
244
291
  const displayName = typeof body.displayName === "string" && body.displayName.trim() ? body.displayName.trim() : undefined;
245
292
  if (displayName?.includes("/")) return jsonResponse({ error: "displayName must not contain /" }, 400);
246
293
  const contextWindow = typeof body.contextWindow === "number" && body.contextWindow > 0 ? Math.floor(body.contextWindow) : undefined;
247
- const inputModalities = Array.isArray(body.inputModalities) ? body.inputModalities.filter((m): m is string => typeof m === "string") : undefined;
294
+ const modalities = readInputModalities(body.inputModalities);
295
+ if (modalities.error) return jsonResponse({ error: modalities.error }, 400);
296
+ const inputModalities = modalities.values;
248
297
  const existing = config.customModels ?? [];
249
298
  const newSlug = routedSlug(provider, modelId);
250
299
  if (existing.some(cm => routedSlug(cm.provider, cm.modelId) === newSlug)) {
@@ -260,8 +309,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
260
309
  addedAt: new Date().toISOString(),
261
310
  };
262
311
  config.customModels = [...existing, entry];
263
- const { saveConfigPreservingClaudeCode: save } = await import("../../config");
264
- save(config);
312
+ persistConfig(config);
265
313
  await refreshCodexCatalogBestEffort();
266
314
  return jsonResponse(entry, 201);
267
315
  }
@@ -289,7 +337,9 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
289
337
  cm.contextWindow = typeof body.contextWindow === "number" && body.contextWindow > 0 ? Math.floor(body.contextWindow) : undefined;
290
338
  }
291
339
  if (body.inputModalities !== undefined) {
292
- cm.inputModalities = Array.isArray(body.inputModalities) ? body.inputModalities.filter((m): m is string => typeof m === "string") : undefined;
340
+ const edited = readInputModalities(body.inputModalities);
341
+ if (edited.error) return jsonResponse({ error: edited.error }, 400);
342
+ cm.inputModalities = edited.values && edited.values.length > 0 ? edited.values : undefined;
293
343
  }
294
344
  const updatedSlug = routedSlug(cm.provider, cm.modelId);
295
345
  if (list.some((other, i) => i !== idx && routedSlug(other.provider, other.modelId) === updatedSlug)) {
@@ -297,8 +347,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
297
347
  }
298
348
  list[idx] = cm;
299
349
  config.customModels = list;
300
- const { saveConfigPreservingClaudeCode: save } = await import("../../config");
301
- save(config);
350
+ persistConfig(config);
302
351
  await refreshCodexCatalogBestEffort();
303
352
  return jsonResponse(cm);
304
353
  }
@@ -312,8 +361,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
312
361
  if (idx === -1) return jsonResponse({ error: "not found" }, 404);
313
362
  list.splice(idx, 1);
314
363
  config.customModels = list.length > 0 ? list : undefined;
315
- const { saveConfigPreservingClaudeCode: save } = await import("../../config");
316
- save(config);
364
+ persistConfig(config);
317
365
  await refreshCodexCatalogBestEffort();
318
366
  return jsonResponse({ ok: true });
319
367
  }
@@ -349,8 +397,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
349
397
  // Empty list clears the allowlist (provider reverts to exposing all models).
350
398
  if (models.length > 0) config.providers[provider].selectedModels = models;
351
399
  else delete config.providers[provider].selectedModels;
352
- const { saveConfigPreservingClaudeCode: save } = await import("../../config");
353
- save(config);
400
+ persistConfig(config);
354
401
  await refreshCodexCatalogBestEffort();
355
402
  return jsonResponse({ ok: true, provider, selected: models });
356
403
  }
@@ -2,7 +2,6 @@ import { randomUUID } from "node:crypto";
2
2
  import { readFileSync } from "node:fs";
3
3
  import type { CatalogModel } from "../../codex/catalog";
4
4
  import { catalogModelSlug, invalidateCodexModelsCache, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog";
5
- import { providerModelsListFromProbeResponse } from "../../codex/catalog/provider-fetch";
6
5
  import {
7
6
  DEFAULT_SUBAGENT_MODELS,
8
7
  codexAutoStartEnabled,
@@ -24,10 +23,16 @@ import {
24
23
  } from "../../oauth";
25
24
  import { removeCredential } from "../../oauth/store";
26
25
  import { providerDestinationResolvedError } from "../../lib/destination-policy";
27
- import { providerOutboundGet, providerRedirectError } from "../../lib/provider-outbound";
26
+ import { ProviderOutboundPolicyError, providerOutboundGet, providerRedirectError } from "../../lib/provider-outbound";
28
27
  import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers";
29
28
  import { deriveProviderPresets } from "../../providers/derive";
30
29
  import { providerCodexAccountMode } from "../../providers/registry";
30
+ import {
31
+ extractModelEnvelopeRows,
32
+ extractProviderModelItems,
33
+ readBoundedDiscoveryJson,
34
+ resolveProviderModelDiscovery,
35
+ } from "../../providers/model-discovery";
31
36
  import { routedSlug, slugEquals } from "../../providers/slug-codec";
32
37
  import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota";
33
38
  import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
@@ -339,6 +344,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
339
344
  return jsonResponse({ ok: false, latencyMs: 0, error: "static catalog only — upstream not verified (not logged in)" });
340
345
  }
341
346
  const { url: modelsUrl, headers } = buildModelsRequest(prov, apiKey, name);
347
+ const discovery = resolveProviderModelDiscovery(name, prov);
342
348
  const started = Date.now();
343
349
  try {
344
350
  const res = await providerOutboundGet(name, prov, modelsUrl, {
@@ -355,15 +361,42 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
355
361
  });
356
362
  }
357
363
  if (!res.ok) {
364
+ try {
365
+ void res.body?.cancel().catch(() => undefined);
366
+ } catch {
367
+ // Best-effort release for non-conforming response streams.
368
+ }
358
369
  return jsonResponse({ ok: false, latencyMs, error: `upstream /models returned ${res.status}` });
359
370
  }
360
- const json = await res.json().catch(() => null);
361
- // OpenAI-style { data }, Google { models }, and Together-style top-level arrays (#617).
362
- const list = providerModelsListFromProbeResponse(json);
363
- if (!Array.isArray(list)) {
364
- return jsonResponse({ ok: false, latencyMs, error: "upstream /models returned an unexpected shape" });
371
+ const bounded = await readBoundedDiscoveryJson(res, discovery.maxResponseBytes);
372
+ if (!bounded.ok) {
373
+ return jsonResponse({
374
+ ok: false,
375
+ latencyMs,
376
+ error: bounded.reason === "response_too_large"
377
+ ? `upstream /models exceeded the ${discovery.maxResponseBytes}-byte response limit`
378
+ : "upstream /models returned invalid JSON",
379
+ });
380
+ }
381
+ // OpenAI-style lists (and Together top-level arrays) use the same validation/dedupe/filter
382
+ // as catalog discovery. Google's /v1beta/models uses `models[].name` and remains a
383
+ // connectivity-only count because it is not an authoritative catalog source.
384
+ const record = bounded.value !== null && typeof bounded.value === "object" && !Array.isArray(bounded.value)
385
+ ? bounded.value as Record<string, unknown>
386
+ : undefined;
387
+ const extracted = Array.isArray(bounded.value) || Array.isArray(record?.data)
388
+ ? extractProviderModelItems(bounded.value, discovery)
389
+ : extractModelEnvelopeRows(bounded.value, discovery.maxModels, ["models"]);
390
+ if (!extracted.ok) {
391
+ return jsonResponse({
392
+ ok: false,
393
+ latencyMs,
394
+ error: extracted.reason === "too_many_models"
395
+ ? `upstream /models exceeded the ${discovery.maxModels}-row model limit`
396
+ : "upstream /models returned an unexpected shape",
397
+ });
365
398
  }
366
- const models = list.length;
399
+ const models = "items" in extracted ? extracted.items.length : extracted.rows.length;
367
400
  return jsonResponse({
368
401
  ok: true,
369
402
  latencyMs,
@@ -374,7 +407,9 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
374
407
  return jsonResponse({
375
408
  ok: false,
376
409
  latencyMs: Date.now() - started,
377
- error: err instanceof Error ? err.message : "Connection test failed",
410
+ error: err instanceof ProviderOutboundPolicyError
411
+ ? `upstream /models blocked by destination policy: ${err.message}`
412
+ : err instanceof Error ? err.message : "Connection test failed",
378
413
  });
379
414
  }
380
415
  }