@bitkyc08/opencodex 2.77.0 → 2.78.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 (222) hide show
  1. package/bin/ocx.mjs +11 -10
  2. package/gui/dist/assets/App-BtfG5id-.js +54 -0
  3. package/gui/dist/assets/App-lCIslZ1L.css +1 -0
  4. package/gui/dist/assets/{Tray-gloIYNaW.js → Tray-DiJpSub0.js} +1 -1
  5. package/gui/dist/assets/index-Btc3Ogw4.js +94 -0
  6. package/gui/dist/assets/index-DmQB8D_R.css +1 -0
  7. package/gui/dist/assets/quota-summary-B_a96nXj.js +1 -0
  8. package/gui/dist/index.html +2 -2
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic/beta-allowlist.ts +19 -2
  11. package/src/adapters/anthropic/client-identity.ts +13 -4
  12. package/src/adapters/anthropic/native-client-preamble.ts +31 -0
  13. package/src/adapters/anthropic/passthrough.ts +103 -25
  14. package/src/adapters/devin/cloud-direct/chat.ts +7 -2
  15. package/src/adapters/devin/trajectory.ts +49 -0
  16. package/src/adapters/devin.ts +13 -0
  17. package/src/adapters/ollama-native.ts +35 -19
  18. package/src/claude/intercept/client-hello.ts +103 -0
  19. package/src/claude/intercept/model-bindings.ts +3 -2
  20. package/src/claude/intercept/picker-listener.ts +279 -66
  21. package/src/cli/access-audio-input.ts +84 -0
  22. package/src/cli/access-audio-live.ts +99 -0
  23. package/src/cli/access-audio.ts +123 -0
  24. package/src/cli/access-data-client.ts +127 -0
  25. package/src/cli/access-data-plane.ts +104 -0
  26. package/src/cli/access-data-response.ts +76 -0
  27. package/src/cli/access.ts +65 -2
  28. package/src/cli/account-api.ts +30 -6
  29. package/src/cli/account-auth.ts +111 -42
  30. package/src/cli/account-key-quota.ts +118 -0
  31. package/src/cli/account-main.ts +11 -4
  32. package/src/cli/account-next-actions.ts +63 -0
  33. package/src/cli/account-policy-dto.ts +68 -0
  34. package/src/cli/account-policy.ts +167 -0
  35. package/src/cli/account-target.ts +8 -0
  36. package/src/cli/account.ts +34 -8
  37. package/src/cli/agent-runtime-settings.ts +130 -0
  38. package/src/cli/agent-settings.ts +101 -0
  39. package/src/cli/agent.ts +19 -2
  40. package/src/cli/alias.ts +7 -2
  41. package/src/cli/aside-profiles.ts +2 -2
  42. package/src/cli/capabilities-access-remote.ts +147 -0
  43. package/src/cli/capabilities-accounts.ts +195 -0
  44. package/src/cli/capabilities-agents-routing.ts +414 -0
  45. package/src/cli/capabilities-base.ts +783 -0
  46. package/src/cli/capabilities-command.ts +1 -0
  47. package/src/cli/capabilities-integrations.ts +264 -0
  48. package/src/cli/capabilities-lab.ts +214 -0
  49. package/src/cli/capabilities-observe-system.ts +662 -0
  50. package/src/cli/capabilities-provider-models.ts +302 -0
  51. package/src/cli/capabilities.ts +30 -1199
  52. package/src/cli/capability-types.ts +54 -0
  53. package/src/cli/catalog-command-result.ts +57 -0
  54. package/src/cli/chatgpt-command.ts +25 -2
  55. package/src/cli/claude-agent-startup-sync.ts +17 -0
  56. package/src/cli/claude-desktop-profile.ts +100 -0
  57. package/src/cli/claude-desktop.ts +39 -6
  58. package/src/cli/codex-shim-autorestore.ts +2 -1
  59. package/src/cli/codex-shim-readiness.ts +23 -3
  60. package/src/cli/combo-input.ts +88 -0
  61. package/src/cli/combo-result.ts +52 -0
  62. package/src/cli/combo-stats.ts +123 -0
  63. package/src/cli/combo.ts +37 -14
  64. package/src/cli/companion-timeline.ts +142 -0
  65. package/src/cli/companion-usage.ts +146 -0
  66. package/src/cli/companion.ts +12 -3
  67. package/src/cli/config-command.ts +24 -8
  68. package/src/cli/dispatch.ts +52 -79
  69. package/src/cli/doctor.ts +22 -4
  70. package/src/cli/help-catalog.ts +31 -14
  71. package/src/cli/help-recovery.ts +2 -2
  72. package/src/cli/help.ts +25 -11
  73. package/src/cli/index.ts +38 -27
  74. package/src/cli/injection-follow.ts +35 -0
  75. package/src/cli/inspect.ts +5 -0
  76. package/src/cli/integration-aside-sync.ts +69 -0
  77. package/src/cli/integration-cursor.ts +106 -0
  78. package/src/cli/integration-input.ts +60 -0
  79. package/src/cli/integration-journal.ts +49 -0
  80. package/src/cli/integration-plan-dto.ts +156 -0
  81. package/src/cli/integration-preview.ts +215 -0
  82. package/src/cli/integrations.ts +24 -18
  83. package/src/cli/json-input.ts +134 -0
  84. package/src/cli/link.ts +32 -4
  85. package/src/cli/local-sync-result.ts +41 -0
  86. package/src/cli/log-follow.ts +84 -0
  87. package/src/cli/log-view-filter.ts +146 -0
  88. package/src/cli/logout-command.ts +44 -0
  89. package/src/cli/model-picker-ordering.ts +137 -0
  90. package/src/cli/models-custom-input.ts +107 -0
  91. package/src/cli/models-custom-runtime.ts +112 -0
  92. package/src/cli/models-order.ts +194 -0
  93. package/src/cli/models-runtime-subcommands.ts +2 -0
  94. package/src/cli/models-runtime.ts +7 -0
  95. package/src/cli/models.ts +66 -81
  96. package/src/cli/observe-stream.ts +142 -0
  97. package/src/cli/observe.ts +75 -34
  98. package/src/cli/provider-batch.ts +77 -0
  99. package/src/cli/provider-lifecycle-runtime.ts +170 -0
  100. package/src/cli/provider-result.ts +124 -0
  101. package/src/cli/provider-runtime.ts +15 -12
  102. package/src/cli/provider-settings.ts +143 -0
  103. package/src/cli/provider.ts +112 -34
  104. package/src/cli/registry.ts +62 -20
  105. package/src/cli/remote-workspace-hub.ts +83 -0
  106. package/src/cli/remote-workspace.ts +6 -1
  107. package/src/cli/restart-failure.ts +27 -0
  108. package/src/cli/root.ts +12 -0
  109. package/src/cli/route-policy-write.ts +137 -0
  110. package/src/cli/route-policy.ts +8 -0
  111. package/src/cli/runtime-api.ts +56 -79
  112. package/src/cli/secret-args.ts +149 -0
  113. package/src/cli/settings-result.ts +30 -0
  114. package/src/cli/storage.ts +77 -7
  115. package/src/cli/system-command.ts +60 -2
  116. package/src/cli/system-restart-client.ts +20 -3
  117. package/src/cli/system-settings-parity.ts +83 -0
  118. package/src/cli/update-restart-candidate.ts +21 -0
  119. package/src/cli/update-restart-child.ts +81 -0
  120. package/src/cli/update-restart-home.ts +38 -0
  121. package/src/cli/update-restart-transport.ts +193 -0
  122. package/src/cli/update-restart.ts +260 -0
  123. package/src/cli/usage-model-search.ts +58 -0
  124. package/src/cli/usage-report.ts +13 -5
  125. package/src/cli/v2-input.ts +66 -0
  126. package/src/cli/v2-local-output.ts +37 -0
  127. package/src/cli/v2-runtime.ts +90 -0
  128. package/src/cli/v2.ts +102 -144
  129. package/src/clients/config-export/dsh.ts +21 -0
  130. package/src/clients/config-export.ts +7 -1
  131. package/src/codex/catalog/build-entries.ts +1 -1
  132. package/src/codex/catalog/bundled.ts +9 -2
  133. package/src/codex/catalog/heal-observation.ts +66 -0
  134. package/src/codex/catalog/publication-observer.ts +19 -0
  135. package/src/codex/catalog/remote.ts +13 -15
  136. package/src/codex/catalog/restore.ts +19 -6
  137. package/src/codex/catalog/retained-sync.ts +58 -10
  138. package/src/codex/catalog/routed-removal.ts +116 -0
  139. package/src/codex/catalog/write-audit-contract.ts +21 -0
  140. package/src/codex/catalog/write-audit.ts +171 -0
  141. package/src/codex/catalog-self-heal.ts +284 -0
  142. package/src/codex/catalog-write-serialization.ts +71 -7
  143. package/src/codex/codex-home-owner.ts +124 -0
  144. package/src/codex/convergence.ts +55 -15
  145. package/src/codex/features.ts +8 -1
  146. package/src/codex/inject/restore.ts +46 -4
  147. package/src/codex/inject.ts +11 -1
  148. package/src/codex/internal/catalog-writer.ts +120 -13
  149. package/src/codex/journal.ts +49 -13
  150. package/src/codex/management-convergence.ts +3 -0
  151. package/src/codex/quota-types.ts +3 -0
  152. package/src/codex/quota.ts +13 -3
  153. package/src/codex/refresh.ts +20 -9
  154. package/src/codex/runtime.ts +7 -1
  155. package/src/codex/shim-diagnostics.ts +80 -0
  156. package/src/codex/shim-fingerprint.ts +7 -2
  157. package/src/codex/shim-migration.ts +153 -0
  158. package/src/codex/shim-overlay.ts +509 -0
  159. package/src/codex/shim-path-resolution.ts +14 -2
  160. package/src/codex/shim-probe.ts +4 -3
  161. package/src/codex/shim-state-file.ts +93 -7
  162. package/src/codex/shim.ts +15 -68
  163. package/src/codex/sync.ts +24 -4
  164. package/src/combos/failover.ts +19 -0
  165. package/src/combos/index.ts +1 -0
  166. package/src/combos/resolve.ts +12 -0
  167. package/src/config/atomic-write.ts +13 -8
  168. package/src/config/diagnostics.ts +19 -1
  169. package/src/config/load-degrade.ts +3 -0
  170. package/src/config/schema/chatgpt-desktop.ts +22 -0
  171. package/src/config/schema/config-schema.ts +32 -14
  172. package/src/config/schema/leaf-validators.ts +3 -2
  173. package/src/generated/compatibility-version.json +445 -129
  174. package/src/integrations/merge.ts +13 -5
  175. package/src/integrations/mutation-plan.ts +46 -3
  176. package/src/integrations/omp-yaml-source.ts +181 -1
  177. package/src/integrations/ownership-policy.ts +66 -0
  178. package/src/integrations/registry.ts +50 -1
  179. package/src/integrations/state.ts +46 -21
  180. package/src/integrations/target.ts +25 -2
  181. package/src/integrations/writer.ts +89 -31
  182. package/src/lib/package-tree-integrity.ts +42 -1
  183. package/src/lib/proxy-env.ts +3 -0
  184. package/src/lib/test-home-guard.ts +2 -1
  185. package/src/oauth/anthropic-account-refusal.ts +19 -8
  186. package/src/oauth/anthropic-routing.ts +17 -12
  187. package/src/oauth/anthropic.ts +27 -2
  188. package/src/oauth/index.ts +6 -8
  189. package/src/oauth/pool-settings-capability.ts +9 -2
  190. package/src/protocols/plan-snapshot.ts +1 -1
  191. package/src/protocols/settings.ts +28 -4
  192. package/src/server/caller-session-identity.ts +32 -0
  193. package/src/server/claude-messages.ts +11 -4
  194. package/src/server/index/serve-options.ts +12 -7
  195. package/src/server/index.ts +1 -1
  196. package/src/server/management/agent-settings-routes.ts +6 -5
  197. package/src/server/management/context.ts +8 -1
  198. package/src/server/management/cursor-integration-routes.ts +3 -3
  199. package/src/server/management/integration-routes.ts +5 -5
  200. package/src/server/management/model-routes.ts +3 -3
  201. package/src/server/management/native-integration-routes.ts +2 -2
  202. package/src/server/management/oauth-account-routes.ts +96 -30
  203. package/src/server/management/route-registry.ts +25 -24
  204. package/src/server/messages-native-eligibility.ts +3 -6
  205. package/src/server/messages-native-oauth.ts +82 -35
  206. package/src/server/messages-native.ts +78 -16
  207. package/src/server/responses/adapter-dispatch.ts +53 -28
  208. package/src/server/responses/core-auth.ts +10 -4
  209. package/src/server/responses/core-combo.ts +46 -6
  210. package/src/service/cli.ts +6 -1
  211. package/src/service/ownership-mutation-lease.d.mts +26 -0
  212. package/src/service/ownership-mutation-lease.mjs +76 -1
  213. package/src/types/config.ts +2 -0
  214. package/src/types/provider.ts +10 -10
  215. package/src/update/index.ts +2 -1
  216. package/src/update/update-failure-guidance.d.mts +6 -0
  217. package/src/update/update-failure-guidance.mjs +30 -5
  218. package/gui/dist/assets/App-COdEeqSj.css +0 -1
  219. package/gui/dist/assets/App-DEpCJEP_.js +0 -54
  220. package/gui/dist/assets/index-Cd7ittMv.css +0 -1
  221. package/gui/dist/assets/index-DwJKFJm5.js +0 -94
  222. package/gui/dist/assets/quota-summary-DGcEJU3h.js +0 -1
