@bitkyc08/opencodex 2.7.43 → 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 (73) hide show
  1. package/bin/ocx.mjs +34 -8
  2. package/gui/dist/assets/index-BDjpkcRN.js +67 -0
  3. package/gui/dist/assets/index-BHsKRFh9.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/cursor/discovery.ts +4 -1
  7. package/src/adapters/cursor/effort-map.ts +3 -0
  8. package/src/adapters/kiro.ts +15 -1
  9. package/src/claude/alias.ts +94 -14
  10. package/src/claude/outbound.ts +6 -3
  11. package/src/cli/catalog-prewarm.ts +24 -0
  12. package/src/cli/claude.ts +32 -7
  13. package/src/cli/doctor.ts +48 -1
  14. package/src/cli/index.ts +5 -0
  15. package/src/cli/interactive-confirm.ts +5 -1
  16. package/src/cli/star-prompt.ts +26 -4
  17. package/src/cli/v2.ts +10 -1
  18. package/src/codex/account-store.ts +2 -0
  19. package/src/codex/catalog/bundled.ts +9 -2
  20. package/src/codex/catalog/parsing.ts +26 -1
  21. package/src/codex/catalog/provider-fetch.ts +240 -82
  22. package/src/codex/catalog/sync.ts +27 -5
  23. package/src/codex/catalog.ts +1 -1
  24. package/src/codex/features.ts +524 -5
  25. package/src/codex/quota.ts +77 -2
  26. package/src/codex/runtime.ts +10 -1
  27. package/src/config.ts +8 -0
  28. package/src/generated/jawcode-model-metadata.ts +12 -12
  29. package/src/github/star-state.ts +191 -0
  30. package/src/lib/bun-binary-validator.d.mts +3 -0
  31. package/src/lib/bun-binary-validator.mjs +18 -0
  32. package/src/lib/bun-runtime.ts +6 -20
  33. package/src/lib/destination-policy.ts +10 -3
  34. package/src/lib/provider-outbound.ts +5 -2
  35. package/src/lib/shadow-call.ts +30 -0
  36. package/src/lib/test-home-guard.ts +90 -0
  37. package/src/lib/win-exec.ts +12 -2
  38. package/src/oauth/index.ts +29 -5
  39. package/src/oauth/key-providers.ts +21 -2
  40. package/src/oauth/kiro-credentials.ts +57 -8
  41. package/src/oauth/kiro.ts +2 -1
  42. package/src/oauth/login-cli.ts +1 -1
  43. package/src/oauth/store.ts +2 -0
  44. package/src/providers/derive.ts +2 -2
  45. package/src/providers/model-discovery.ts +356 -0
  46. package/src/providers/registry.ts +114 -0
  47. package/src/router.ts +5 -3
  48. package/src/server/auth-cors.ts +4 -2
  49. package/src/server/live.ts +75 -25
  50. package/src/server/management/agent-settings-routes.ts +78 -4
  51. package/src/server/management/config-routes.ts +19 -7
  52. package/src/server/management/context.ts +11 -1
  53. package/src/server/management/model-routes.ts +46 -13
  54. package/src/server/management/provider-routes.ts +44 -9
  55. package/src/server/management/shared.ts +2 -2
  56. package/src/server/management/sidebar-routes.ts +39 -0
  57. package/src/server/management-api.ts +3 -1
  58. package/src/server/responses/core.ts +31 -20
  59. package/src/server/responses/upstream-error.ts +48 -0
  60. package/src/server/startup-action-control.ts +30 -14
  61. package/src/service.ts +237 -19
  62. package/src/storage/policy-job.ts +26 -5
  63. package/src/storage/restore-job.ts +16 -5
  64. package/src/storage/worker-lifecycle.ts +81 -0
  65. package/src/tray/windows.ts +32 -4
  66. package/src/types.ts +11 -0
  67. package/src/update/badge.ts +72 -0
  68. package/src/update/job.ts +8 -4
  69. package/src/usage/expected-prices.ts +6 -5
  70. package/src/usage/log.ts +8 -0
  71. package/src/web-search/loop.ts +57 -16
  72. package/gui/dist/assets/index-Czw-jpTU.css +0 -1
  73. package/gui/dist/assets/index-cmds12BG.js +0 -67
