@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
@@ -2,7 +2,7 @@ import { projectAntigravitySelectedModels } from "../../providers/antigravity-ef
2
2
  import { existsSync, readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { initializeConfigOwnership } from "../../lib/config-ownership";
5
- import { getConfigDir, loadConfig, websocketsEnabled } from "../../config";
5
+ import { getConfigDir, loadConfig, websocketsEnabled, withConfigMutationLockSync } from "../../config";
6
6
  import { shouldSyncCodexOnStart } from "../desired-state";
7
7
  import { legacyCustomModelCatalogSlugs } from "../custom-model-catalog-migration";
8
8
  import { getCodexHome } from "../paths";
@@ -40,6 +40,8 @@ import {
40
40
  nativeMultiAgentDefaults,
41
41
  } from "./parsing";
42
42
  import type { CatalogModel, MultiAgentMode, RawCatalog, RawEntry } from "./parsing";
43
+ import { ocxRoutedNamespaceCounts, routedRemovalBackedByConfigFile, unconfiguredRoutedRemoval } from "./routed-removal";
44
+ import { ConfigMutationLockError } from "../../config/mutation-lock";
43
45
  import {
44
46
  accountBoundNativeOpenAiSlugsBySelector,
45
47
  desktopAllowlistSuppressedNativeSlugs,
@@ -64,11 +66,13 @@ import {
64
66
  } from "../catalog-write-serialization";
65
67
  import {
66
68
  preparedBytesDifferFromDisk,
69
+ auditRefusedCatalogReplacement,
67
70
  publishHashedCodexCatalogBackup,
68
71
  publishLegacyCodexCatalogBackup,
69
72
  replaceActiveCodexCatalog,
70
73
  replaceCodexModelsCache,
71
74
  type PreparedCatalogFileWrite,
75
+ type CatalogFileReplacement,
72
76
  } from "../internal/catalog-writer";
73
77
  import { visibleCodexAccountSelectors } from "./account-models";
74
78
  import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS, NATIVE_OPENAI_MODELS, NATIVE_RESERVE_MODEL } from "./native-models";
@@ -105,8 +109,15 @@ interface RetainedCatalogSyncResult {
105
109
  comboOmissions: ComboCatalogOmission[];
106
110
  /** Validated catalog commit (including identical bytes), or a refused refresh. */
107
111
  refreshOutcome?: "committed" | "refused";
108
- /** `desired_disabled` observed under K after the provider await; nothing was written. */
109
- skippedReason?: "desired_disabled";
112
+ /**
113
+ * `desired_disabled`: observed under K after the provider await. `unbacked_routed_removal`: the
114
+ * catalog would lose routed namespaces config.json still enables, or config.json is missing or
115
+ * unreadable (#6529). `foreign_owner`: this Codex home is bound to another OPENCODEX_HOME. Nothing
116
+ * was written in any of these cases.
117
+ */
118
+ skippedReason?: "desired_disabled" | "unbacked_routed_removal" | "foreign_owner" | "owner_unknown";
119
+ /** With `unbacked_routed_removal`: how many routed namespaces the refused write would have emptied. */
120
+ protectedRoutedNamespaces?: number;
110
121
  }
111
122
 
112
123
  /**
@@ -554,12 +565,46 @@ function writeRetainedCatalogSync({
554
565
  if (!preparedBytesDifferFromDisk(preparedCatalog)) {
555
566
  return { added, path: catalogPath, catalogWritten: false, comboOmissions };
556
567
  }
557
-
558
- replaceActiveCodexCatalog(permit, owningCodexHome, preparedCatalog);
568
+ // A refresh may drop a provider's rows only when config.json on disk agrees the provider is gone.
569
+ // Without that, a config that is not the user's (missing or unreadable file read as defaults,
570
+ // another OPENCODEX_HOME) would publish a native-only catalog and exit cleanly (#6529).
571
+ const refused = (protectedRoutedNamespaces: number): RetainedCatalogSyncResult => ({
572
+ added: 0,
573
+ path: catalogPath,
574
+ catalogWritten: false,
575
+ comboOmissions,
576
+ skippedReason: "unbacked_routed_removal",
577
+ protectedRoutedNamespaces,
578
+ });
579
+ let replacement: CatalogFileReplacement | undefined;
580
+ const removal = unconfiguredRoutedRemoval(onDiskCatalog, catalog, config);
581
+ if (removal !== null) {
582
+ // Hold C (K -> C) from the config read through the replacement, so a save that enables one of
583
+ // these providers cannot land between the check and the write. A held C is a refusal too.
584
+ let backed = false;
585
+ try {
586
+ backed = withConfigMutationLockSync(() => {
587
+ if (!routedRemovalBackedByConfigFile(removal)) return false;
588
+ replacement = replaceActiveCodexCatalog(permit, owningCodexHome, preparedCatalog);
589
+ return replacement.kind !== "refused";
590
+ });
591
+ } catch (error) {
592
+ if (!(error instanceof ConfigMutationLockError)) throw error;
593
+ }
594
+ if (!backed) {
595
+ if (replacement?.kind !== "refused") {
596
+ auditRefusedCatalogReplacement(permit, owningCodexHome, preparedCatalog, "unbacked-routed-removal");
597
+ }
598
+ return refused(removal.namespaces.length);
599
+ }
600
+ } else {
601
+ replacement = replaceActiveCodexCatalog(permit, owningCodexHome, preparedCatalog);
602
+ if (replacement.kind === "refused") return refused(ocxRoutedNamespaceCounts(onDiskCatalog).size);
603
+ }
559
604
  return {
560
605
  added,
561
606
  path: catalogPath,
562
- catalogWritten: true,
607
+ catalogWritten: replacement?.kind === "written",
563
608
  comboOmissions,
564
609
  };
565
610
  }
@@ -649,7 +694,7 @@ export async function syncCatalogModels(
649
694
  owningCodexHome,
650
695
  modelEntitlements,
651
696
  });
652
- });
697
+ }, { intent: "refresh", writer: "retained-sync" });
653
698
  if (committed.kind === "completed" && committed.value !== null) {
654
699
  return {
655
700
  ...committed.value,
@@ -662,6 +707,10 @@ export async function syncCatalogModels(
662
707
  catalogWritten: false,
663
708
  comboOmissions,
664
709
  refreshOutcome: "refused",
710
+ ...(committed.kind === "unavailable" && committed.reason === "foreign-owner"
711
+ ? { skippedReason: "foreign_owner" as const } : {}),
712
+ ...(committed.kind === "unavailable" && committed.reason === "owner-unknown"
713
+ ? { skippedReason: "owner_unknown" as const } : {}),
665
714
  };
666
715
  }
667
716
 
@@ -729,9 +778,7 @@ export function invalidateCodexModelsCacheWithPermitOutcome(
729
778
  // `cacheSynced` mean what its name and its consumers already assume, and what
730
779
  // `pullRemoteCatalog` and the early returns in `refreshCodexModelCatalog`
731
780
  // already assert: a write happened.
732
- if (!preparedBytesDifferFromDisk(preparedCache)) return "unchanged";
733
- replaceCodexModelsCache(permit, owningCodexHome, preparedCache);
734
- return "written";
781
+ return replaceCodexModelsCache(permit, owningCodexHome, preparedCache).kind;
735
782
  } catch {
736
783
  return "failed";
737
784
  }
@@ -750,6 +797,7 @@ export function invalidateCodexModelsCache(options?: CodexCatalogSyncOptions): b
750
797
  const outcome = withCatalogWriteSerialization(
751
798
  owningCodexHome,
752
799
  permit => invalidateCodexModelsCacheWithPermit(permit, owningCodexHome, options),
800
+ { intent: "cache", writer: "cache-invalidate" },
753
801
  );
754
802
  return outcome.kind === "completed" && outcome.value;
755
803
  }
@@ -0,0 +1,116 @@
1
+ import { COMBO_NAMESPACE } from "../../combos";
2
+ import { readConfigAdmissionSnapshot, type ConfigAdmissionSnapshot } from "../../config/diagnostics";
3
+ import type { OcxConfig } from "../../types";
4
+ import { trustedAccountBoundNativeCatalogSlug } from "./account-models";
5
+ import { isOcxAuthoredRoutedEntry } from "./build-entries";
6
+ import { isNativeAliasCatalogEntry } from "./metadata";
7
+ import type { RawCatalog, RawEntry } from "./parsing";
8
+
9
+ /**
10
+ * Routed namespaces a candidate catalog empties of OpenCodex-authored rows only because the config
11
+ * that produced it does not enable them: the "deleted provider" rule in build-entries. That rule is
12
+ * the one way a refresh removes a provider's rows wholesale, so it is the one a config that is not
13
+ * the user's (a missing or unreadable config.json read as defaults, another OPENCODEX_HOME) turns
14
+ * into a silent native-only catalog (#6529).
15
+ */
16
+ export interface UnconfiguredRoutedRemoval {
17
+ readonly namespaces: readonly string[];
18
+ }
19
+
20
+ type RoutedNamespaceConfig = Pick<OcxConfig, "providers" | "combos">;
21
+
22
+ function routedNamespace(entry: RawEntry): string | null {
23
+ if (!entry || typeof entry !== "object" || typeof entry.slug !== "string") return null;
24
+ // Account-bound native rows follow the account pool, not provider config.
25
+ if (trustedAccountBoundNativeCatalogSlug(entry) !== undefined) return null;
26
+ if (isNativeAliasCatalogEntry(entry)) return COMBO_NAMESPACE;
27
+ // Combo aliases may be bare or use an unrelated provider namespace. Ownership selects the
28
+ // combo namespace only with the same generated-description authorship signal as routed rows.
29
+ if (entry.owned_by === COMBO_NAMESPACE
30
+ && typeof entry.description === "string"
31
+ && entry.description.startsWith("Routed via opencodex → ")) return COMBO_NAMESPACE;
32
+ // Foreign rows (Cursor, user tooling) are never removed by the provider rule.
33
+ if (!isOcxAuthoredRoutedEntry(entry)) return null;
34
+ return entry.slug.slice(0, entry.slug.indexOf("/"));
35
+ }
36
+
37
+ /** OpenCodex-authored routed rows per namespace; foreign and account-bound rows never count. */
38
+ export function ocxRoutedNamespaceCounts(catalog: RawCatalog | null): Map<string, number> {
39
+ const counts = new Map<string, number>();
40
+ for (const entry of catalog?.models ?? []) {
41
+ const namespace = routedNamespace(entry);
42
+ if (namespace !== null) counts.set(namespace, (counts.get(namespace) ?? 0) + 1);
43
+ }
44
+ return counts;
45
+ }
46
+
47
+ /** Total OpenCodex-authored routed rows, or null for a value that is not a catalog. */
48
+ export function ocxRoutedRowCount(catalog: unknown): number | null {
49
+ if (catalog === null || typeof catalog !== "object" || !Array.isArray((catalog as RawCatalog).models)) return null;
50
+ let total = 0;
51
+ for (const count of ocxRoutedNamespaceCounts(catalog as RawCatalog).values()) total += count;
52
+ return total;
53
+ }
54
+
55
+ /** Whether a config still produces routed rows for a namespace: an enabled provider, or any combo. */
56
+ export function configEnablesRoutedNamespace(config: RoutedNamespaceConfig, namespace: string): boolean {
57
+ if (namespace === COMBO_NAMESPACE && Object.keys(config.combos ?? {}).length > 0) return true;
58
+ const provider = config.providers?.[namespace];
59
+ return provider !== undefined && provider.disabled !== true;
60
+ }
61
+
62
+ /**
63
+ * The namespaces whose OpenCodex-authored routed rows are on disk now, absent from the candidate,
64
+ * and not enabled by the driving config; null when the candidate removes no such namespace.
65
+ * Removals inside a namespace the driving config still enables (discovery, selection, a deleted
66
+ * custom model) are the provider's own business and are not reported here.
67
+ */
68
+ export function unconfiguredRoutedRemoval(
69
+ active: RawCatalog | null,
70
+ candidate: RawCatalog,
71
+ driving: RoutedNamespaceConfig,
72
+ ): UnconfiguredRoutedRemoval | null {
73
+ const activeModels = active?.models ?? [];
74
+ if (activeModels.length === 0) return null;
75
+ const kept = new Set((candidate.models ?? []).flatMap(entry => typeof entry.slug === "string" ? [entry.slug] : []));
76
+ const namespaces = new Set<string>();
77
+ for (const entry of activeModels) {
78
+ if (typeof entry.slug === "string" && kept.has(entry.slug)) continue;
79
+ const namespace = routedNamespace(entry);
80
+ if (namespace !== null && !configEnablesRoutedNamespace(driving, namespace)) namespaces.add(namespace);
81
+ }
82
+ return namespaces.size > 0 ? { namespaces: [...namespaces].sort() } : null;
83
+ }
84
+
85
+ /**
86
+ * True when config.json on disk backs every removal: it is a readable, parseable file, and it enables
87
+ * none of the removed namespaces. A missing, unreadable or salvaged file backs nothing, and a file
88
+ * that still enables a namespace shows the driving config was not the one on disk. Read once, at
89
+ * write time, so a config that fell back to defaults while the file was briefly unreadable cannot
90
+ * pass on a later successful read.
91
+ */
92
+ export function routedRemovalBackedByConfigFile(
93
+ removal: UnconfiguredRoutedRemoval,
94
+ read: () => ConfigAdmissionSnapshot = readConfigAdmissionSnapshot,
95
+ ): boolean {
96
+ const snapshot = read();
97
+ if (snapshot.kind !== "read" || snapshot.diagnostics.source !== "file") return false;
98
+ return removal.namespaces.every(namespace => !configEnablesRoutedNamespace(snapshot.diagnostics.config, namespace));
99
+ }
100
+
101
+ /** One line for logs when K refused a writer from another OPENCODEX_HOME; names no path. */
102
+ export const FOREIGN_CODEX_HOME_OWNER_MESSAGE = "Codex catalog left unchanged: this Codex home was set up by "
103
+ + "another OpenCodex home (OPENCODEX_HOME), recorded in its opencodex-journal.json. Run opencodex "
104
+ + "from that home, or run `ocx restore` there first.";
105
+
106
+ /** Unavailable evidence is not proof of a foreign owner. No paths or journal content are exposed. */
107
+ export const UNKNOWN_CODEX_HOME_OWNER_MESSAGE = "Codex catalog left unchanged: ownership of this Codex home "
108
+ + "could not be checked. Restore readable ownership evidence and retry.";
109
+
110
+ /** One line for logs: counts only, never provider names, model ids or paths. */
111
+ export function unbackedRoutedRemovalMessage(count: number): string {
112
+ return `Codex catalog left unchanged: this refresh would remove the routed models of ${count} `
113
+ + `provider namespace${count === 1 ? "" : "s"} that config.json still enables, or config.json `
114
+ + "is missing or unreadable. Run `ocx sync` again once config.json is readable; use `ocx restore` "
115
+ + "to remove routed models on purpose.";
116
+ }
@@ -0,0 +1,21 @@
1
+ /** Bottom-layer metadata contract: neither K nor the filesystem adapter owns intent. */
2
+ export type CatalogWriteIntent = "refresh" | "cache" | "pull" | "restore";
3
+ export type CatalogAuditConfigSource = "file" | "default" | "fallback" | "unreadable";
4
+ export type CatalogAuditRefusalReason = "foreign-owner" | "owner-unknown"
5
+ | "unbacked-routed-removal" | "unbacked-routed-clear";
6
+
7
+ export interface CatalogWriteAuditDetails {
8
+ readonly target: "catalog" | "cache";
9
+ readonly outcome: "written" | "refused";
10
+ readonly reason?: CatalogAuditRefusalReason;
11
+ readonly routedBefore?: number | null;
12
+ readonly routedAfter?: number | null;
13
+ readonly configSource?: CatalogAuditConfigSource;
14
+ }
15
+
16
+ export interface CatalogWriteAuditEvent extends CatalogWriteAuditDetails {
17
+ readonly intent: CatalogWriteIntent;
18
+ readonly writer: string;
19
+ /** Supplied by K, never discovered by the adapter. */
20
+ readonly opencodexHome: string;
21
+ }
@@ -0,0 +1,171 @@
1
+ import {
2
+ closeSync, constants, fstatSync, ftruncateSync, lstatSync, openSync, readSync, writeSync,
3
+ type Stats,
4
+ } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { redactUserPath } from "../../lib/redact";
7
+ import { hardenSecretPath, windowsSecretAclApplies } from "../../lib/windows-secret-acl";
8
+ import type { CatalogWriteAuditEvent } from "./write-audit-contract";
9
+
10
+ export const CODEX_CATALOG_AUDIT_FILE = "opencodex-catalog-audit.jsonl";
11
+ export const CATALOG_AUDIT_MAX_BYTES = 256 * 1024;
12
+ export const CATALOG_AUDIT_KEEP_RECORDS = 400;
13
+ export const CATALOG_AUDIT_MAX_EVENT_BYTES = 2 * 1024;
14
+ const MAX_HOME_CHARACTERS = 256;
15
+ const AUDIT_ACL_DEADLINE_MS = 1000;
16
+ const WRITERS = new Set([
17
+ "convergence", "retained-sync", "cache-from-catalog", "cache-invalidate",
18
+ "catalog-pull", "catalog-restore", "codex-restore", "sync-cache", "startup-cache",
19
+ ]);
20
+
21
+ export function codexCatalogAuditPath(codexHome: string): string {
22
+ return join(codexHome, CODEX_CATALOG_AUDIT_FILE);
23
+ }
24
+
25
+ function safeCount(value: number | null | undefined): number | null {
26
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
27
+ }
28
+
29
+ function auditLine(event: CatalogWriteAuditEvent): Buffer {
30
+ const writer = WRITERS.has(event.writer) ? event.writer : "other";
31
+ // Explicit projection: no argv, arbitrary event properties, model IDs or provider names.
32
+ return Buffer.from(`${JSON.stringify({
33
+ at: new Date().toISOString(), pid: process.pid, ppid: process.ppid,
34
+ command: writer,
35
+ opencodexHome: redactUserPath(event.opencodexHome).slice(0, MAX_HOME_CHARACTERS),
36
+ target: event.target, outcome: event.outcome, reason: event.reason,
37
+ intent: event.intent, writer,
38
+ configSource: event.configSource,
39
+ routedBefore: safeCount(event.routedBefore), routedAfter: safeCount(event.routedAfter),
40
+ })}\n`);
41
+ }
42
+
43
+ function regularPrivateFile(stat: Stats): boolean {
44
+ // POSIX bits cannot prove NTFS privacy. Windows requires fresh-file ACL hardening.
45
+ return !windowsSecretAclApplies() && stat.isFile() && stat.nlink === 1
46
+ && stat.uid === process.getuid?.() && (stat.mode & 0o777) === 0o600;
47
+ }
48
+
49
+ function sameFile(left: Stats, right: Stats): boolean {
50
+ return left.dev === right.dev && left.ino === right.ino && left.size === right.size
51
+ && left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs;
52
+ }
53
+
54
+ /** Read at most the tail budget plus one byte to locate a complete first record. */
55
+ function readTail(fd: number, size: number): Buffer {
56
+ const start = Math.max(0, size - CATALOG_AUDIT_MAX_BYTES - 1);
57
+ const bytes = Buffer.alloc(size - start);
58
+ let length = 0;
59
+ while (length < bytes.length) {
60
+ const count = readSync(fd, bytes, length, bytes.length - length, start + length);
61
+ if (count === 0) break;
62
+ length += count;
63
+ }
64
+ if (length !== bytes.length) throw new Error("audit file changed during read");
65
+ if (start === 0) return bytes;
66
+ const boundary = bytes.indexOf(10);
67
+ return boundary === -1 ? Buffer.alloc(0) : bytes.subarray(boundary + 1);
68
+ }
69
+
70
+ function completeRecords(bytes: Buffer): { records: Buffer[]; count: number; clean: boolean } {
71
+ const records: Buffer[] = [];
72
+ let start = 0;
73
+ let clean = true;
74
+ let count = 0;
75
+ for (let end = bytes.indexOf(10); end !== -1; end = bytes.indexOf(10, start)) {
76
+ const line = bytes.subarray(start, end);
77
+ try {
78
+ const text = line.toString("utf8");
79
+ if (!line.equals(Buffer.from(text))) throw new Error("invalid UTF-8");
80
+ JSON.parse(text);
81
+ records[count % CATALOG_AUDIT_KEEP_RECORDS] = bytes.subarray(start, end + 1);
82
+ count++;
83
+ } catch { clean = false; }
84
+ start = end + 1;
85
+ }
86
+ const pivot = count > CATALOG_AUDIT_KEEP_RECORDS ? count % CATALOG_AUDIT_KEEP_RECORDS : 0;
87
+ return { records: records.slice(pivot).concat(records.slice(0, pivot)), count,
88
+ clean: clean && start === bytes.length };
89
+ }
90
+
91
+ function writeAll(fd: number, bytes: Buffer, position: number | null): void {
92
+ let offset = 0;
93
+ while (offset < bytes.length) {
94
+ const count = writeSync(fd, bytes, offset, bytes.length - offset,
95
+ position === null ? null : position + offset);
96
+ if (count === 0) throw new Error("audit write made no progress");
97
+ offset += count;
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Best-effort adapter. Caller MUST hold K, even for a refusal that has no permit.
103
+ * POSIX owners compact the descriptor; foreign callers only append within both caps.
104
+ * Windows only publishes into fresh owner files after required ACL hardening.
105
+ * This diagnostic has no replay or authorization role. Process death may leave a partial line.
106
+ */
107
+ export function appendCatalogWriteAudit(
108
+ codexHome: string,
109
+ event: CatalogWriteAuditEvent,
110
+ options: { readonly create: boolean },
111
+ ): "appended" | "created" | "skipped" {
112
+ let fd: number | undefined;
113
+ try {
114
+ const line = auditLine(event);
115
+ if (line.length > CATALOG_AUDIT_MAX_EVENT_BYTES) return "skipped";
116
+ const path = codexCatalogAuditPath(codexHome);
117
+ const windows = windowsSecretAclApplies();
118
+ let before: Stats | undefined;
119
+ try { before = lstatSync(path); }
120
+ catch (error) {
121
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT" || !options.create) return "skipped";
122
+ }
123
+ if (before && !regularPrivateFile(before)) return "skipped";
124
+ // No mkdir, pathname truncation or chmod. O_EXCL prevents creation over a raced path.
125
+ const flags = constants.O_RDWR | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0)
126
+ | (before ? (options.create ? 0 : constants.O_APPEND) : constants.O_CREAT | constants.O_EXCL);
127
+ fd = openSync(path, flags, 0o600);
128
+ let opened = fstatSync(fd);
129
+ if (windows) {
130
+ // Existing files have already been refused: no read-only NTFS privacy verifier exists.
131
+ if (!opened.isFile() || opened.nlink !== 1 || opened.size !== 0
132
+ || !sameFile(opened, lstatSync(path))) return "skipped";
133
+ if (!hardenSecretPath(path, { required: true, deadlineMs: AUDIT_ACL_DEADLINE_MS }).ok) return "skipped";
134
+ const hardened = fstatSync(fd);
135
+ // ACL changes legitimately move ctime. Retain object identity, then take a fresh baseline.
136
+ if (!hardened.isFile() || hardened.nlink !== 1 || hardened.size !== 0
137
+ || opened.dev !== hardened.dev || opened.ino !== hardened.ino
138
+ || !sameFile(hardened, lstatSync(path))) return "skipped";
139
+ opened = hardened;
140
+ } else if (!regularPrivateFile(opened) || (before && !sameFile(before, opened))) return "skipped";
141
+ if (!options.create && opened.size + line.length > CATALOG_AUDIT_MAX_BYTES) return "skipped";
142
+ const tail = readTail(fd, opened.size);
143
+ const parsed = completeRecords(tail);
144
+ if (!sameFile(opened, fstatSync(fd)) || !sameFile(opened, lstatSync(path))) return "skipped";
145
+ if (!options.create) {
146
+ if (!parsed.clean || parsed.count + 1 > CATALOG_AUDIT_KEEP_RECORDS) return "skipped";
147
+ writeAll(fd, line, null);
148
+ } else if (opened.size <= CATALOG_AUDIT_MAX_BYTES - line.length
149
+ && parsed.clean && parsed.count < CATALOG_AUDIT_KEEP_RECORDS) {
150
+ writeAll(fd, line, opened.size);
151
+ } else {
152
+ const retained: Buffer[] = [line];
153
+ let bytes = line.length;
154
+ for (let index = parsed.records.length - 1; index >= 0 && retained.length < CATALOG_AUDIT_KEEP_RECORDS; index--) {
155
+ const record = parsed.records[index]!;
156
+ if (bytes + record.length > CATALOG_AUDIT_MAX_BYTES) break;
157
+ retained.unshift(record);
158
+ bytes += record.length;
159
+ }
160
+ const output = Buffer.concat(retained, bytes);
161
+ writeAll(fd, output, 0);
162
+ ftruncateSync(fd, output.length);
163
+ }
164
+ return before ? "appended" : "created";
165
+ } catch {
166
+ // Deliberately best-effort: an audit IO failure never changes the catalog result.
167
+ return "skipped";
168
+ } finally {
169
+ if (fd !== undefined) { try { closeSync(fd); } catch { /* diagnostic only */ } }
170
+ }
171
+ }