@bitkyc08/opencodex 2.22.0 → 2.23.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 (79) hide show
  1. package/gui/dist/assets/{index-ClEcVlFO.js → index-rFWrIE11.js} +19 -19
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +2 -2
  4. package/src/adapters/anthropic.ts +39 -7
  5. package/src/adapters/cursor/tool-definitions.ts +48 -0
  6. package/src/adapters/google.ts +18 -12
  7. package/src/adapters/openai-chat.ts +106 -13
  8. package/src/adapters/tool-call-id.ts +119 -0
  9. package/src/adapters/tool-catalog-nudge.ts +3 -0
  10. package/src/bridge.ts +16 -5
  11. package/src/chat/inbound.ts +5 -11
  12. package/src/claude/context-windows.ts +5 -1
  13. package/src/claude/desktop-3p.ts +11 -6
  14. package/src/claude/inbound.ts +39 -1
  15. package/src/claude/model-info.ts +28 -8
  16. package/src/cli/account-api.ts +5 -1
  17. package/src/cli/claude-desktop.ts +3 -0
  18. package/src/cli/config-command.ts +37 -14
  19. package/src/codex/app-server-restart-service.ts +1 -1
  20. package/src/codex/auth-api.ts +5 -0
  21. package/src/codex/auth-context.ts +43 -2
  22. package/src/codex/catalog/metadata.ts +55 -8
  23. package/src/codex/catalog/native-models.ts +32 -2
  24. package/src/codex/catalog/parsing.ts +21 -7
  25. package/src/codex/catalog/provider-fetch.ts +35 -7
  26. package/src/codex/catalog/sync.ts +40 -18
  27. package/src/codex/catalog-refresh-status.ts +21 -3
  28. package/src/codex/catalog.ts +1 -1
  29. package/src/codex/convergence-types.ts +23 -2
  30. package/src/codex/desired-state.ts +1 -1
  31. package/src/codex/inject.ts +38 -7
  32. package/src/codex/injected-marker.ts +28 -0
  33. package/src/codex/journal.ts +40 -1
  34. package/src/codex/management-convergence.ts +55 -2
  35. package/src/codex/quota-rejection.ts +61 -1
  36. package/src/codex/quota.ts +60 -6
  37. package/src/codex/routing.ts +30 -3
  38. package/src/combos/failover.ts +20 -0
  39. package/src/config.ts +271 -4
  40. package/src/generated/compatibility-version.json +86 -74
  41. package/src/grok/sync.ts +3 -1
  42. package/src/lab/artifacts/sanitize.ts +1 -1
  43. package/src/lab/live/manifest.ts +1 -1
  44. package/src/lib/codex-restart-contract.ts +1 -1
  45. package/src/lib/config-ownership.ts +1 -0
  46. package/src/lib/errors.ts +9 -0
  47. package/src/lib/lab-activation.ts +1 -1
  48. package/src/lib/optional-shutdown-hooks.ts +1 -1
  49. package/src/providers/quota.ts +10 -4
  50. package/src/providers/registry.ts +2 -2
  51. package/src/responses/parser.ts +42 -7
  52. package/src/responses/provider-opaque-metadata.ts +1 -1
  53. package/src/responses/thought-signature-replay.ts +261 -0
  54. package/src/router.ts +6 -1
  55. package/src/routing/compatibility/provider-slot.ts +1 -1
  56. package/src/routing/evaluator.ts +12 -2
  57. package/src/routing/health.ts +16 -5
  58. package/src/routing/history/schema.ts +1 -1
  59. package/src/routing/trace.ts +1 -1
  60. package/src/server/auth-cors.ts +56 -21
  61. package/src/server/chat-completions.ts +6 -2
  62. package/src/server/index.ts +5 -3
  63. package/src/server/management/agent-settings-routes.ts +26 -4
  64. package/src/server/management/context.ts +1 -1
  65. package/src/server/management/model-rows.ts +5 -0
  66. package/src/server/management/native-integration-routes.ts +4 -1
  67. package/src/server/management/provider-routes.ts +19 -0
  68. package/src/server/management/shared.ts +3 -3
  69. package/src/server/management-api.ts +13 -6
  70. package/src/server/passive-route-linker.ts +1 -1
  71. package/src/server/relay.ts +16 -0
  72. package/src/server/responses/compact.ts +10 -3
  73. package/src/server/responses/core.ts +160 -33
  74. package/src/server/responses/fetch-helpers.ts +34 -2
  75. package/src/server/responses/input-admission.ts +17 -9
  76. package/src/server/responses-undeclared-tool-guard.ts +153 -0
  77. package/src/server/system-env.ts +4 -2
  78. package/src/service.ts +14 -7
  79. package/src/types.ts +34 -0
@@ -266,6 +266,21 @@ export function isNativeOpenAiEntry(entry: RawEntry): boolean {
266
266
  return typeof entry.slug === "string" && !entry.slug.includes("/");
267
267
  }
268
268
 