@@ -1,5 +1,6 @@
1
1
  import { projectAntigravitySelectedModels } from "../providers/antigravity-effort-families";
2
2
  import { join } from "node:path";
3
+ import { sameCatalogHealPath } from "./catalog/heal-observation";
3
4
 
4
5
  import { getConfigDir, saveConfigPreservingClaudeCode, websocketsEnabled, withExpectedConfigGenerationSync } from "../config";
5
6
  import { reconcileSuccessfulModelDiscoveries } from "../providers/new-model-policy";
@@ -83,11 +84,20 @@ import {
83
84
  type CodexModelEntitlementSnapshot,
84
85
  } from "./model-entitlements";
85
86
  import { resolveAdmittedCodexModelEntitlements } from "./model-entitlement-admission";
87
+ import {
88
+ FOREIGN_CODEX_HOME_OWNER_MESSAGE,
89
+ UNKNOWN_CODEX_HOME_OWNER_MESSAGE,
90
+ routedRemovalBackedByConfigFile,
91
+ unbackedRoutedRemovalMessage,
92
+ unconfiguredRoutedRemoval,
93
+ type UnconfiguredRoutedRemoval,
94
+ } from "./catalog/routed-removal";
86
95
  import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "./catalog/native-models";
87
96
  import { providerCodexAccountMode } from "../providers/registry";