@@ -453,12 +453,15 @@ export function responsesSseToAnthropicSse(
453
453
  if (line.startsWith("event: ")) eventName = line.slice(7).trim();
454
454
  else if (line.startsWith("data: ")) dataLine += line.slice(6);
455
455
  }
456
- if (!eventName || !dataLine) continue;
456
+ if (!dataLine) continue;
457
457
  let data: unknown;
458
458
  try { data = JSON.parse(dataLine); } catch { continue; }
459
459
  if (!isRec(data)) continue;
460
- if (terminated) continue;
461
- handleFrame(eventName, data);
460
+ // Responses-compatible gateways may omit the optional SSE event field
461
+ // while retaining the event name in the JSON payload's required type.
462
+ const resolvedEventName = eventName || (typeof data.type === "string" ? data.type : "");
463
+ if (!resolvedEventName || terminated) continue;
464
+ handleFrame(resolvedEventName, data);
462
465
  }
463
466
  }
464
467
  // EOF without a terminal frame is a TRUNCATION, not success (devlog 100:
@@ -0,0 +1,24 @@
1
+ import type { OcxConfig } from "../types";
2
+
3
+ type GatherRoutedModels = (config: OcxConfig) => Promise<unknown>;
4
+
5
+ export type CatalogPrewarmDeps = {
6
+ loadConfig?: () => OcxConfig;
7
+ importCatalog?: () => Promise<{ gatherRoutedModels: GatherRoutedModels }>;
8
+ };
9
+
10
+ /**
11
+ * After the listen port is bound, kick off live provider discovery so the first
12
+ * GUI /v1/models and syncModelsToCodex share one gather flight instead of racing
13
+ * duplicate upstream /models fetches.
14
+ */
15
+ export function scheduleCatalogPrewarm(deps: CatalogPrewarmDeps = {}): void {
16
+ void Promise.resolve()
17
+ .then(async () => {
18
+ const load = deps.loadConfig ?? (await import("../config")).loadConfig;
19
+ const { gatherRoutedModels } = await (deps.importCatalog?.() ?? import("../codex/catalog"));
20
+ return gatherRoutedModels(load());
21
+ })
22
+ .catch(() => {});
23
+ }
24
+
package/src/cli/claude.ts CHANGED
@@ -48,6 +48,27 @@ export function buildClaudeEnv(
48
48
  // leaving the child with no token at all (audit R2-1). It is opencodex state, never
49
49
  // user auth, so dropping it unconditionally is safe.
50
50
  if (env.ANTHROPIC_AUTH_TOKEN === PROXY_MARKER) delete env.ANTHROPIC_AUTH_TOKEN;
51
+ // Step 1b — drop Anthropic credentials that the bundled Bun runtime synthesized from a
52
+ // project `.env`/`.env.local` (issue #701). Claude Code disables claude.ai connectors the
53
+ // moment either token slot is populated, so an ambient project file silently moved a
54
+ // subscriber onto API billing while their OAuth login stayed healthy. The npm launcher
55
+ // runs under Node, which does NOT auto-load dotenv, so it records the slots that existed
56
+ // before Bun started; anything populated now but absent then came from the working
57
+ // directory, not from the user. A genuine shell export is still honored, which keeps
58
+ // auto-mode API-key auth working. An ABSENT marker means provenance is unknowable
59
+ // (a direct `bun src/cli/index.ts` run, a test, or an older launcher), and then we
60
+ // change nothing rather than guess — an EMPTY marker is different: the launcher ran
61
+ // and saw no pre-existing slots.
62
+ const preBunSlots = base.OCX_PRE_BUN_ANTHROPIC_ENV;
63
+ if (preBunSlots !== undefined) {
64
+ const exported = new Set(preBunSlots.split(",").filter(name => name.length > 0));
65
+ for (const name of ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"] as const) {
66
+ const value = env[name];
67
+ if (value !== undefined && value !== "" && !exported.has(name)) delete env[name];
68
+ }
69
+ }
70
+ // Never forward the seam itself to Claude Code.
71
+ delete env.OCX_PRE_BUN_ANTHROPIC_ENV;
51
72
  const setDefault = (name: string, value: string | undefined) => {
52
73
  if (value === undefined || value.length === 0) return;
53
74
  if (env[name] !== undefined && env[name] !== "") return; // user wins
@@ -76,15 +97,19 @@ export function buildClaudeEnv(
76
97
  if ((config.apiKeys?.length ?? 0) > 0) {
77
98
  setDefault("ANTHROPIC_AUTH_TOKEN", config.apiKeys![0].key);
78
99
  }
79
- // Detection reads the SAME base env this launch will use, so the resolver and the
80
- // spawned process cannot disagree. Injected deps are spread FIRST and `env` bound
81
- // LAST, and the injection type excludes `env`, so a test fake cannot break that.
82
- // `ownTokens` is bound last for the same reason: it is config-derived, and a fake
83
- // that replaced it could make our own admission key look like user auth.
100
+ // Detection reads the SANITIZED launch env the exact object spawned below so the
101
+ // resolver and the spawned process cannot disagree. It deliberately does NOT read the
102
+ // raw base: the provenance strip above already removed dotenv-only credentials, and
103
+ // letting a value the child never receives decide the marker left an auto-mode user
104
+ // with neither the credential NOR the proxy marker (#701 audit round 2). Injected deps
105
+ // are spread FIRST and `env` bound LAST, and the injection type excludes `env`, so a
106
+ // test fake cannot break that. `ownTokens` is bound last for the same reason: it is
107
+ // config-derived, and a fake that replaced it could make our own admission key look
108
+ // like user auth.
84
109
  const resolved = resolveClaudeAuthMode(config, detectClaudeAuth({
85
- ...defaultAuthDetectDeps(base as NodeJS.ProcessEnv),
110
+ ...defaultAuthDetectDeps(env as NodeJS.ProcessEnv),
86
111
  ...(deps.authDetect ?? {}),
87
- env: () => base as NodeJS.ProcessEnv,
112
+ env: () => env as NodeJS.ProcessEnv,
88
113
  ownTokens: ownAdmissionTokens(config),
89
114
  }));
90
115
  if (!env.ANTHROPIC_AUTH_TOKEN && resolved.markerMode === "proxy") {
package/src/cli/doctor.ts CHANGED
@@ -307,13 +307,47 @@ export type ConfiguredProxyDiagnostic = {
307
307
  detail: string;
308
308
  };
309
309
 
310
- function envReferenceName(value: string): string | null {
310
+ export function envReferenceName(value: string): string | null {
311
311
  const braced = value.match(/^\$\{(\w+)\}$/);
312
312
  if (braced) return braced[1]!;
313
313
  const bare = value.match(/^\$(\w+)$/);
314
314
  return bare ? bare[1]! : null;
315
315
  }
316
316
 
317
+ export type ProviderApiKeyDiagnostic = {
318
+ provider: string;
319
+ envName: string;
320
+ detail: string;
321
+ };
322
+
323
+ /** Warn when a key-auth provider's apiKey env reference resolves empty in this process. */
324
+ export function collectProviderApiKeyDiagnostics(
325
+ providers: Record<string, { authMode?: string; apiKey?: string }> = readConfigDiagnostics().config.providers ?? {},
326
+ env: EnvMap = process.env,
327
+ ): ProviderApiKeyDiagnostic[] {
328
+ const resolveInEnv = (value: string): string | undefined => {
329
+ const name = envReferenceName(value);
330
+ if (!name) return value;
331
+ return env[name];
332
+ };
333
+ const rows: ProviderApiKeyDiagnostic[] = [];
334
+ for (const [provider, config] of Object.entries(providers)) {
335
+ if (config.authMode !== "key") continue;
336
+ const raw = typeof config.apiKey === "string" ? config.apiKey.trim() : "";
337
+ if (!raw) continue;
338
+ const envName = envReferenceName(raw);
339
+ if (!envName) continue;
340
+ const resolved = resolveInEnv(raw);
341
+ if (resolved?.trim()) continue;
342
+ rows.push({
343
+ provider,
344
+ envName,
345
+ detail: `provider ${provider}: env reference ${envName} is unset or empty in this process`,
346
+ });
347
+ }
348
+ return rows;
349
+ }
350
+
317
351
  export function collectConfiguredProxy(): ConfiguredProxyDiagnostic {
318
352
  const diagnostics = readConfigDiagnostics();
319
353
  const rawProxy = typeof diagnostics.config.proxy === "string" ? diagnostics.config.proxy.trim() : "";
@@ -742,6 +776,16 @@ export async function runDoctor(args: string[] = []): Promise<void> {
742
776
  console.log("\nConfigured proxy (value hidden)");
743
777
  console.log(` ${configuredProxy.present ? "set " : "unset "} ${configuredProxy.key} (${configuredProxy.source}; ${configuredProxy.detail})`);
744
778
 
779
+ const providerApiKeys = collectProviderApiKeyDiagnostics(doctorConfig.providers);
780
+ console.log("\nProvider API keys (value hidden)");
781
+ if (providerApiKeys.length === 0) {
782
+ console.log(" ok no empty env-referenced provider keys detected in this process");
783
+ } else {
784
+ for (const row of providerApiKeys) {
785
+ console.log(` !! ${row.detail}`);
786
+ }
787
+ }
788
+
745
789
  console.log("\nRunning proxy process proxy env (presence only)");
746
790
  if (runningProxyEnv.status === "not_running") {
747
791
  console.log(" -- no running ocx proxy process found");
@@ -825,6 +869,9 @@ export async function runDoctor(args: string[] = []): Promise<void> {
825
869
  serviceViable: startup.serviceViable,
826
870
  });
827
871
  if (proxyDown) hints.push(proxyDown);
872
+ for (const row of providerApiKeys) {
873
+ hints.push(`${row.detail}. Set ${row.envName} in the shell that starts the proxy, or store a literal key in config (value hidden here).`);
874
+ }
828
875
  const anyDrvfs = paths.some(p => detectFsType(p.path, mounts).isDrvfs || detectFsType(p.path, mounts).isMntDrive);
829
876
  const noProxy = currentProxyEnv.every(p => !p.present) && !configuredProxy.present;
830
877
  if (!startup.rebootSafe) {
package/src/cli/index.ts CHANGED
@@ -38,6 +38,7 @@ import { startTokenGuardian } from "../oauth/token-guardian";
38
38
  import { startHistoryMigrationGuardian } from "../codex/history-migration-guardian";
39
39
  import { maybeAutoRestoreCodexShim } from "./codex-shim-autorestore";
40
40
  import { maybeShowStarPrompt } from "./star-prompt";
41
+ import { scheduleCatalogPrewarm } from "./catalog-prewarm";
41
42
  import { maybeShowUpdatePrompt } from "../update/notify";
42
43
  import { syncModelsToCodex } from "../codex/sync";
43
44
  import { normalizeUpdateChannel, runGuiUpdateWorker } from "../update/job";
@@ -190,6 +191,10 @@ async function handleStart(options: { block?: boolean } = {}) {
190
191
  for (let attempt = 0; ; attempt++) {
191
192
  try {
192
193
  server = startServer(port);
194
+ // Prewarm the live provider model cache as soon as the port is bound so the
195
+ // first GUI /v1/models (and syncModelsToCodex below) share one discovery flight
196
+ // instead of racing duplicate upstream /models fetches.
197
+ scheduleCatalogPrewarm();
193
198
  break;
194
199
  } catch (err) {
195
200
  if (!isAddrInUse(err) || attempt >= 2) throw err;
@@ -25,7 +25,11 @@ export interface InteractiveConfirmOptions {
25
25
  output?: NodeJS.WriteStream;
26
26
  }
27
27
 
28
- const REVERSE = "\x1b[7m";
28
+ // The highlight sets an explicit black-on-white pair rather than bare reverse
29
+ // video (\x1b[7m). Reverse alone inherits whatever foreground colour is in
30
+ // effect, so on some themes the selected label rendered as black text on a black
31
+ // block and the choice became invisible.
32
+ const REVERSE = "\x1b[30;47m";
29
33
  const DIM = "\x1b[2m";
30
34
  const RESET = "\x1b[0m";
31
35
  const CLEAR_LINE = "\r\x1b[K";
@@ -3,6 +3,7 @@ import { join } from "node:path";
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { getConfigDir } from "../config";
5
5
  import { recordOwnedConfigPath } from "../lib/config-ownership";
6
+ import { commandInvocation } from "../lib/win-exec";
6
7
  import { isAgentDriven } from "./agent-driven";
7
8
  import { interactiveConfirm } from "./interactive-confirm";
8
9
 
@@ -29,16 +30,37 @@ export function hasStarPromptRun(): boolean {
29
30
  * that case the prompt stays silent instead of asking for something it would
30
31
  * then fail to do.
31
32
  */
33
+ /**
34
+ * On Windows `gh` is a `.cmd` shim; a shell-less spawn of the bare name skips
35
+ * PATHEXT and refuses `.cmd` targets, so it stalls until the timeout instead of
36
+ * failing fast. Route every call through the launcher the rest of the CLI uses.
37
+ */
38
+ /** Resolve `gh` once; callers keep their own spawnSync overload. */
39
+ function ghInvocation(args: string[]) {
40
+ const invocation = commandInvocation("gh", args);
41
+ return {
42
+ file: invocation.file,
43
+ args: invocation.args,
44
+ verbatim: invocation.options.windowsVerbatimArguments === true,
45
+ };
46
+ }
47
+
32
48
  function ghAvailable(): boolean {
33
- const version = spawnSync("gh", ["--version"], { stdio: "ignore", timeout: 3000, windowsHide: true });
49
+ const v = ghInvocation(["--version"]);
50
+ const version = spawnSync(v.file, v.args,
51
+ { stdio: "ignore", timeout: 3000, windowsHide: true, windowsVerbatimArguments: v.verbatim });
34
52
  if (version.error || version.status !== 0) return false;
35
- const auth = spawnSync("gh", ["auth", "status"], { stdio: "ignore", timeout: 5000, windowsHide: true });
53
+ const a = ghInvocation(["auth", "status"]);
54
+ const auth = spawnSync(a.file, a.args,
55
+ { stdio: "ignore", timeout: 5000, windowsHide: true, windowsVerbatimArguments: a.verbatim });
36
56
  return !auth.error && auth.status === 0;
37
57
  }
38
58
 
39
59
  function starRepo(): { ok: boolean; error?: string } {
40
- const r = spawnSync("gh", ["api", "-X", "PUT", `/user/starred/${REPO}`],
41
- { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 10000, windowsHide: true });
60
+ const star = ghInvocation(["api", "-X", "PUT", `/user/starred/${REPO}`]);
61
+ const r = spawnSync(star.file, star.args,
62
+ { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 10000, windowsHide: true,
63
+ windowsVerbatimArguments: star.verbatim });
42
64
  if (r.error) return { ok: false, error: r.error.message };
43
65
  if (r.status !== 0) return { ok: false, error: (r.stderr || r.stdout || "").trim() || `gh exited ${r.status}` };
44
66
  return { ok: true };
package/src/cli/v2.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  * - nothing in the catalog build path calls this module; no auto-flip exists.
12
12
  */
13
13
  import { execFileSync } from "node:child_process";
14
- import { getLogicalMaxThreads, hasAgentsMaxThreads, isMultiAgentV2Enabled, transitionMultiAgentV2 } from "../codex/features";
14
+ import { getAgentsEnabled, getAgentsMaxDepth, getLogicalMaxThreads, getSubagentDeveloperInstructions, hasAgentsMaxThreads, isMultiAgentV2Enabled, transitionMultiAgentV2 } from "../codex/features";
15
15
 
16
16
  import { commandInvocation, type SpawnInvocation } from "../lib/win-exec";
17
17
  import { loadConfig, saveConfig } from "../config";
@@ -85,6 +85,15 @@ export async function cmdV2(args: string[], deps: V2CliDeps = {}, findPort?: ()
85
85
  log.log(multiAgentModeLine(cfg.multiAgentMode ?? "default"));
86
86
  const threads = getLogicalMaxThreads();
87
87
  log.log(`max_threads: ${threads ?? "(unset — codex default)"}`);
88
+ const v2Active = isEnabled();
89
+ const agentsEnabled = getAgentsEnabled();
90
+ log.log(`agents.enabled: ${agentsEnabled === null ? "(unset — upstream default true)" : agentsEnabled}`);
91
+ const maxDepth = getAgentsMaxDepth();
92
+ // max_depth is V1-only upstream; say so whenever V2 is active so the number
93
+ // cannot be misread as an effective V2 limit.
94
+ log.log(`agents.max_depth: ${maxDepth ?? "(unset — upstream default 1)"}${v2Active ? " (V1-only — ignored while multi_agent_v2 is enabled)" : ""}`);
95
+ const instructions = getSubagentDeveloperInstructions();
96
+ log.log(`subagent_developer_instructions: ${instructions === null ? "(unset — children inherit)" : instructions === "" ? '"" (clears inherited instructions)' : JSON.stringify(instructions)}`);
88
97
  if (isEnabled() && hasMaxThreads()) {
89
98
  log.log("WARNING: [agents] max_threads is set — codex refuses to start while multi_agent_v2 is enabled. Remove it from config.toml (concurrency lives in features.multi_agent_v2.max_concurrent_threads_per_session).");
90
99
  }
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { closeSync, existsSync, readFileSync, mkdirSync, openSync, unlinkSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { getConfigDir, atomicWriteFile, backupInvalidConfig, hardenConfigDir, hardenExistingSecret } from "../config";
5
+ import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
5
6
  import type { CodexAccountCredentialRecord, CodexAccountCredentials } from "../types";
6
7
 
7
8
  type LegacyCodexAccountStore = Record<string, CodexAccountCredentials>;
@@ -97,6 +98,7 @@ function loadCodexAccountRecordStore(): CodexAccountStore {
97
98
 
98
99
  function persist(store: CodexAccountStore): void {
99
100
  const dir = getConfigDir();
101
+ assertNotRealHomeUnderTest(dir);
100
102
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
101
103
  atomicWriteFile(codexAccountsPath(), JSON.stringify(store, null, 2) + "\n");
102
104
  }
@@ -151,14 +151,21 @@ export function loadBundledCodexCatalog(deps: BundledCatalogDeps = {}): RawCatal
151
151
  let cacheKey: string | null = null;
152
152
  const candidates = deps.commandCandidates?.() ?? (() => {
153
153
  const resolved = resolveAndPersistCodexRuntime({
154
- execFileSync: execFile,
154
+ // Forward an INJECTED execFileSync only. Passing the real one unconditionally made
155
+ // resolveCacheKey() bail out (it refuses to memoize injected-dep resolves), so every
156
+ // catalog read re-ran the ~1s `codex --version` probe even on a warm cache hit.
157
+ ...(deps.execFileSync ? { execFileSync: deps.execFileSync } : {}),
155
158
  configDir: deps.configDir,
156
159
  env: deps.env,
157
160
  platform: deps.platform,
158
161
  existsSync: deps.existsSync,
159
162
  readFileSync: deps.readFileSync,
160
163
  now: deps.now,
161
- discoverAlternatives: deps.discoverAlternatives,
164
+ // Catalog loading only consumes `resolved.runtime.command`, never `newerAvailable`.
165
+ // Full PATH discovery probes every candidate launcher (100+ on a dev machine, ~1.2s),
166
+ // which alone can exceed the 3s budget `ocx claude` allows /api/claude-code. Priority
167
+ // selection is identical either way; callers wanting discovery diagnostics opt in.
168
+ discoverAlternatives: deps.discoverAlternatives ?? false,
162
169
  });
163
170
  if (useCache) {
164
171
  cacheKey = [
@@ -110,6 +110,8 @@ export interface CatalogModel {
110
110
  /** Whether Codex may send Responses text.verbosity for this routed model. */
111
111
  supportsVerbosity?: boolean;
112
112
  supportsReasoningSummaries?: boolean;
113
+ /** Normalized upstream capability names retained for management/API consumers (#485 follow-up). */
114
+ capabilities?: string[];
113
115
  }
114
116
 
115
117
  export type RawEntry = Record<string, unknown>;
@@ -272,6 +274,16 @@ export function ensureStrictCatalogFields(
272
274
  if (!Array.isArray(entry.input_modalities) && !options.preserveExactInputModalities) {
273
275
  entry.input_modalities = ["text"];
274
276
  }
277
+ // Codex parses `input_modalities` as a closed enum. One out-of-enum value (zenmux advertises
278
+ // "video") makes its config loader reject the entire catalog, which takes down plugins, apps and
279
+ // MCP servers — not just that model. Normalize at the single point every entry passes through,
280
+ // because provider metadata, jawcode metadata and effort sync each write this field.
281
+ if (Array.isArray(entry.input_modalities)) {
282
+ const accepted = entry.input_modalities.filter(value =>
283
+ value === "text" || value === "image" || value === "audio");
284
+ // Never leave it empty: an entry with no modality at all is worse than a text-only one.
285
+ entry.input_modalities = accepted.length > 0 ? accepted : ["text"];
286
+ }
275
287
  const contextWindow = typeof entry.context_window === "number" && entry.context_window > 0 ? entry.context_window : 128000;
276
288
  entry.context_window = contextWindow;
277
289
  if (
@@ -288,7 +300,18 @@ export function ensureStrictCatalogFields(
288
300
 
289
301
  export type MultiAgentMode = "v1" | "default" | "v2";
290
302
 
291
- export function applyMultiAgentMode(entries: RawEntry[], mode: MultiAgentMode): RawEntry[] {
303
+ /**
304
+ * @param v2FeatureEnabled When the native multi_agent_v2 feature is on, "default"
305
+ * mode stamps unpinned entries as "v2" instead of deleting the key. The native
306
+ * binary validates spawn_agent models against THIS catalog with its own
307
+ * `multi_agent_version == Some(V2)` test (codex-rs multi_agents_common.rs), so an
308
+ * absent pin means a clean refusal at spawn time — exactly the cross-provider
309
+ * spawns opencodex exists to enable (option B, devlog
310
+ * 260730_codex_rs_upstream_v2_live_handoff/060). Upstream pins are always
311
+ * preserved: a genuine "v1" pin is a real capability statement and stays excluded.
312
+ * With the feature off the output is byte-identical to the historical behavior.
313
+ */
314
+ export function applyMultiAgentMode(entries: RawEntry[], mode: MultiAgentMode, v2FeatureEnabled = false): RawEntry[] {
292
315
  if (mode === "default") {
293
316
  // Restore upstream defaults: clear any stale forced multi_agent_version and
294
317
  // re-apply upstream pins from the snapshot for native entries that have one.
@@ -298,6 +321,8 @@ export function applyMultiAgentMode(entries: RawEntry[], mode: MultiAgentMode):
298
321
  const upstreamPin = upstream?.multi_agent_version;
299
322
  if (typeof upstreamPin === "string") {
300
323
  entry.multi_agent_version = upstreamPin;
324
+ } else if (v2FeatureEnabled) {
325
+ entry.multi_agent_version = "v2";
301
326
  } else {
302
327
  delete entry.multi_agent_version;
303
328
  }