@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
@@ -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";
@@ -140,16 +141,20 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
140
141
  }
141
142
 
142
143
  if (url.pathname === "/api/startup-action" && req.method === "POST") {
143
- let body: { action?: unknown };
144
+ let body: { action?: unknown; repair?: unknown };
144
145
  try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
145
146
  if (!body || !["install-service", "install-shim"].includes(String(body.action))) {
146
147
  return jsonResponse({ error: "action must be install-service or install-shim" }, 400);
147
148
  }
149
+ if (body.repair !== undefined && typeof body.repair !== "boolean") {
150
+ return jsonResponse({ error: "repair must be a boolean when provided" }, 400);
151
+ }
148
152
  try {
149
153
  const action = body.action as StartupInstallAction;
150
- const result = await (deps.runStartupInstallAction ?? runStartupInstallAction)(action);
154
+ const repair = body.repair === true;
155
+ const result = await (deps.runStartupInstallAction ?? runStartupInstallAction)(action, { repair });
151
156
  invalidateStartupHealthCache();
152
- return jsonResponse({ ok: true, action, message: result.message });
157
+ return jsonResponse({ ok: true, action, repair, message: result.message });
153
158
  } catch (error) {
154
159
  return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 500);
155
160
  }
@@ -225,10 +230,11 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
225
230
 
226
231
  if (url.pathname === "/api/sync" && req.method === "POST") {
227
232
  const { syncModelsToCodex } = await import("../../codex/sync");
233
+ const { attachStaleAppServerHint } = await import("../../codex/app-server-processes");
228
234
  const result = await syncModelsToCodex(undefined, config, null);
229
235
  return jsonResponse({
230
- ...result,
231
- staleAppServerHint: "If Codex App still shows an older model list, restart its long-lived app-server process after sync.",
236
+ ...attachStaleAppServerHint(result),
237
+ ...(result.ok ? {} : { error: result.message }),
232
238
  }, result.ok ? 200 : 500);
233
239
  }
234
240
 
@@ -349,7 +355,11 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
349
355
 
350
356
  if (url.pathname === "/api/shadow-call-settings" && req.method === "GET") {
351
357
  const sci = config.shadowCallIntercept ?? {};
352
- return jsonResponse({ enabled: sci.enabled === true, model: sci.model ?? "" });
358
+ return jsonResponse({
359
+ enabled: sci.enabled === true,
360
+ model: sci.model ?? "",
361
+ sourceModels: shadowSourceModels(sci.sourceModels),
362
+ });
353
363
  }
354
364
 
355
365
  if (url.pathname === "/api/shadow-call-settings" && req.method === "PUT") {
@@ -371,7 +381,12 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
371
381
  }
372
382
  saveConfigPreservingClaudeCode(config);
373
383
  const sci = config.shadowCallIntercept;
374
- return jsonResponse({ ok: true, enabled: sci.enabled === true, model: sci.model ?? "" });
384
+ return jsonResponse({
385
+ ok: true,
386
+ enabled: sci.enabled === true,
387
+ model: sci.model ?? "",
388
+ sourceModels: shadowSourceModels(sci.sourceModels),
389
+ });
375
390
  }
376
391
  return null;
377
392
  }
@@ -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
 
@@ -34,7 +34,19 @@ import { primeCodexPoolQuotas } from "../../codex/auth-api";
34
34
  import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap";
35
35
  import { resolveCodexHomeDir } from "../../codex/home";
36
36
  import { scanStorage } from "../../storage/scanner";