88
97
  import { OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers";
89
98
  import { withCatalogWriteSerialization } from "./catalog-write-serialization";
90
99
  import {
100
+ auditRefusedCatalogReplacement,
91
101
  publishHashedCodexCatalogBackup,
92
102
  publishLegacyCodexCatalogBackup,
93
103
  replaceActiveCodexCatalog,
@@ -109,14 +119,20 @@ import type {
109
119
  export interface CatalogWriteReceipt {
110
120
  readonly keyedBackup: "written" | "preserved" | "not-requested";
111
121
  readonly legacyBackup: "written" | "preserved" | "not-requested";
112
- readonly catalog: "written" | "not-written";
113
- readonly cache: "written" | "not-written";
122
+ /** `unchanged`: the bytes on disk already matched, so nothing was rewritten. */
123
+ readonly catalog: "written" | "unchanged" | "not-written";
124
+ readonly cache: "written" | "unchanged" | "not-written";
114
125
  }
115
126
 
116
127
  export type CodexCatalogCommitResult =
117
128
  | { readonly kind: "committed"; readonly changed: boolean; readonly writes: CatalogWriteReceipt }
118
129
  | { readonly kind: "stale"; readonly reason: "generation" | "home-selection" | "source-observation" | "process-local" | "target-identity" | "candidate-consumed" | "account-entitlement" }
119
- | { readonly kind: "refused"; readonly reason: "source-unreadable" | "source-ambiguous" | "target-unsafe" }
130
+ /**
131
+ * `unbacked-routed-removal`: the candidate drops routed namespaces config.json does not back, or
132
+ * clears every routed row while config.json is unreadable. `foreign-owner`: this Codex home is
133
+ * bound to another OPENCODEX_HOME (#6529).
134
+ */
135
+ | { readonly kind: "refused"; readonly reason: "source-unreadable" | "source-ambiguous" | "target-unsafe" | "unbacked-routed-removal" | "foreign-owner" | "owner-unknown" }
120
136
  | { readonly kind: "failed"; readonly surface: "disk"; readonly writes: CatalogWriteReceipt };
121
137
 
122
138
  declare const catalogCandidateBrand: unique symbol;
@@ -140,10 +156,11 @@ interface CandidateState {
140
156
  readonly cache: PreparedCatalogFileWrite;
141
157
  readonly keyedBackup?: PreparedCatalogFileWrite;
142
158
  readonly legacyBackup?: PreparedCatalogFileWrite;
143
- readonly changed: boolean;
144
159
  readonly notices: readonly CatalogNotice[];
145
160
  readonly modelEntitlements: CodexModelEntitlementSnapshot;
146
161
  readonly discoveryConfig?: OcxConfig;
162
+ /** Routed namespaces this candidate empties only because the driving config lacks them. */
163
+ readonly routedRemoval: UnconfiguredRoutedRemoval | null;
147
164
  }
148
165
 
149
166
  const candidateStates = new WeakMap<object, CandidateState>();
@@ -537,11 +554,10 @@ export async function gatherCodexCatalogCandidate(
537
554
  ...(pristineBytes ? { keyedBackup: { path: paths.keyedBackup, content: pristineBytes } } : {}),
538
555
  ...(pristineBytes && paths.legacyBackup
539
556
  ? { legacyBackup: { path: paths.legacyBackup, content: pristineBytes } } : {}),
540
- changed: Buffer.from(activeBytes ?? []).toString("utf8") !== preparedCatalogBytes
541
- || Buffer.from(cacheBytes ?? []).toString("utf8") !== preparedCacheBytes,
542
557
  notices: Object.freeze([...notices]),
543
558
  modelEntitlements,
544
559
  ...(discoveryChanged ? { discoveryConfig } : {}),
560
+ routedRemoval: unconfiguredRoutedRemoval(active, preparedCatalog, snapshot.config),
545
561
  });
546
562
  return { kind: "candidate", candidate };
547
563
  } catch (error) {
@@ -626,11 +642,14 @@ function fixedCommit(state: CandidateState, permit: Parameters<typeof replaceAct
626
642
  if (state.legacyBackup) {
627
643
  writes = { ...writes, legacyBackup: publishLegacyCodexCatalogBackup(permit, state.home, state.legacyBackup) };
628
644
  }
629
- replaceActiveCodexCatalog(permit, state.home, state.catalog);
630
- writes = { ...writes, catalog: "written" };
631
- replaceCodexModelsCache(permit, state.home, state.cache);
632
- writes = { ...writes, cache: "written" };
633
- return { kind: "committed", changed: state.changed, writes };
645
+ const catalog = replaceActiveCodexCatalog(permit, state.home, state.catalog);
646
+ // The funnel's backstop: every routed row would go while config.json is not a readable file.
647
+ // Nothing past the pristine backups was written, and the cache must not describe a catalog
648
+ // that was never published.
649
+ if (catalog.kind === "refused") return { kind: "refused", reason: "unbacked-routed-removal" };
650
+ writes = { ...writes, catalog: catalog.kind };
651
+ writes = { ...writes, cache: replaceCodexModelsCache(permit, state.home, state.cache).kind };
652
+ return { kind: "committed", changed: writes.catalog === "written" || writes.cache === "written", writes };
634
653
  } catch {
635
654
  return { kind: "failed", surface: "disk", writes };
636
655
  }
@@ -639,6 +658,7 @@ function fixedCommit(state: CandidateState, permit: Parameters<typeof replaceAct
639
658
  export async function commitCodexCatalogCandidate(
640
659
  candidate: CodexCatalogCandidate,
641
660
  deadlineMs: number,
661
+ lifecycle: Readonly<{ beforeCommit?: () => boolean; expectedCatalogPath?: string }> = {},
642
662
  ): Promise<CommitAttempt> {
643
663
  const state = candidateStates.get(candidate as object);
644
664
  if (!state) return { kind: "refused", reason: "source-ambiguous" };
@@ -649,13 +669,33 @@ export async function commitCodexCatalogCandidate(
649
669
  state.consumed = true;
650
670
  const guarded = withExpectedConfigGenerationSync(state.generation, () => {
651
671
  const invalid = revalidateCandidate(state);
652
- return invalid ?? fixedCommit(state, permit);
672
+ if (invalid) return invalid;
673
+ if (lifecycle.expectedCatalogPath !== undefined && !sameCatalogHealPath(state.catalog.path, lifecycle.expectedCatalogPath)) {
674
+ return { kind: "stale", reason: "target-identity" } as const;
675
+ }
676
+ if (lifecycle.beforeCommit && lifecycle.beforeCommit() !== true) return { kind: "stale", reason: "process-local" } as const;
677
+ // A refresh may empty a routed namespace only when config.json on disk agrees it is gone.
678
+ // Read under K, so a config that fell back to defaults during a transient read failure,
679
+ // or that belongs to another OPENCODEX_HOME, cannot publish a native-only catalog (#6529).
680
+ if (state.routedRemoval !== null && !routedRemovalBackedByConfigFile(state.routedRemoval)) {
681
+ auditRefusedCatalogReplacement(permit, state.home, state.catalog, "unbacked-routed-removal");
682
+ console.warn(`[opencodex] ${unbackedRoutedRemovalMessage(state.routedRemoval.namespaces.length)}`);
683
+ return { kind: "refused", reason: "unbacked-routed-removal" } as const;
684
+ }
685
+ return fixedCommit(state, permit);
653
686
  });
654
687
  if (guarded.kind === "conflict") return { kind: "stale", reason: "generation" } as const;
655
688
  if (guarded.kind === "unavailable") return { kind: "busy" } as const;
656
689
  return guarded.value;
657
- });
690
+ }, { intent: "refresh", writer: "convergence" });
658
691
  if (acquired.kind === "completed") return acquired.value;
692
+ if (acquired.reason === "foreign-owner" || acquired.reason === "owner-unknown") {
693
+ // Not retryable: another OPENCODEX_HOME owns this Codex home until it restores (#6529).
694
+ state.consumed = true;
695
+ console.warn(`[opencodex] ${acquired.reason === "foreign-owner"
696
+ ? FOREIGN_CODEX_HOME_OWNER_MESSAGE : UNKNOWN_CODEX_HOME_OWNER_MESSAGE}`);
697
+ return { kind: "refused", reason: acquired.reason };
698
+ }
659
699
  if (acquired.reason !== "busy" || Date.now() >= deadline) return { kind: "busy" };
660
700
  await Bun.sleep(Math.min(10, Math.max(1, deadline - Date.now())));
661
701
  }