269
+ /**
270
+ * Auto-compaction threshold for a native row.
271
+ *
272
+ * The usual rule is 90% of the window, but a model whose measured input ceiling sits below
273
+ * that (GPT-5.6: 922,000 against a 1,050,000 window, where 90% would be 945,000) has to
274
+ * clamp to the ceiling instead — otherwise the client keeps filling until upstream answers
275
+ * `context_length_exceeded` and compaction never gets a chance to run.
276
+ */
277
+ function nativeAutoCompactLimit(contextWindow: number, maxInputTokens: number | undefined, contextCap?: number): number {
278
+ const ninety = Math.floor(contextWindow * 0.9);
279
+ if (typeof maxInputTokens !== "number" || maxInputTokens <= 0) return ninety;
280
+ const cappedMaxInput = applyProviderContextCap(maxInputTokens, contextCap) ?? maxInputTokens;
281
+ return Math.min(ninety, cappedMaxInput, contextWindow);
282
+ }
283
+
269
284
  export function applyNativeOpenAiContextOverride(entry: RawEntry, contextCap?: number): void {
270
285
  const nativeSlug = trustedAccountBoundNativeCatalogSlug(entry)
271
286
  ?? (isNativeOpenAiEntry(entry) ? entry.slug as string : undefined);
@@ -275,7 +290,7 @@ export function applyNativeOpenAiContextOverride(entry: RawEntry, contextCap?: n
275
290
  if (typeof override.contextWindow === "number") {
276
291
  const contextWindow = applyProviderContextCap(override.contextWindow, contextCap) ?? override.contextWindow;
277
292
  entry.context_window = contextWindow;
278
- entry.auto_compact_token_limit = Math.floor(contextWindow * 0.9);
293
+ entry.auto_compact_token_limit = nativeAutoCompactLimit(contextWindow, override.maxInputTokens, contextCap);
279
294
  }
280
295
  if (typeof override.maxContextWindow === "number") {
281
296
  entry.max_context_window = applyProviderContextCap(override.maxContextWindow, contextCap) ?? override.maxContextWindow;
@@ -288,7 +303,7 @@ export function applyNativeOpenAiContextOverride(entry: RawEntry, contextCap?: n
288
303
  const cappedContext = applyProviderContextCap(currentContext, contextCap);
289
304
  if (cappedContext !== currentContext && typeof cappedContext === "number") {
290
305
  entry.context_window = cappedContext;
291
- entry.auto_compact_token_limit = Math.floor(cappedContext * 0.9);
306
+ entry.auto_compact_token_limit = nativeAutoCompactLimit(cappedContext, override?.maxInputTokens, contextCap);
292
307
  }
293
308
  const currentMax = typeof entry.max_context_window === "number" ? entry.max_context_window : undefined;
294
309
  const cappedMax = applyProviderContextCap(currentMax, contextCap);
@@ -462,16 +477,15 @@ export function normalizeRoutedCatalogEntry(entry: RawEntry, parallelToolCalls =
462
477
  // tool_search round-trip (upstream codex-rs code_mode suite; live canary 2026-08-13: routed
463
478
  // kimi/k3 called tools.mcp__node_repl__js → isError:false). Stamping false here instead forces
464
479
  // every MCP declaration into exec.description — a measured 2.7x turn-1 payload regression
465
- // (96,699 → 258,929 chars; devlog/_plan/260813_tool_catalog_deferral/010). So non-Cursor routed
466
- // rows advertise deferred discovery; the #1522 reachability concern is covered by the code-mode
467
- // path, not by paying the full-catalog tax. Cursor stays false: its runTurn transport bypasses
468
- // the web-search sidecar and has no proven deferred path.
480
+ // (96,699 → 258,929 chars; devlog/_plan/260813_tool_catalog_deferral/010). So every routed
481
+ // code-mode row advertises deferred discovery. Cursor still omits hosted web-search metadata below,
482
+ // but disabling this separate exposure bit can inflate `exec` past Cursor's 120 KB wire cap (#1830).
469
483
  if (isCursorEntry) {
470
484
  delete entry.web_search_tool_type;
471
485
  } else {
472
486
  entry.web_search_tool_type = "text_and_image";
473
487
  }
474
- entry.supports_search_tool = !isCursorEntry;
488
+ entry.supports_search_tool = true;
475
489
  // Cursor's transport already serializes overlapping tool calls into atomic Responses tool events.
476
490
  // Advertising parallel calls lets Codex send the same native capability bit it sends for OpenAI.
477
491
  // Opt-in providers (OcxProviderConfig.parallelToolCalls, e.g. xAI) advertise it too: the
@@ -74,7 +74,7 @@ import { createAdmissionGate, ResourceAdmissionError, type AdmissionMetrics } fr
74
74
 
75
75
  import { CODEX_CUSTOM_MODEL_CATALOG_KIND, JAWCODE_CATALOG_AUGMENT_PROVIDERS, catalogModelSlug, shouldExposeRoutedModel } from "./parsing";
76
76
  import type { CatalogModel } from "./parsing";
77
- import { disabledNativeSlugs, hasComboTargets, isNativeOpenAiCapabilityAliasModel, nativeDefaultReasoningEffort, nativeInputModalities, nativeOpenAiContextWindow, nativeOpenAiSlugs, nativeParallelToolCalls, nativeReasoningEfforts } from "./metadata";
77
+ import { disabledNativeSlugs, hasComboTargets, isNativeOpenAiCapabilityAliasModel, nativeDefaultReasoningEffort, nativeInputModalities, nativeOpenAiContextWindow, nativeOpenAiMaxInputTokens, nativeOpenAiSlugs, nativeParallelToolCalls, nativeReasoningEfforts } from "./metadata";
78
78
  import { deriveComboCatalogModel, normalizedOpenAiApiSignature, openAiApiCollisionWarnings, replaceLastComboCatalogOmissions, warnUncataloguedComboOnce } from "./aggregation";
79
79
  import type { ComboCatalogOmission } from "./aggregation";
80
80
  import type { CatalogGatherProviderAuthEvidence } from "./filesystem-evidence";
@@ -703,6 +703,8 @@ const COMBO_MEMBER_CONTEXT_FALLBACK = 128_000;
703
703
 
704
704
  interface ComboCatalogMemberFallback {
705
705
  readonly contextWindow?: number;
706
+ /** Input ceiling when it is lower than the window (native GPT-5.6: 922k under 1.05M). */
707
+ readonly maxInputTokens?: number;
706
708
  readonly inputModalities?: readonly string[];
707
709
  readonly reasoningEfforts?: readonly string[];
708
710
  }
@@ -744,7 +746,11 @@ export function resolveComboCatalogMember(
744
746
  if (!addMaxInput && !addModalities && !addReasoning) return member;
745
747
  return {
746
748
  ...member,
747
- ...(addMaxInput ? { maxInputTokens: contextWindow } : {}),
749
+ // Never claim a larger input budget than the window, and prefer the model's own
750
+ // measured ceiling when the fallback carries one.
751
+ ...(addMaxInput
752
+ ? { maxInputTokens: Math.min(fallback.maxInputTokens ?? contextWindow!, contextWindow!) }
753
+ : {}),
748
754
  ...(addModalities ? { inputModalities: [...fallback.inputModalities!] } : {}),
749
755
  ...(addReasoning ? { reasoningEfforts: [...fallback.reasoningEfforts!] } : {}),
750
756
  };
@@ -765,7 +771,7 @@ export function resolveComboCatalogMember(
765
771
  }
766
772
  const maxInput = typeof existing.maxInputTokens === "number" && existing.maxInputTokens > 0
767
773
  ? Math.min(existing.maxInputTokens, capped)
768
- : capped;
774
+ : Math.min(fallback?.maxInputTokens ?? capped, capped);
769
775
  return withFallbackMetadata({
770
776
  ...existing,
771
777
  contextWindow: capped,
@@ -790,6 +796,10 @@ export function resolveComboCatalogMember(
790
796
  : (typeof base.maxInputTokens === "number" && base.maxInputTokens > 0
791
797
  ? base.maxInputTokens
792
798
  : undefined);
799
+ // Kept OUT of knownMaxInput on purpose: that value doubles as a context-window fallback
800
+ // below, and a native alias whose input ceiling (922k) is lower than its window (1.05M)
801
+ // would otherwise shrink the advertised window to the input limit.
802
+ const fallbackMaxInput = existing || prov ? fallback?.maxInputTokens : undefined;
793
803
  // Real discovery/config values win. A native alias is the next fallback tier.
794
804
  // The generic 128k/text synthesis from #1305 remains the final fallback.
795
805
  const fallbackContext = existing || prov ? fallback?.contextWindow : undefined;
@@ -814,8 +824,11 @@ export function resolveComboCatalogMember(
814
824
  ?? (prov ? configuredReasoningEfforts(prov, target.model) : undefined)
815
825
  ?? base.reasoningEfforts
816
826
  ?? (fallback?.reasoningEfforts ? [...fallback.reasoningEfforts] : undefined);
817
- const maxInputTokens = knownMaxInput !== undefined
818
- ? Math.min(knownMaxInput, contextWindow)
827
+ // The model's own measured input ceiling still applies when discovery gave us nothing:
828
+ // GPT-5.6 advertises a 1.05M window but refuses input past 922k.
829
+ const effectiveMaxInput = knownMaxInput ?? fallbackMaxInput;
830
+ const maxInputTokens = effectiveMaxInput !== undefined
831
+ ? Math.min(effectiveMaxInput, contextWindow)
819
832
  : contextWindow;
820
833
 
821
834
  return {
@@ -1712,7 +1725,9 @@ async function gatherRoutedModelsUncached(
1712
1725
  id: slug,
1713
1726
  owned_by: "openai",
1714
1727
  contextWindow,
1715
- maxInputTokens: contextWindow,
1728
+ // Input limit, not the total window: GPT-5.6 advertises 1,050,000 but refuses past
1729
+ // 922,000 (measured). Falls back to the window for slugs with no separate ceiling.
1730
+ maxInputTokens: Math.min(nativeOpenAiMaxInputTokens(slug, openaiContextCap) ?? contextWindow, contextWindow),
1716
1731
  inputModalities: nativeInputModalities(slug),
1717
1732
  reasoningEfforts: nativeReasoningEfforts(slug),
1718
1733
  ...(nativeParallelToolCalls(slug) ? { parallelToolCalls: true } : {}),
@@ -1730,11 +1745,15 @@ async function gatherRoutedModelsUncached(
1730
1745
  const combo = getCombo(config, id);
1731
1746
  if (!combo) continue;
1732
1747
  const nativeContextWindow = combo.nativeAlias && combo.alias
1733
- ? nativeOpenAiContextWindow(combo.alias)
1748
+ ? nativeOpenAiContextWindow(combo.alias, providerContextCap(config, OPENAI_CODEX_PROVIDER_ID))
1749
+ : undefined;
1750
+ const nativeAliasMaxInput = combo.nativeAlias && combo.alias
1751
+ ? nativeOpenAiMaxInputTokens(combo.alias, providerContextCap(config, OPENAI_CODEX_PROVIDER_ID))
1734
1752
  : undefined;
1735
1753
  const nativeAliasFallback = combo.nativeAlias && combo.alias && nativeContextWindow !== undefined
1736
1754
  ? {
1737
1755
  contextWindow: nativeContextWindow,
1756
+ ...(nativeAliasMaxInput !== undefined ? { maxInputTokens: Math.min(nativeAliasMaxInput, nativeContextWindow) } : {}),
1738
1757
  inputModalities: nativeInputModalities(combo.alias),
1739
1758
  reasoningEfforts: nativeReasoningEfforts(combo.alias),
1740
1759
  }
@@ -1788,6 +1807,14 @@ async function gatherRoutedModelsUncached(
1788
1807
  ? Math.min(cm.contextWindow, nativeAliasContextWindow)
1789
1808
  : cm.contextWindow
1790
1809
  : nativeAliasContextWindow;
1810
+ // Input ceiling for a native capability alias, clamped to whatever window we settled on
1811
+ // above. A custom row that lowered the window must not keep the full native input budget.
1812
+ const nativeAliasMaxInputTokens = codexForwardNativeCapabilityAlias
1813
+ ? nativeOpenAiMaxInputTokens(cm.modelId, providerContextCap(config, OPENAI_CODEX_PROVIDER_ID))
1814
+ : undefined;
1815
+ const customMaxInputTokens = nativeAliasMaxInputTokens !== undefined && customContextWindow !== undefined
1816
+ ? Math.min(nativeAliasMaxInputTokens, customContextWindow)
1817
+ : nativeAliasMaxInputTokens;
1791
1818
  const nativeAliasDefaultEffort = codexForwardNativeCapabilityAlias
1792
1819
  ? nativeDefaultReasoningEffort(cm.modelId)
1793
1820
  : undefined;
@@ -1804,6 +1831,7 @@ async function gatherRoutedModelsUncached(
1804
1831
  ? { displayName: cm.displayName }
1805
1832
  : codexForwardNativeCapabilityAlias ? { displayName: "Daybreak Blue" } : {}),
1806
1833
  ...(customContextWindow !== undefined ? { contextWindow: customContextWindow } : {}),
1834
+ ...(customMaxInputTokens !== undefined ? { maxInputTokens: customMaxInputTokens } : {}),
1807
1835
  ...(cm.inputModalities
1808
1836
  ? { inputModalities: cm.inputModalities }
1809
1837
  : codexForwardNativeCapabilityAlias ? { inputModalities: nativeInputModalities(cm.modelId) } : {}),
@@ -90,21 +90,36 @@ export type SubagentRosterExclusionReason =
90
90
  /**
91
91
  * Whether a catalog entry may be offered as a V2 subagent model.
92
92
  *
93
- * Upstream (codex-rs 92938d880) requires `multi_agent_version === "v2"` exactly,
94
- * because upstream assumes a single backend serves every model. opencodex routes
95
- * many providers, so that equality would reject the cross-provider spawns this
96
- * proxy exists to enable.
93
+ * Upstream changed this rule in codex-rs `6d4d9442c` ("Support leaf models in
94
+ * multi-agent v2"). `model_supports_multi_agent_backend`
95
+ * (core/src/tools/handlers/multi_agents_common.rs:36-42) now admits EVERY model
96
+ * except one explicitly marked `disabled`; the older `== Some(V2)` equality that
97
+ * `92938d880` introduced is gone.
97
98
  *
98
- * Decision (option B, devlog 260730_codex_rs_upstream_v2_live_handoff/060): any
99
- * model opencodex actually routes is eligible. An entry pinned to a DIFFERENT
100
- * multi-agent backend (`v1`) stays excluded, because that pin is a real capability
101
- * statement rather than an absence of information. An unpinned entry (null or
102
- * absent) is a routed or unpinned-native model and is allowed. The three-way
103
- * distinction is the substance; do not flatten it into a truthiness check.
99
+ * The field no longer answers "may I be a delegation target". It answers "does the
100
+ * CHILD get collaboration tools": `collab_tools_enabled`
101
+ * (core/src/tools/spec_plan.rs:599-610) grants a child recursive tools only when its
102
+ * own catalog value is exactly `Some(V2)`. The three-way distinction survives, but it
103
+ * now means eligible-recursive / eligible-LEAF / excluded:
104
+ *
105
+ * - `"v2"` -> eligible, and the child may itself delegate.
106
+ * - `"v1"` -> eligible LEAF worker. This is upstream's pin for `gpt-5.6-luna`
107
+ * (models-manager/models.json); excluding it here is exactly what
108
+ * kept Luna out of opencodex's roster.
109
+ * - absent/null -> eligible LEAF worker (routed or unpinned-native model).
110
+ * - `"disabled"` -> the sole capability-based exclusion.
111
+ *
112
+ * This is the roster filter only. Catalog STAMPING is a separate concern owned by
113
+ * `applyMultiAgentMode`, including the `keepNativeChatGptOnV1` policy (#1728) that
114
+ * keeps ChatGPT-native rows on `v1` so a native parent can still spawn a routed child
115
+ * despite backend-encrypted NEW_TASK bodies (#92). Recognizing those `v1` rows as
116
+ * eligible leaves here is what makes that policy usable, not a contradiction of it.
117
+ *
118
+ * Devlog: 260816_codexrs_multiagent_v2_and_history_perf/011 (C1), superseding the
119
+ * option-B decision in 260730_codex_rs_upstream_v2_live_handoff/060.
104
120
  */
105
121
  export function isEligibleV2SubagentEntry(entry: RawEntry): boolean {
106
- const pinned = entry.multi_agent_version;
107
- return pinned === "v2" || pinned === null || pinned === undefined;
122
+ return entry.multi_agent_version !== "disabled";
108
123
  }
109
124
 
110
125
  export interface EffectiveSubagentModel {
@@ -343,10 +358,9 @@ export function deriveEntry(
343
358
  });
344
359
  }
345
360
  // Fallback when no template is available (best-effort; strict parser may need more).
346
- // Cursor fallback rows mirror normalizeRoutedCatalogEntry: no deferred discovery, no hosted
347
- // web-search metadata (runTurn transport bypasses the sidecar). Non-Cursor routed fallbacks
348
- // advertise deferred discovery code mode keeps deferred MCP callable (devlog
349
- // 260813_tool_catalog_deferral/010+020); search=false costs a measured 2.7x turn-1 payload.
361
+ // All routed fallbacks enable deferred code-mode tool exposure; otherwise the nested catalog
362
+ // expands into `exec.description` and can exceed Cursor's 120 KB serialized tool limit (#1830).
363
+ // Cursor still omits hosted web-search metadata because runTurn bypasses that separate sidecar.
350
364
  const isCursorFallback = isRouted && model?.provider === "cursor";
351
365
  const entry: RawEntry = {
352
366
  slug, display_name: routedDisplayName(slug), description: desc,
@@ -354,7 +368,7 @@ export function deriveEntry(
354
368
  priority, base_instructions: "You are a helpful coding assistant.",
355
369
  ...(isRouted
356
370
  ? isCursorFallback
357
- ? { supports_search_tool: false }
371
+ ? { supports_search_tool: true }
358
372
  : { web_search_tool_type: "text_and_image", supports_search_tool: true }
359
373
  : {}),
360
374
  };
@@ -1672,8 +1686,16 @@ export async function syncCatalogModels(config: OcxConfig): Promise<RetainedCata
1672
1686
  export function restoreCodexCatalogWithPermit(
1673
1687
  permit: CatalogWritePermit,
1674
1688
  owningCodexHome: string,
1689
+ /**
1690
+ * The catalog this injection actually wrote, when it is known (#1798).
1691
+ *
1692
+ * Re-resolving from the CURRENT config is wrong after a Codex app rewrite that dropped
1693
+ * `model_catalog_json`: that sends restore to the default catalog while the routed file we
1694
+ * really wrote is left untouched. The recorded path is the file whose routing is ours.
1695
+ */
1696
+ injectedCatalogPath?: string | null,
1675
1697
  ): { removed: number; kept: number; path: string } {
1676
- const catalogPath = readCodexCatalogPath();
1698
+ const catalogPath = injectedCatalogPath ?? readCodexCatalogPath();
1677
1699
  const catalog = readCatalog(catalogPath);
1678
1700
  if (!catalog || !Array.isArray(catalog.models)) return { removed: 0, kept: 0, path: catalogPath };
1679
1701
  const disabledModels = currentDisabledModelsForRestore();
@@ -1,4 +1,4 @@
1
- import type { CatalogDisposition, CatalogNotice } from "./convergence-types";
1
+ import type { CatalogDisposition, CatalogFailureCause, CatalogNotice } from "./convergence-types";
2
2
 
3
3
  const INVALID_CATALOG_DISPOSITION_FIELD = Symbol("invalid-catalog-disposition-field");
4
4
 
@@ -69,11 +69,15 @@ export function normalizeCatalogDisposition(value: unknown): CatalogDisposition
69
69
  const phase = ownDataProperty(value, "phase");
70
70
  const retryable = ownDataProperty(value, "retryable");
71
71
  const partialWrite = ownDataProperty(value, "partialWrite");
72
- if ((reason !== "provider-auth" && reason !== "provider-network" && reason !== "disk")
72
+ if ((reason !== "provider-auth" && reason !== "provider-network" && reason !== "disk"
73
+ && reason !== "request-invalid" && reason !== "admission" && reason !== "internal")
73
74
  || (phase !== "gather" && phase !== "commit")
74
75
  || typeof retryable !== "boolean"
75
76
  || typeof partialWrite !== "boolean") return null;
76
- return { status, reason, phase, retryable, partialWrite };
77
+ // The cause is rebuilt from closed vocabularies, never copied through: this is the
78
+ // boundary that keeps a message, path or account id from riding out on a failure.
79
+ const cause = normalizeCatalogFailureCause(ownDataProperty(value, "cause"));
80
+ return { status, reason, phase, retryable, partialWrite, ...(cause ? { cause } : {}) };
77
81
  }
78
82
  return null;
79
83
  } catch {
@@ -81,6 +85,20 @@ export function normalizeCatalogDisposition(value: unknown): CatalogDisposition
81
85
  }
82
86
  }
83
87
 
88
+ const FAILURE_CAUSE_KINDS: ReadonlySet<string> = new Set(["invalid-request", "lock-busy", "io", "unknown"]);
89
+ const FAILURE_CAUSE_CODES: ReadonlySet<string> = new Set([
90
+ "ENOSPC", "EACCES", "EPERM", "EROFS", "ENOENT", "SQLITE_BUSY",
91
+ ]);
92
+
93
+ function normalizeCatalogFailureCause(value: unknown): CatalogFailureCause | undefined {
94
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return undefined;
95
+ const kind = ownDataProperty(value, "kind");
96
+ if (typeof kind !== "string" || !FAILURE_CAUSE_KINDS.has(kind)) return undefined;
97
+ const code = ownDataProperty(value, "code");
98
+ const safeCode = typeof code === "string" && FAILURE_CAUSE_CODES.has(code) ? code : undefined;
99
+ return { kind, ...(safeCode ? { code: safeCode } : {}) } as CatalogFailureCause;
100
+ }
101
+
84
102
  /** Whether a persisted mutation still needs a successful catalog commit. */
85
103
  export function catalogRefreshIsPending(disposition: CatalogDisposition): boolean {
86
104
  return disposition.status !== "committed";
@@ -2,7 +2,7 @@
2
2
  // Public surface preserved exactly; importers keep using "src/codex/catalog".
3
3
  export { isMediaGenerationModelId, shouldExposeRoutedModel, readCodexCatalogPath, readCatalog, normalizeRoutedCatalogEntry, catalogModelSlug, filterSupportedNativeSlugs, catalogModelSupportsReasoningSummaries } from "./catalog/parsing";
4
4
  export type { CatalogModel, MultiAgentMode } from "./catalog/parsing";
5
- export { accountBoundNativeOpenAiSlugs, accountBoundNativeOpenAiSlugsBySelector, CODEX_NATIVE_ALIAS_CATALOG_KIND, NATIVE_DAYBREAK_BLUE_MODEL, NATIVE_OPENAI_CAPABILITY_ALIAS_MODELS, NATIVE_OPENAI_MODELS, configuredNativeAliasSlugs, desktopAllowlistSuppressedNativeSlugs, isNativeAliasCatalogEntry, isNativeOpenAiCapabilityAliasModel, nativeOpenAiCapabilitySourceSlug, nativeOpenAiContextWindow, disabledNativeSlugs, visibleNativeSlugs, desktopVisibleNativeSlugs, nativeModelRows, applyNativeVisibility, observedAccountBoundNativeEntries, observedAccountBoundNativeOpenAiSlugs, upstreamNativeEntry, nativeOpenAiSlugs, listCatalogNativeSlugs, nativeInputModalities, nativeReasoningEfforts, nativeDefaultReasoningEffort, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi } from "./catalog/metadata";
5
+ export { accountBoundNativeOpenAiSlugs, accountBoundNativeOpenAiSlugsBySelector, CODEX_NATIVE_ALIAS_CATALOG_KIND, NATIVE_DAYBREAK_BLUE_MODEL, NATIVE_GPT56_MAX_INPUT_TOKENS, NATIVE_OPENAI_CAPABILITY_ALIAS_MODELS, NATIVE_OPENAI_MODELS, configuredNativeAliasSlugs, desktopAllowlistSuppressedNativeSlugs, isNativeAliasCatalogEntry, isNativeOpenAiCapabilityAliasModel, nativeOpenAiCapabilitySourceSlug, nativeOpenAiContextWindow, nativeOpenAiMaxInputTokens, disabledNativeSlugs, visibleNativeSlugs, desktopVisibleNativeSlugs, nativeModelRows, applyNativeVisibility, observedAccountBoundNativeEntries, observedAccountBoundNativeOpenAiSlugs, upstreamNativeEntry, nativeOpenAiSlugs, listCatalogNativeSlugs, nativeInputModalities, nativeReasoningEfforts, nativeDefaultReasoningEffort, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi } from "./catalog/metadata";
6
6
  export { isSpawnableCodexCandidate, codexExecInvocation, loadBundledCodexCatalog, materializeBundledCodexCatalog, loadCatalogTemplate } from "./catalog/bundled";
7
7
  export { nativeEffortClamp, shouldApplyNativeEffortClamp, catalogModelEfforts, codexSupportedReasoningEfforts, clampedDefaultEffort, clampEntryToCodexSupportedEfforts, clampCatalogModelsToCodexSupport } from "./catalog/effort";
8
8
  export { applyProviderConfigHints, isDatedVariantId, filterCatalogVisibleModels, gatherRoutedModels, clearGatherRoutedModelsInflight, augmentRoutedModelsWithRegistryOpenAiApiRows, augmentRoutedModelsWithMetadata, resolveComboCatalogMember, configuredComboTargetModelsByProvider } from "./catalog/provider-fetch";
@@ -161,8 +161,29 @@ export type CatalogDisposition =
161
161
  | { status: "skipped";
162
162
  reason: "not-requested" | "catalog-unavailable" | "busy" | "stale" | "refused";
163
163
  retryable: boolean }
164
- | { status: "failed"; reason: "provider-auth" | "provider-network" | "disk";
165
- phase: "gather" | "commit"; retryable: boolean; partialWrite: boolean };
164
+ | { status: "failed";
165
+ /**
166
+ * `disk` used to absorb every unclassified failure, so a malformed request and a
167
+ * genuine ENOSPC were indistinguishable and both reported non-retryable (#1784).
168
+ */
169
+ reason: "provider-auth" | "provider-network" | "disk" | "request-invalid" | "admission" | "internal";
170
+ phase: "gather" | "commit"; retryable: boolean; partialWrite: boolean;
171
+ /** Allowlisted cause summary. Closed vocabularies only -- never message text. */
172
+ cause?: CatalogFailureCause };
173
+
174
+ /**
175
+ * Why a catalog operation failed, in terms safe to return from the management plane.
176
+ *
177
+ * Both fields are closed sets on purpose. An `Error.constructor.name` is dependency- or
178
+ * input-influenced (any thrown custom class names itself) and an `Error.message` routinely
179
+ * carries paths, home directories and account identifiers, none of which may cross this
180
+ * boundary.
181
+ */
182
+ export type CatalogFailureCause = {
183
+ kind: "invalid-request" | "lock-busy" | "io" | "unknown";
184
+ /** Recognized errno/code token, when the underlying error carried one. */
185
+ code?: "ENOSPC" | "EACCES" | "EPERM" | "EROFS" | "ENOENT" | "SQLITE_BUSY";
186
+ };
166
187
 
167
188
  /**
168
189
  * The ONLY way Codex-owned bytes are written. Startup, ensure, /api/sync, the
@@ -18,7 +18,7 @@
18
18
  * depending on the temp root. Convergence takes the write lock; this module only
19
19
  * records intent through the config coordinator.
20
20
  *
21
- * Design record: devlog/_plan/260803_codex_desktop_toggle/030_desired_state.md.
21
+ * Design record: devlog/_fin/260803_codex_desktop_toggle/030_desired_state.md.
22
22
  */
23
23
  import { loadConfig, mutatePersistedConfig } from "../config";
24
24
  import type { OcxClientIntegrationsConfig, OcxConfig } from "../types";
@@ -30,6 +30,8 @@ import {
30
30
  } from "./user-identity";
31
31
  import {
32
32
  markJournalInjectedState,
33
+ journaledInjectedOpenaiBaseUrl,
34
+ journaledInjectedCatalogPath,
33
35
  removeJournal,
34
36
  restoreJournalState,
35
37
  writeJournal,
@@ -52,6 +54,7 @@ import {
52
54
  providerTableStart,
53
55
  providerTableString,
54
56
  rootTomlString,
57
+ stripJournaledOpenaiBaseUrl,
55
58
  tomlStringPattern,
56
59
  } from "./injected-marker";
57
60
  import {
@@ -1138,12 +1141,18 @@ interface StripOpencodexConfigResult {
1138
1141
  */
1139
1142
  function stripOpencodexConfigResult(
1140
1143
  content: string,
1144
+ journaledBaseUrl: string | null = null,
1141
1145
  ): StripOpencodexConfigResult {
1142
1146
  let out = content;
1143
1147
  const hadRootOcxProvider =
1144
1148
  readRootTomlString(out, "model_provider") === "opencodex";
1145
- const hadInjectedBaseUrl = hasInjectedOpenaiBaseUrl(out);
1149
+ // #1798: marker adjacency is FORMATTING evidence, and a Codex app rewrite keeps values
1150
+ // while dropping comments. Fall back to VALUE evidence -- the exact URL we recorded
1151
+ // writing -- so an app-rewritten config is still recognized as ours.
1152
+ const hadInjectedBaseUrl = hasInjectedOpenaiBaseUrl(out)
1153
+ || (journaledBaseUrl !== null && rootTomlString(out, "openai_base_url") === journaledBaseUrl);
1146
1154
  out = stripInjectedOpenaiBaseUrl(out); // before removeOcxSection — it keys on the marker line too
1155
+ out = stripJournaledOpenaiBaseUrl(out, journaledBaseUrl);
1147
1156
  if (out.includes("[model_providers.opencodex]")) {
1148
1157
  out = removeOcxSection(out);
1149
1158
  }
@@ -1195,8 +1204,12 @@ export function removeCodexConfig(
1195
1204
  // The unchanged fast path compares in LF space so an untouched file is never rewritten.
1196
1205
  const eol = dominantEol(rawContent);
1197
1206
  const content = applyEol(rawContent, "\n");
1198
- const had = hasOpencodexRouting(content);
1199
- const stripped = stripOpencodexConfigResult(content);
1207
+ // Read the recorded injection once: the strip below consumes it, and so does the
1208
+ // ownership verdict, which must agree with what was actually removed.
1209
+ const journaledBaseUrl = journaledInjectedOpenaiBaseUrl();
1210
+ const had = hasOpencodexRouting(content)
1211
+ || (journaledBaseUrl !== null && rootTomlString(content, "openai_base_url") === journaledBaseUrl);
1212
+ const stripped = stripOpencodexConfigResult(content, journaledBaseUrl);
1200
1213
  if (had || stripped.content !== content) {
1201
1214
  atomicWriteFile(CODEX_CONFIG_PATH, applyEol(stripped.content, eol));
1202
1215
  }
@@ -1371,13 +1384,23 @@ function restoreCodexConfigInline(): CodexRestoreConfigResult {
1371
1384
  }
1372
1385
 
1373
1386
  /** The catalog half, always inside its own K acquisition. */
1374
- function restoreCodexCatalogArtifact(revalidateDesiredState: boolean): CodexRestoreCatalogResult {
1387
+ /**
1388
+ * The catalog half, always inside its own K acquisition.
1389
+ *
1390
+ * `journaledCatalogPath` must be captured by the CALLER, before the config half runs: a
1391
+ * successful journal restore deletes the journal, and a config restore can remove
1392
+ * `model_catalog_json`. Reading it here would be too late in both cases (#1798).
1393
+ */
1394
+ function restoreCodexCatalogArtifact(
1395
+ revalidateDesiredState: boolean,
1396
+ journaledCatalogPath: string | null,
1397
+ ): CodexRestoreCatalogResult {
1375
1398
  const owningCodexHome = getCodexHome();
1376
1399
  try {
1377
1400
  const restored = withCatalogWriteSerialization(owningCodexHome, permit =>
1378
1401
  revalidateDesiredState && shouldSyncCodexOnStart(loadConfig())
1379
1402
  ? null
1380
- : restoreCodexCatalogWithPermit(permit, owningCodexHome));
1403
+ : restoreCodexCatalogWithPermit(permit, owningCodexHome, journaledCatalogPath));
1381
1404
  return restored.kind === "completed" && restored.value !== null
1382
1405
  ? { state: "ok", changed: restored.value.removed > 0, ...restored.value, message: "Codex catalog restored." }
1383
1406
  : restored.kind === "completed"
@@ -1435,6 +1458,10 @@ export async function restoreNativeCodexAsync(
1435
1458
  integrationRecord: () => readIntegrationRecord(),
1436
1459
  });
1437
1460
 
1461
+ // Captured before the config half: a successful journal restore DELETES the journal, and
1462
+ // restoring the config can drop `model_catalog_json`. Either one would hide the routed
1463
+ // catalog we actually wrote (#1798).
1464
+ const journaledCatalogPath = journaledInjectedCatalogPath();
1438
1465
  let config: CodexRestoreConfigResult;
1439
1466
  let transitionReceipt: { nativeGeneration: number; currentTxId: string } | undefined;
1440
1467
 
@@ -1511,7 +1538,7 @@ export async function restoreNativeCodexAsync(
1511
1538
  config = restoreCodexConfigInline();
1512
1539
  }
1513
1540
 
1514
- const catalog = restoreCodexCatalogArtifact(options.revalidateDesiredState === true);
1541
+ const catalog = restoreCodexCatalogArtifact(options.revalidateDesiredState === true, journaledCatalogPath);
1515
1542
  const outcome = await runCodexHistoryJob({
1516
1543
  ...resolveCodexHistoryJobTarget(),
1517
1544
  ...(options.revalidateDesiredState ? { expectedDesiredEnabled: false } : {}),
@@ -1561,8 +1588,12 @@ export function restoreNativeCodex(options: { skipHistory?: boolean; revalidateD
1561
1588
  if (options.revalidateDesiredState && shouldSyncCodexOnStart(loadConfig())) {
1562
1589
  return desiredEnabledRestoreSkip();
1563
1590
  }
1591
+ // Captured before the config half: a successful journal restore DELETES the journal, and
1592
+ // restoring the config can drop `model_catalog_json`. Either one would hide the routed
1593
+ // catalog we actually wrote (#1798).
1594
+ const journaledCatalogPath = journaledInjectedCatalogPath();
1564
1595
  const config = restoreCodexConfigInline();
1565
- const catalog = restoreCodexCatalogArtifact(options.revalidateDesiredState === true);
1596
+ const catalog = restoreCodexCatalogArtifact(options.revalidateDesiredState === true, journaledCatalogPath);
1566
1597
  // Design B (loopback) steady state: threads are already tagged openai, so prove the
1567
1598
  // no-op with a readonly probe instead of write-opening a DB the Codex app may hold
1568
1599
  // (Windows: WAL writer lock -> seconds of stalling + a false warning on every stop).
@@ -50,6 +50,34 @@ export function providerTableString(content: string, provider: string, key: stri
50
50
  return null;
51
51
  }
52
52
 
53
+ /**
54
+ * Drop a root `openai_base_url` whose VALUE is the one a recorded injection wrote.
55
+ *
56
+ * #1798: the marker-adjacency rule below is formatting evidence, and the Codex app
57
+ * reserializes the file -- values kept, comments dropped. This rule is value evidence
58
+ * instead, so it still recognizes our URL after that rewrite. It is deliberately an
59
+ * EXACT value match against what we recorded writing: a user gateway we never wrote
60
+ * cannot match, so restore can never delete a URL that was not ours.
61
+ */
62
+ export function stripJournaledOpenaiBaseUrl(content: string, injectedUrl: string | null): string {
63
+ if (!injectedUrl) return content;
64
+ const lines = content.split(String.fromCharCode(10));
65
+ const firstTable = lines.findIndex(l => /^\s*\[/.test(l));
66
+ const rootEnd = firstTable === -1 ? lines.length : firstTable;
67
+ const drop = new Set<number>();
68
+ for (let i = 0; i < rootEnd; i++) {
69
+ const line = lines[i]!;
70
+ if (!isRootOpenaiBaseUrlLine(line)) continue;
71
+ if (rootTomlString(line, "openai_base_url") !== injectedUrl) continue;
72
+ drop.add(i);
73
+ // Take an ownership marker directly above it too, so repeated cycles cannot
74
+ // accumulate orphaned comments.
75
+ if (i > 0 && lines[i - 1]!.includes(OCX_SECTION_MARKER)) drop.add(i - 1);
76
+ }
77
+ if (drop.size === 0) return content;
78
+ return lines.filter((_, i) => !drop.has(i)).join(String.fromCharCode(10));
79
+ }
80
+
53
81
  export function hasInjectedOpenaiBaseUrl(content: string): boolean {
54
82
  const lines = content.split("\n");
55
83
  const firstTable = lines.findIndex(l => /^\s*\[/.test(l));
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { existsSync, readFileSync, unlinkSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { atomicWriteFile } from "../config";
5
- import { hasInjectedCodexRouting } from "./injected-marker";
5
+ import { hasInjectedCodexRouting, rootTomlString } from "./injected-marker";
6
6
  import { CODEX_HOME, CODEX_CONFIG_PATH, CODEX_PROFILE_PATH } from "./paths";
7
7
 
8
8
  /**
@@ -22,6 +22,24 @@ interface Journal {
22
22
  originalProfile: string | null;
23
23
  injectedConfigHash?: string;
24
24
  injectedProfileHash?: string | null;
25
+ /**
26
+ * The exact root `openai_base_url` this injection wrote, when it wrote one.
27
+ *
28
+ * #1798: ownership used to be inferred from a marker COMMENT on the preceding line,
29
+ * which a reserializing Codex app deletes while keeping the value. Recording the value
30
+ * we actually wrote makes ownership provable from evidence rather than from formatting,
31
+ * and it is what lets restore tell OUR loopback URL apart from a gateway the user set.
32
+ */
33
+ injectedOpenaiBaseUrl?: string | null;
34
+ /**
35
+ * The catalog path this injection actually wrote to.
36
+ *
37
+ * #1798: restore re-resolves the catalog from the CURRENT config, so a Codex app rewrite
38
+ * that dropped `model_catalog_json` sends restore to the default catalog while the
39
+ * proxy-written one is left routed. The injected path is the only durable record of which
40
+ * file we actually touched.
41
+ */
42
+ injectedCatalogPath?: string | null;
25
43
  pid: number;
26
44
  timestamp: string;
27
45
  }
@@ -96,9 +114,30 @@ export function markJournalInjectedState(config: string, profile: string | null)
96
114
  if (journal.injectedConfigHash) return;
97
115
  journal.injectedConfigHash = sha256(config) ?? undefined;
98
116
  journal.injectedProfileHash = sha256(profile);
117
+ // Read from the bytes we are about to install, not from the file: another writer may
118
+ // already have rewritten it, and then the recorded value would describe their config.
119
+ journal.injectedOpenaiBaseUrl = rootTomlString(config, "openai_base_url");
120
+ journal.injectedCatalogPath = rootTomlString(config, "model_catalog_json");
99
121
  atomicWriteFile(JOURNAL_PATH, JSON.stringify(journal));
100
122
  }
101
123
 
124
+ /**
125
+ * The root `openai_base_url` the last injection wrote, or null when it wrote none.
126
+ *
127
+ * #1798: the fallback strip recognizes an injected URL by the marker COMMENT above it,
128
+ * and a Codex app rewrite keeps values while dropping comments. This is the evidence that
129
+ * survives such a rewrite, so restore can still prove the URL is ours -- and, just as
130
+ * importantly, prove that a DIFFERENT URL is not.
131
+ */
132
+ export function journaledInjectedOpenaiBaseUrl(): string | null {
133
+ return readJournal()?.injectedOpenaiBaseUrl ?? null;
134
+ }
135
+
136
+ /** The catalog path the last injection wrote to, or null when none was recorded. */
137
+ export function journaledInjectedCatalogPath(): string | null {
138
+ return readJournal()?.injectedCatalogPath ?? null;
139
+ }
140
+
102
141
  export function removeJournal(): void {
103
142
  try { unlinkSync(JOURNAL_PATH); } catch { /* ignore */ }
104
143
  }