37
- import { executeArchivedCleanup, pickWireCleanupTestHooks, previewArchivedCleanup, type CleanupMode } from "../../storage/cleanup";
37
+ import { executeArchivedCleanup, listTrashEntries, pickWireCleanupTestHooks, previewArchivedCleanup, type CleanupMode, type RestoreErrorCode } from "../../storage/cleanup";
38
+ import { runArchivedCleanupJob } from "../../storage/cleanup-job";
39
+ import { getRestoreTrashTestStreamResponse, runRestoreTrashEntryJob } from "../../storage/restore-job";
40
+ import {
41
+ normalizeStorageCleanupPolicy,
42
+ parseStorageCleanupPolicyInput,
43
+ writeStorageCleanupPolicyToConfig,
44
+ } from "../../storage/policy";
45
+ import {
46
+ getStorageCleanupPolicyJobState,
47
+ getStorageCleanupPolicyTestStreamResponse,
48
+ requestStorageCleanupPolicyRun,
49
+ } from "../../storage/policy-job";
38
50
  import {
39
51
  currentUsageLogRevision,
40
52
  readUsageSnapshotForManagement,
@@ -278,7 +290,7 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
278
290
  ? pickWireCleanupTestHooks(body._test)
279
291
  : undefined;
280
292
  try {
281
- const result = executeArchivedCleanup({
293
+ const result = await runArchivedCleanupJob({
282
294
  percent,
283
295
  mode: mode as CleanupMode,
284
296
  digest,
@@ -286,14 +298,20 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
286
298
  });
287
299
  if (!result.ok) {
288
300
  const status =
289
- result.error === "codex_busy" || result.error === "stale_preview" || result.error === "referenced_history"
301
+ result.error === "codex_busy"
302
+ || result.error === "stale_preview"
303
+ || result.error === "referenced_history"
304
+ || result.error === "storage_mutation_busy"
305
+ || result.error === "restore_pending_overlap"
290
306
  ? 409
291
307
  : result.error === "invalid_mode" || result.error === "invalid_digest"
292
308
  ? 400
293
309
  : 500;
294
310
  const messages: Record<string, string> = {
295
311
  codex_busy: "Codex is using state.sqlite — try again after quitting Codex.",
312
+ storage_mutation_busy: "Another storage cleanup or restore is in progress — try again shortly.",
296
313
  stale_preview: "Archived files changed since preview — run Preview again.",
314
+ restore_pending_overlap: "Selected archives overlap an incomplete trash restore — finish or retry restore first.",
297
315
  referenced_history: "Selected archives are still referenced by forked or paginated history.",
298
316
  invalid_digest: "Preview digest is missing or invalid.",
299
317
  invalid_mode: "mode must be quarantine or permanent.",
@@ -326,5 +344,151 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
326
344
  }
327
345
  }
328
346
 
347
+ if (url.pathname === "/api/storage/trash" && req.method === "GET") {
348
+ try {
349
+ const entries = listTrashEntries();
350
+ return jsonResponse({
351
+ entries: entries.map(({ id, epoch, fileCount, bytes, quarantinedAt, mode }) => ({
352
+ id,
353
+ epoch,
354
+ fileCount,
355
+ bytes,
356
+ ...(quarantinedAt !== undefined ? { quarantinedAt } : {}),
357
+ ...(mode ? { mode } : {}),
358
+ })),
359
+ });
360
+ } catch {
361
+ return jsonResponse({ error: "trash_list_failed", entries: [] }, 500);
362
+ }
363
+ }
364
+
365
+ if (url.pathname === "/api/storage/trash/restore/test-stream" && req.method === "GET") {
366
+ if (process.env.OPENCODEX_CLEANUP_TEST_HOOKS === "1") {
367
+ const stream = getRestoreTrashTestStreamResponse();
368
+ if (stream) return stream;
369
+ }
370
+ // Always answer this test-only path — never fall through to the GUI SPA (200 HTML).
371
+ return jsonResponse({ error: "not_available" }, 404);
372
+ }
373
+
374
+ if (url.pathname === "/api/storage/trash/restore" && req.method === "POST") {
375
+ let body: { id?: unknown };
376
+ try { body = await req.json(); } catch { return jsonResponse({ error: "invalid_json" }, 400); }
377
+ const id = typeof body?.id === "string" ? body.id : "";
378
+ if (!id.trim()) {
379
+ return jsonResponse({ error: "invalid_trash", message: "Trash entry id is required." }, 400);
380
+ }
381
+ try {
382
+ const result = await runRestoreTrashEntryJob(id);
383
+ if (!result.ok) {
384
+ const status =
385
+ result.error === "codex_busy"
386
+ || result.error === "dest_exists"
387
+ || result.error === "storage_mutation_busy"
388
+ ? 409
389
+ : result.error === "missing_trash"
390
+ ? 404
391
+ : result.error === "invalid_trash"
392
+ ? 400
393
+ : 500;
394
+ const messages: Record<RestoreErrorCode, string> = {
395
+ invalid_trash: "Trash entry id is missing or invalid.",
396
+ missing_trash: "Trash entry was not found.",
397
+ codex_busy: "Codex is using state.sqlite — try again after quitting Codex.",
398
+ storage_mutation_busy: "Another storage cleanup or restore is in progress — try again shortly.",
399
+ dest_exists: "Restore destination already exists — remove or rename the archived file and retry.",
400
+ fs_failed: "Filesystem restore failed. Some files may already be restored — check archived_sessions and .trash.",
401
+ db_reconcile_failed: "Could not restore Codex state database rows.",
402
+ restore_failed: "Restore failed.",
403
+ restore_worker_timeout: "Restore took too long (over 10 minutes) and was stopped.",
404
+ restore_worker_aborted: "Restore was cancelled during shutdown.",
405
+ restore_worker_failed: "Restore worker crashed or failed unexpectedly.",
406
+ };
407
+ const errorCode = result.error ?? "restore_failed";
408
+ const baseMessage = messages[errorCode] ?? messages.restore_failed;
409
+ const message =
410
+ result.message && errorCode === "restore_worker_failed"
411
+ ? `${baseMessage} (${result.message})`
412
+ : baseMessage;
413
+ return jsonResponse({
414
+ ok: false,
415
+ error: errorCode,
416
+ message,
417
+ count: result.count,
418
+ bytes: result.bytes,
419
+ restoredPaths: result.restoredPaths,
420
+ ...(result.trashDir ? { trashDir: result.trashDir } : {}),
421
+ }, status);
422
+ }
423
+ return jsonResponse({
424
+ ok: true,
425
+ trashDir: result.trashDir,
426
+ count: result.count,
427
+ bytes: result.bytes,
428
+ restoredPaths: result.restoredPaths,
429
+ });
430
+ } catch {
431
+ return jsonResponse({
432
+ ok: false,
433
+ error: "restore_failed",
434
+ message: "Restore failed.",
435
+ }, 500);
436
+ }
437
+ }
438
+
439
+ if (url.pathname === "/api/storage/cleanup-policy/test-stream" && req.method === "GET") {
440
+ const stream = getStorageCleanupPolicyTestStreamResponse();
441
+ if (stream) return stream;
442
+ // Production: hook is off. Return an explicit JSON 404 — do not fall through to the GUI.
443
+ return jsonResponse({ error: "not_found" }, 404);
444
+ }
445
+
446
+ if (url.pathname === "/api/storage/cleanup-policy" && req.method === "GET") {
447
+ const policy = normalizeStorageCleanupPolicy(config.storageCleanupPolicy);
448
+ return jsonResponse({
449
+ ...policy,
450
+ job: getStorageCleanupPolicyJobState(),
451
+ });
452
+ }
453
+
454
+ if (url.pathname === "/api/storage/cleanup-policy" && req.method === "PUT") {
455
+ let raw: unknown;
456
+ try { raw = await req.json(); } catch { return jsonResponse({ error: "invalid_json" }, 400); }
457
+ const previous = normalizeStorageCleanupPolicy(config.storageCleanupPolicy);
458
+ const parsed = parseStorageCleanupPolicyInput(raw, previous);
459
+ if (!parsed.ok) return jsonResponse({ error: parsed.error }, 400);
460
+ // Never enable implicitly: if client omitted enabled, keep previous (default false).
461
+ const body = raw as Record<string, unknown>;
462
+ if (body.enabled === undefined) parsed.policy.enabled = previous.enabled;
463
+ const saved = writeStorageCleanupPolicyToConfig(parsed.policy);
464
+ config.storageCleanupPolicy = saved;
465
+ return jsonResponse({ ok: true, policy: saved, job: getStorageCleanupPolicyJobState() });
466
+ }
467
+
468
+ if (url.pathname === "/api/storage/cleanup-policy/run" && req.method === "POST") {
469
+ try {
470
+ const accepted = requestStorageCleanupPolicyRun({ reason: "manual", force: true });
471
+ if (!accepted.accepted) {
472
+ return jsonResponse({
473
+ ok: false,
474
+ started: false,
475
+ error: "already_running",
476
+ message: "A cleanup policy run is already in progress.",
477
+ job: accepted.state,
478
+ policy: normalizeStorageCleanupPolicy(config.storageCleanupPolicy),
479
+ }, 409);
480
+ }
481
+ // Return promptly — clients poll GET for skip/defer/success/error outcomes.
482
+ return jsonResponse({
483
+ ok: true,
484
+ started: true,
485
+ job: accepted.state,
486
+ policy: normalizeStorageCleanupPolicy(config.storageCleanupPolicy),
487
+ });
488
+ } catch {
489
+ return jsonResponse({ ok: false, error: "cleanup_failed" }, 500);
490
+ }
491
+ }
492
+
329
493
  return null;
330
494
  }
@@ -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";
@@ -63,6 +92,11 @@ 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;
66
100
 
67
101
  if (url.pathname === "/api/models" && req.method === "GET") {
68
102
  const models = await fetchAllModels(config);
@@ -123,8 +157,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
123
157
  try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
124
158
  const disabled = Array.isArray(body.models) ? body.models.filter((m): m is string => typeof m === "string") : [];
125
159
  config.disabledModels = disabled;
126
- const { saveConfigPreservingClaudeCode: save } = await import("../../config");
127
- save(config);
160
+ persistConfig(config);
128
161
  await refreshCodexCatalogBestEffort();
129
162
  return jsonResponse({ ok: true, disabled });
130
163
  }
@@ -223,7 +256,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
223
256
  }
224
257
 
225
258
  config.disabledModels = disabled;
226
- saveConfigPreservingClaudeCode(config);
259
+ persistConfig(config);
227
260
  await refreshCodexCatalogBestEffort();
228
261
  return jsonResponse({ ok: true, scope, provider, enabled: body.enabled, disabled });
229
262
  }
@@ -244,7 +277,9 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
244
277
  const displayName = typeof body.displayName === "string" && body.displayName.trim() ? body.displayName.trim() : undefined;
245
278
  if (displayName?.includes("/")) return jsonResponse({ error: "displayName must not contain /" }, 400);
246
279
  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;
280
+ const modalities = readInputModalities(body.inputModalities);
281
+ if (modalities.error) return jsonResponse({ error: modalities.error }, 400);
282
+ const inputModalities = modalities.values;
248
283
  const existing = config.customModels ?? [];
249
284
  const newSlug = routedSlug(provider, modelId);
250
285
  if (existing.some(cm => routedSlug(cm.provider, cm.modelId) === newSlug)) {
@@ -260,8 +295,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
260
295
  addedAt: new Date().toISOString(),
261
296
  };
262
297
  config.customModels = [...existing, entry];
263
- const { saveConfigPreservingClaudeCode: save } = await import("../../config");
264
- save(config);
298
+ persistConfig(config);
265
299
  await refreshCodexCatalogBestEffort();
266
300
  return jsonResponse(entry, 201);
267
301
  }