@@ -676,7 +716,7 @@ function projectCommit(result: CommitAttempt, notices: readonly CatalogNotice[])
676
716
  export async function convergeCodexCatalog(
677
717
  snapshot: CatalogAdmissionSnapshot,
678
718
  request: ConvergeRequest,
679
- lifecycle: Readonly<{ onCommitBegin?: () => void }> = {},
719
+ lifecycle: Readonly<{ onCommitBegin?: () => void; beforeCommit?: () => boolean; expectedCatalogPath?: string }> = {},
680
720
  ): Promise<Readonly<{ changed: boolean; catalogRefresh: CatalogDisposition }>> {
681
721
  if (request.scope !== "catalog" || request.action !== "converge") {
682
722
  return {
@@ -688,7 +728,7 @@ export async function convergeCodexCatalog(
688
728
  if (gathered.kind === "disposition") return { changed: false, catalogRefresh: gathered.disposition };
689
729
  const state = candidateStates.get(gathered.candidate as object)!;
690
730
  lifecycle.onCommitBegin?.();
691
- const committed = await commitCodexCatalogCandidate(gathered.candidate, request.deadlineMs);
731
+ const committed = await commitCodexCatalogCandidate(gathered.candidate, request.deadlineMs, lifecycle);
692
732
  if (committed.kind === "committed" && state.discoveryConfig) {
693
733
  const mutable = snapshot.config as OcxConfig;
694
734
  mutable.modelDiscovery = state.discoveryConfig.modelDiscovery;
@@ -38,6 +38,7 @@ import { AtomicWriteResidualTempError, AtomicWriteSecretResidualError, atomicWri
38
38
  import { forgetEphemeralSecretPath } from "../lib/windows-secret-acl";
39
39
  import { CODEX_CONFIG_PATH } from "./paths";
40
40
  import { resolveAndPersistCodexRuntime } from "./runtime";
41
+ import { decodeOverlayState } from "./shim-state-file";
41
42
  import { canonicalizeOpenCodexModeHint } from "./multi-agent-mode-policy";
42
43
 
43
44
  /** Upstream codex-rs feature key: allow `request_user_input` in Default mode. */
@@ -1232,13 +1233,19 @@ function codexNativeBinaryCandidates(command: string): string[] {
1232
1233
  /** Backing paths tied to this exact OCX shim entry in codex-shim.json. */
1233
1234
  function selectedShimBackingPaths(commandPath: string): string[] {
1234
1235
  try {
1235
- const state = JSON.parse(readFileSync(join(getConfigDir(), "codex-shim.json"), "utf8")) as {
1236
+ const configDir = getConfigDir();
1237
+ const state = JSON.parse(readFileSync(join(configDir, "codex-shim.json"), "utf8")) as {
1238
+ schema?: unknown;
1239
+ mode?: unknown;
1236
1240
  wrapperPath?: unknown;
1237
1241
  originalPath?: unknown;
1238
1242
  backupPath?: unknown;
1239
1243
  realPath?: unknown;
1240
1244
  wrappers?: Array<Record<string, unknown>>;
1241
1245
  };
1246
+ const overlay = decodeOverlayState(state, configDir);
1247
+ if (overlay) return resolve(overlay.wrapperPath) === resolve(commandPath) ? [overlay.launcherPath] : [];
1248
+ if (state.schema !== undefined || state.mode !== undefined) return [];
1242
1249
  const entries = Array.isArray(state.wrappers) && state.wrappers.length > 0 ? state.wrappers : [state];
1243
1250
  const selected = resolve(commandPath);
1244
1251
  for (const entry of entries) {
@@ -1,8 +1,11 @@
1
+ import { assertCodexHomeOwner, codexHomeOwnerBlocksCompensation, CodexHomeOwnerRefusal, type CodexHomeOwnerRefusalReason } from "../codex-home-owner";
1
2
  import { loadConfig } from "../../config";
2
3
  import { shouldSyncCodexOnStart } from "../desired-state";
3
4
  import { siblingOfLivePort, siblingSkipMessage } from "../sibling-start";
4
5
  import { withCatalogWriteSerialization } from "../catalog-write-serialization";
5
6
  import { restoreCodexCatalogWithPermit } from "../catalog/sync";
7
+ import { readCodexCatalogPath } from "../catalog/parsing";
8
+ import { notifyCatalogPublication } from "../catalog/publication-observer";
6
9
  import { withCodexWriteLock, CodexWriteLockSkipped } from "../codex-write-lock";
7
10
  import { inspectNativeCodexOwnership } from "../../integrations/native/ownership-preflight";
8
11
  import { resolveCodexHistoryTransition } from "../history-transition";
@@ -24,6 +27,7 @@ import {
24
27
  import {
25
28
  journaledInjectedCatalogPath,
26
29
  removeJournal,
30
+ releaseJournalHomeBinding,
27
31
  restoreJournalState,
28
32
  } from "../journal";
29
33
  import {
@@ -128,6 +132,7 @@ export interface CodexRestoreHistoryResult {
128
132
  }
129
133
 
130
134
  export interface CodexNativeRestoreResult {
135
+ ownershipRefusal?: CodexHomeOwnerRefusalReason;
131
136
  success: boolean;
132
137
  message: string;
133
138
  externalProvider?: string;
@@ -339,6 +344,9 @@ export interface RestoreConfigOptions {
339
344
 
340
345
  /** The config/profile half of a native restore, reported as one artifact. */
341
346
  function restoreCodexConfigInline(kind = "sync", options: RestoreConfigOptions = {}): CodexRestoreConfigResult {
347
+ assertCodexHomeOwner(getCodexHome());
348
+ beforeRestoreConfigForTests?.(kind);
349
+ assertCodexHomeOwner(getCodexHome());
342
350
  const preImages = captureCodexPreImages();
343
351
  const result = restoreCodexConfigInlineImpl(kind, options);
344
352
  if (result.state === "failed") {
@@ -350,7 +358,6 @@ function restoreCodexConfigInline(kind = "sync", options: RestoreConfigOptions =
350
358
 
351
359
  function restoreCodexConfigInlineImpl(kind: string, options: RestoreConfigOptions): CodexRestoreConfigResult {
352
360
  try {
353
- beforeRestoreConfigForTests?.(kind);
354
361
  const disposition = resolveRestoreHistoryDisposition(options.removeProviderTable);
355
362
  if (disposition.kind === "refuse") {
356
363
  return { state: "failed", changed: false, action: "failed", message: `Codex configuration and journal preserved: ${disposition.reason}.` };
@@ -368,12 +375,20 @@ function restoreCodexConfigInlineImpl(kind: string, options: RestoreConfigOption
368
375
  // accepting that tagged conversations stop opening.
369
376
  const capturedBlock = options.removeProviderTable === true ? null : readOcxProviderTableBlock();
370
377
  const journal = restoreJournalState();
378
+ if (journal.ownershipRefusal) throw new CodexHomeOwnerRefusal(journal.ownershipRefusal);
371
379
  if (journal.unverified) {
372
380
  return {
373
381
  state: "failed", changed: false, action: "failed",
374
382
  message: "Codex journal recovery was not verified; current configuration files and the journal were preserved.",
375
383
  };
376
384
  }
385
+ if (journal.profileRestoreFailed) {
386
+ return {
387
+ state: "failed", changed: false, action: "failed",
388
+ message: "Codex profile could not be restored; native files and the journal binding were preserved.",
389
+ };
390
+ }
391
+ assertCodexHomeOwner(getCodexHome());
377
392
  const restored = journal.configRestored
378
393
  ? { success: true, message: "Codex config restored from opencodex journal.", retainedProviderTable: undefined as string[] | undefined }
379
394
  : removeCodexConfig({
@@ -403,6 +418,9 @@ function restoreCodexConfigInlineImpl(kind: string, options: RestoreConfigOption
403
418
  if (settled.kind === "stand-down" && settled.retainProviderTable && capturedBlock !== null) {
404
419
  retainedLines = retainOcxProviderTableOnDisk(capturedBlock) ?? retainedLines;
405
420
  }
421
+ // All native work and the final migration check succeeded. A field-level
422
+ // fallback retains its snapshot, but no longer owns this Codex home.
423
+ releaseJournalHomeBinding();
406
424
  }
407
425
  if (restored.success && retainedLines !== null) {
408
426
  return {
@@ -430,6 +448,7 @@ function restoreCodexConfigInlineImpl(kind: string, options: RestoreConfigOption
430
448
  }
431
449
  : { state: "failed", changed: false, action: "failed", message: restored.message };
432
450
  } catch (error) {
451
+ if (error instanceof CodexHomeOwnerRefusal) throw error;
433
452
  return { state: "failed", changed: false, action: "failed", message: error instanceof Error ? error.message : String(error) };
434
453
  }
435
454
  }
@@ -451,7 +470,8 @@ function restoreCodexCatalogArtifact(
451
470
  const restored = withCatalogWriteSerialization(owningCodexHome, permit =>
452
471
  revalidateDesiredState && shouldSyncCodexOnStart(loadConfig())
453
472
  ? null
454
- : restoreCodexCatalogWithPermit(permit, owningCodexHome, journaledCatalogPath));
473
+ : restoreCodexCatalogWithPermit(permit, owningCodexHome, journaledCatalogPath),
474
+ { intent: "restore", writer: "codex-restore" });
455
475
  return restored.kind === "completed" && restored.value !== null
456
476
  ? { state: "ok", changed: restored.value.removed > 0, ...restored.value, message: "Codex catalog restored." }
457
477
  : restored.kind === "completed"
@@ -487,8 +507,10 @@ export async function restoreNativeCodexAsync(
487
507
  const sibling = siblingRestoreSkip();
488
508
  if (sibling) return sibling;
489
509
  try {
510
+ assertCodexHomeOwner(getCodexHome());
490
511
  return await restoreNativeCodexAsyncImpl(options);
491
512
  } catch (error) {
513
+ if (error instanceof CodexHomeOwnerRefusal) return homeOwnerRestoreRefusal(error);
492
514
  if (!(error instanceof CodexRestoreRefusal)) throw error;
493
515
  return failedConfigRestoreEnvelope(error.config);
494
516
  }
@@ -533,7 +555,7 @@ async function restoreNativeCodexAsyncImpl(
533
555
  // Captured before the config half: a successful journal restore DELETES the journal, and
534
556
  // restoring the config can drop `model_catalog_json`. Either one would hide the routed
535
557
  // catalog we actually wrote (#1798).
536
- const journaledCatalogPath = journaledInjectedCatalogPath();
558
+ const journaledCatalogPath = journaledInjectedCatalogPath() ?? readCodexCatalogPath();
537
559
  let config: CodexRestoreConfigResult;
538
560
  let transitionReceipt: { nativeGeneration: number; currentTxId: string } | undefined;
539
561
 
@@ -549,6 +571,7 @@ async function restoreNativeCodexAsyncImpl(
549
571
  readAdmissionUnderLock: () => witness,
550
572
  },
551
573
  (ctx) => {
574
+ assertCodexHomeOwner(getCodexHome());
552
575
  if (options.revalidateDesiredState && shouldSyncCodexOnStart(loadConfig())) {
553
576
  throw new CodexWriteLockSkipped("desired_enabled");
554
577
  }
@@ -576,6 +599,7 @@ async function restoreNativeCodexAsyncImpl(
576
599
  // Throw inside N so the published remove transition rolls back too.
577
600
  if (restored.state === "failed") throw new CodexRestoreRefusal(restored);
578
601
  } catch (error) {
602
+ if (codexHomeOwnerBlocksCompensation(getCodexHome(), error)) throw error;
579
603
  const compensated = restoreCodexPreImages(preImages);
580
604
  if (!compensated.complete) throw new CodexPartialWriteError(compensated.unrestored);
581
605
  throw error;
@@ -619,6 +643,7 @@ async function restoreNativeCodexAsyncImpl(
619
643
  }
620
644
 
621
645
  if (config.state === "failed") return failedConfigRestoreEnvelope(config);
646
+ notifyCatalogPublication({ kind: "native-released", path: journaledCatalogPath });
622
647
  const catalog = restoreCodexCatalogArtifact(options.revalidateDesiredState === true, journaledCatalogPath);
623
648
  // Re-asked after the config half, because the store can paginate mid-transaction. Deciding
624
649
  // the history job from the pre-write answer alone would spawn a Worker whose own preflight
@@ -681,11 +706,27 @@ async function restoreNativeCodexAsyncImpl(
681
706
  };
682
707
  }
683
708
 
709
+ function homeOwnerRestoreRefusal(error: CodexHomeOwnerRefusal): CodexNativeRestoreResult {
710
+ return { ...skippedRestoreEnvelope(false, error.message), ownershipRefusal: error.reason };
711
+ }
712
+
684
713
  export function restoreNativeCodex(
685
714
  options: { skipHistory?: boolean; revalidateDesiredState?: boolean; removeProviderTable?: boolean } = {},
686
715
  ): CodexNativeRestoreResult {
687
716
  const sibling = siblingRestoreSkip();
688
717
  if (sibling) return sibling;
718
+ try {
719
+ assertCodexHomeOwner(getCodexHome());
720
+ return restoreNativeCodexImpl(options);
721
+ } catch (error) {
722
+ if (error instanceof CodexHomeOwnerRefusal) return homeOwnerRestoreRefusal(error);
723
+ throw error;
724
+ }
725
+ }
726
+
727
+ function restoreNativeCodexImpl(
728
+ options: { skipHistory?: boolean; revalidateDesiredState?: boolean; removeProviderTable?: boolean } = {},
729
+ ): CodexNativeRestoreResult {
689
730
  const activeProvider = currentExternalCodexModelProvider();
690
731
  if (activeProvider) {
691
732
  removeJournal();
@@ -700,9 +741,10 @@ export function restoreNativeCodex(
700
741
  // Captured before the config half: a successful journal restore DELETES the journal, and
701
742
  // restoring the config can drop `model_catalog_json`. Either one would hide the routed
702
743
  // catalog we actually wrote (#1798).
703
- const journaledCatalogPath = journaledInjectedCatalogPath();
744
+ const journaledCatalogPath = journaledInjectedCatalogPath() ?? readCodexCatalogPath();
704
745
  const config = restoreCodexConfigInline("sync", options);
705
746
  if (config.state === "failed") return failedConfigRestoreEnvelope(config);
747
+ notifyCatalogPublication({ kind: "native-released", path: journaledCatalogPath });
706
748
  // Same mid-transaction pagination re-check as the async path.
707
749
  const historyStoodDown = historyStandsDown
708
750
  || resolveRestoreHistoryDisposition(options.removeProviderTable).kind === "stand-down";
@@ -7,6 +7,7 @@ import {
7
7
  readConfigAdmissionSnapshot,
8
8
  withConfigMutationLockSync,
9
9
  } from "../config";
10
+ import { assertCodexHomeOwner, codexHomeOwnerBlocksCompensation, CodexHomeOwnerRefusal, type CodexHomeOwnerRefusalReason } from "./codex-home-owner";
10
11
  import { CodexWriteLockSkipped, withCodexWriteLock } from "./codex-write-lock";
11
12
  import {
12
13
  localClientSkipMessage,
@@ -134,6 +135,7 @@ function runClientWriteGuard(guard: InjectCodexOptions["beforeClientWrite"]): vo
134
135
 
135
136
 
136
137
  export interface CodexInjectResult {
138
+ ownershipRefusal?: CodexHomeOwnerRefusalReason;
137
139
  success: boolean;
138
140
  message: string;
139
141
  /** False when injection intentionally preserves configuration owned by another provider. */
@@ -192,8 +194,9 @@ export async function injectCodexConfig(
192
194
  if (siblingOfLivePort() !== null) {
193
195
  return { success: true, status: "skipped", skippedReason: "sibling", message: siblingSkipMessage() };
194
196
  }
195
- try { return await injectCodexConfigImpl(port, config, options); }
197
+ try { assertCodexHomeOwner(getCodexHome()); return await injectCodexConfigImpl(port, config, options); }
196
198
  catch (error) {
199
+ if (error instanceof CodexHomeOwnerRefusal) return { success: false, ownershipRefusal: error.reason, message: error.message };
197
200
  if (error instanceof CodexHistoryPreflightRefusal) return { success: false, historyPreflightFailureReason: error.message, message: `Codex config injection refused: ${error.message}. Existing configuration and history were preserved.` };
198
201
  if (error instanceof CodexInjectRefusal) return error.result;
199
202
  throw error;
@@ -415,6 +418,7 @@ async function injectCodexConfigImpl(
415
418
  * flip included — before the result is reported.
416
419
  */
417
420
  const reconcileAndDerivePlan = (): { plan: CodexInjectionPlanOk; nativeInput: string } => {
421
+ assertCodexHomeOwner(getCodexHome());
418
422
  if (missingConfig) createEmptyCodexConfigInBoundary();
419
423
  let nativeInput = rawContent;
420
424
  let plan = admittedPlan;
@@ -453,6 +457,7 @@ async function injectCodexConfigImpl(
453
457
  beforeHistoryArtifactCommitForTests?.(eligibility.kind);
454
458
  plan.historyRelabelRefusal = observeHistoryRefusalOrThrow(plan);
455
459
  historyArtifactStageForTests?.("after-preflight");
460
+ assertCodexHomeOwner(getCodexHome());
456
461
  writeJournal({
457
462
  currentStateIsNative: journalBaselineIsNative(nativeInput),
458
463
  configContent: plan.baselineContent,
@@ -463,6 +468,7 @@ async function injectCodexConfigImpl(
463
468
  if (hasUnverifiedJournalBaseline(plan.baselineContent, readCurrentProfile())) throw new Error(unverifiedJournalMessage);
464
469
  atomicWriteFile(CODEX_CONFIG_PATH, plan.content);
465
470
  historyArtifactStageForTests?.("after-config");
471
+ assertCodexHomeOwner(getCodexHome());
466
472
  atomicWriteFile(CODEX_PROFILE_PATH, plan.profileContent);
467
473
  markJournalInjectedState(plan.content, plan.profileContent, {
468
474
  // A root override is ours whenever we wrote one and no user-owned value won. That is
@@ -526,6 +532,7 @@ async function injectCodexConfigImpl(
526
532
  };
527
533
  }
528
534
  runClientWriteGuard(options.beforeClientWrite);
535
+ assertCodexHomeOwner(getCodexHome());
529
536
  /*
530
537
  * One preimage covers the reconcile and the artifact commit together: a
531
538
  * refusal after the feature transition hands back the exact bytes the
@@ -537,6 +544,7 @@ async function injectCodexConfigImpl(
537
544
  applyNativeArtifacts(resolved.plan, resolved.nativeInput);
538
545
  effectivePlan = resolved.plan;
539
546
  } catch (error) {
547
+ if (codexHomeOwnerBlocksCompensation(getCodexHome(), error)) throw error;
540
548
  const restored = restoreCodexPreImages(preImages);
541
549
  if (!restored.complete) throw new CodexPartialWriteError(restored.unrestored);
542
550
  throw error;
@@ -582,6 +590,7 @@ async function injectCodexConfigImpl(
582
590
  // transition or capturing preimages; rejection must not compensate over
583
591
  // a disconnect's restored files.
584
592
  runClientWriteGuard(options.beforeClientWrite);
593
+ assertCodexHomeOwner(getCodexHome());
585
594
  /*
586
595
  * Exact pre-images, captured under the lock and used for compensation.
587
596
  *
@@ -630,6 +639,7 @@ async function injectCodexConfigImpl(
630
639
  }
631
640
  applyNativeArtifacts(resolved.plan, resolved.nativeInput);
632
641
  } catch (error) {
642
+ if (codexHomeOwnerBlocksCompensation(getCodexHome(), error)) throw error;
633
643
  // Compensate, then ALWAYS throw. Returning a partial result would let the
634
644
  // lock commit a row describing an apply that did not finish.
635
645
  const restored = restoreCodexPreImages(preImages);
@@ -5,12 +5,21 @@ import {
5
5
  AtomicWriteResidualTempError,
6
6
  AtomicWriteSecretResidualError,
7
7
  atomicWriteFile,
8
- getConfigDir,
9
8
  resolveWriteTarget,
10
9
  type AtomicWriteIO,
11
- } from "../../config";
10
+ } from "../../config/atomic-write";
11
+ import { getConfigDir } from "../../config/paths";
12
+ import { readConfigAdmissionSnapshot } from "../../config/diagnostics";
13
+ import { ConfigMutationLockError, withConfigMutationLockSync } from "../../config/mutation-lock";
14
+ import { codexCatalogAuditPath } from "../catalog/write-audit";
15
+ import type { CatalogAuditConfigSource, CatalogAuditRefusalReason, CatalogWriteAuditDetails } from "../catalog/write-audit-contract";
16
+ import { ocxRoutedRowCount } from "../catalog/routed-removal";
17
+ import { notifyCatalogPublication } from "../catalog/publication-observer";
12
18
  import {
13
19
  assertCatalogWritePermit,
20
+ auditCatalogWriteWithPermit,
21
+ catalogWritePermitContext,
22
+ CatalogWritePermitRefusal,
14
23
  type CatalogWritePermit,
15
24
  } from "../catalog-write-serialization";
16
25
  import {
@@ -61,14 +70,15 @@ let backupTempSequence = 0;
61
70
  * An unreadable or absent file reports "differs", so the caller performs the real
62
71
  * write; that also converges a file that does not exist yet.
63
72
  */
64
- export function preparedBytesDifferFromDisk(prepared: PreparedCatalogFileWrite): boolean {
65
- let onDisk: Buffer;
66
- try {
67
- onDisk = readFileSync(prepared.path);
68
- } catch {
69
- return true;
70
- }
71
- return !onDisk.equals(Buffer.from(prepared.content, "utf8"));
73
+ function readExistingCatalogBytes(path: string): Buffer | null {
74
+ try { return readFileSync(path); } catch { return null; }
75
+ }
76
+
77
+ export function preparedBytesDifferFromDisk(
78
+ prepared: PreparedCatalogFileWrite,
79
+ onDisk: Buffer | null = readExistingCatalogBytes(prepared.path),
80
+ ): boolean {
81
+ return onDisk === null || !onDisk.equals(Buffer.from(prepared.content, "utf8"));
72
82
  }
73
83
 
74
84
  function isMissingPathError(error: unknown): boolean {
@@ -199,16 +209,105 @@ function publishCatalogBackup(
199
209
  return "written";
200
210
  }
201
211
 
202
- /** Replace the active catalog with the caller's already-prepared bytes. */
212
+ export type CatalogFileReplacement =
213
+ | { readonly kind: "written" }
214
+ | { readonly kind: "unchanged" }
215
+ | { readonly kind: "refused"; readonly reason: "unbacked-routed-clear" };
216
+
217
+ function routedRowsFromJson(content: string): number | null {
218
+ try { return ocxRoutedRowCount(JSON.parse(content)); } catch { return null; }
219
+ }
220
+
221
+ function configSourceNow(): CatalogAuditConfigSource {
222
+ try {
223
+ const snapshot = readConfigAdmissionSnapshot();
224
+ return snapshot.kind === "read" ? snapshot.diagnostics.source
225
+ : snapshot.diagnostics.source === "default" ? "default" : "unreadable";
226
+ } catch { return "unreadable"; }
227
+ }
228
+
229
+ function routedRowsOnDisk(path: string): number | null {
230
+ try { return routedRowsFromJson(readFileSync(path, "utf8")); } catch { return null; }
231
+ }
232
+
233
+ function auditWrite(
234
+ permit: CatalogWritePermit,
235
+ home: string,
236
+ details: CatalogWriteAuditDetails,
237
+ register: boolean,
238
+ ): void {
239
+ try {
240
+ if (auditCatalogWriteWithPermit(permit, home, details) !== "created"
241
+ || !register || details.configSource !== "file") return;
242
+ // The manifest rejects external CODEX_HOME paths. Such audit files remain uninstall
243
+ // residuals; diagnostic creation does not widen config ownership or claim cleanup.
244
+ if (!recordOwnedConfigPath(getConfigDir(), codexCatalogAuditPath(home))) return;
245
+ } catch { /* Audit and registration failures cannot change the publication outcome. */ }
246
+ }
247
+
248
+ /** Audit an actual refusal before the replacement funnel. Caller must avoid a second funnel event. */
249
+ export function auditRefusedCatalogReplacement(
250
+ permit: CatalogWritePermit,
251
+ home: string,
252
+ prepared: PreparedCatalogFileWrite,
253
+ reason: CatalogAuditRefusalReason,
254
+ ): void {
255
+ assertCatalogWritePermit(permit, home);
256
+ auditWrite(permit, home, {
257
+ target: "catalog", outcome: "refused", reason,
258
+ routedBefore: routedRowsOnDisk(prepared.path), routedAfter: routedRowsFromJson(prepared.content),
259
+ configSource: configSourceNow(),
260
+ }, true);
261
+ }
262
+
263
+ /** One funnel for exact-byte idempotence and intent admission, under a live K permit. */
203
264
  export function replaceActiveCodexCatalog(
204
265
  permit: CatalogWritePermit,
205
266
  owningCodexHome: string,
206
267
  prepared: PreparedCatalogFileWrite,
207
268
  io?: AtomicWriteIO,
208
- ): void {
269
+ ): CatalogFileReplacement {
209
270
  assertCatalogWritePermit(permit, owningCodexHome);
271
+ const { intent } = catalogWritePermitContext(permit);
272
+ if (intent === "cache") {
273
+ throw new CatalogWritePermitRefusal("A models-cache permit cannot replace the Codex catalog.");
274
+ }
275
+ const onDisk = readExistingCatalogBytes(prepared.path);
276
+ if (!preparedBytesDifferFromDisk(prepared, onDisk)) {
277
+ notifyCatalogPublication({ kind: "published", path: prepared.path, intent });
278
+ return { kind: "unchanged" };
279
+ }
280
+ const routedBefore = onDisk === null ? null : routedRowsFromJson(onDisk.toString("utf8"));
281
+ const routedAfter = routedRowsFromJson(prepared.content);
282
+ const finish = (result: CatalogFileReplacement): CatalogFileReplacement => {
283
+ if (result.kind !== "unchanged") auditWrite(permit, owningCodexHome, {
284
+ target: "catalog", outcome: result.kind,
285
+ reason: result.kind === "refused" ? result.reason : undefined,
286
+ routedBefore, routedAfter, configSource: configSourceNow(),
287
+ }, io === undefined);
288
+ if (result.kind !== "refused") notifyCatalogPublication({ kind: "published", path: prepared.path, intent });
289
+ return result;
290
+ };
291
+ if (intent === "refresh" && (routedBefore ?? 0) > 0 && routedAfter === 0) {
292
+ // K -> C, including the replacement: a config save cannot race the authority check.
293
+ try {
294
+ return finish(withConfigMutationLockSync(() => {
295
+ const snapshot = readConfigAdmissionSnapshot();
296
+ if (snapshot.kind !== "read" || snapshot.diagnostics.source !== "file") {
297
+ return { kind: "refused", reason: "unbacked-routed-clear" } as const;
298
+ }
299
+ atomicWriteFile(prepared.path, prepared.content, io);
300
+ resetCodexAppServerCatalogStateCache();
301
+ return { kind: "written" } as const;
302
+ }));
303
+ } catch (error) {
304
+ if (error instanceof ConfigMutationLockError) return finish({ kind: "refused", reason: "unbacked-routed-clear" });
305
+ throw error;
306
+ }
307
+ }
210
308
  atomicWriteFile(prepared.path, prepared.content, io);
211
309
  resetCodexAppServerCatalogStateCache();
310
+ return finish({ kind: "written" });
212
311
  }
213
312
 
214
313
  /** Atomically publish the catalog-path-keyed immutable backup without clobbering. */
@@ -242,8 +341,16 @@ export function replaceCodexModelsCache(
242
341
  owningCodexHome: string,
243
342
  prepared: PreparedCatalogFileWrite,
244
343
  io?: AtomicWriteIO,
245
- ): void {
344
+ ): Exclude<CatalogFileReplacement, { kind: "refused" }> {
246
345
  assertCatalogWritePermit(permit, owningCodexHome);
346
+ catalogWritePermitContext(permit);
347
+ if (!preparedBytesDifferFromDisk(prepared)) return { kind: "unchanged" };
348
+ const routedBefore = routedRowsOnDisk(prepared.path);
247
349
  atomicWriteFile(prepared.path, prepared.content, io);
248
350
  resetCodexAppServerCatalogStateCache();
351
+ auditWrite(permit, owningCodexHome, {
352
+ target: "cache", outcome: "written", routedBefore,
353
+ routedAfter: routedRowsFromJson(prepared.content), configSource: configSourceNow(),
354
+ }, io === undefined);
355
+ return { kind: "written" };
249
356
  }