@@ -289,7 +323,9 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
289
323
  cm.contextWindow = typeof body.contextWindow === "number" && body.contextWindow > 0 ? Math.floor(body.contextWindow) : undefined;
290
324
  }
291
325
  if (body.inputModalities !== undefined) {
292
- cm.inputModalities = Array.isArray(body.inputModalities) ? body.inputModalities.filter((m): m is string => typeof m === "string") : undefined;
326
+ const edited = readInputModalities(body.inputModalities);
327
+ if (edited.error) return jsonResponse({ error: edited.error }, 400);
328
+ cm.inputModalities = edited.values && edited.values.length > 0 ? edited.values : undefined;
293
329
  }
294
330
  const updatedSlug = routedSlug(cm.provider, cm.modelId);
295
331
  if (list.some((other, i) => i !== idx && routedSlug(other.provider, other.modelId) === updatedSlug)) {
@@ -297,8 +333,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
297
333
  }
298
334
  list[idx] = cm;
299
335
  config.customModels = list;
300
- const { saveConfigPreservingClaudeCode: save } = await import("../../config");
301
- save(config);
336
+ persistConfig(config);
302
337
  await refreshCodexCatalogBestEffort();
303
338
  return jsonResponse(cm);
304
339
  }
@@ -312,8 +347,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
312
347
  if (idx === -1) return jsonResponse({ error: "not found" }, 404);
313
348
  list.splice(idx, 1);
314
349
  config.customModels = list.length > 0 ? list : undefined;
315
- const { saveConfigPreservingClaudeCode: save } = await import("../../config");
316
- save(config);
350
+ persistConfig(config);
317
351
  await refreshCodexCatalogBestEffort();
318
352
  return jsonResponse({ ok: true });
319
353
  }
@@ -349,8 +383,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
349
383
  // Empty list clears the allowlist (provider reverts to exposing all models).
350
384
  if (models.length > 0) config.providers[provider].selectedModels = models;
351
385
  else delete config.providers[provider].selectedModels;
352
- const { saveConfigPreservingClaudeCode: save } = await import("../../config");
353
- save(config);
386
+ persistConfig(config);
354
387
  await refreshCodexCatalogBestEffort();
355
388
  return jsonResponse({ ok: true, provider, selected: models });
356
389
  }