@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
@@ -0,0 +1,284 @@
1
+ import { readConfigAdmissionSnapshot } from "../config/diagnostics";
2
+ import { readRuntimePort } from "../config/process-state";
3
+ import { readClientConnectionState } from "../client/state";
4
+ import { getActiveTurnCount, isDraining, isRecyclingForExit, isShutdownDraining } from "../server/lifecycle";
5
+ import type { OcxConfig } from "../types";
6
+ import { observeCatalogHealFile, sameCatalogHealPath, selectCatalogHealPath, type CatalogObservation } from "./catalog/heal-observation";
7
+ import { subscribeCatalogPublication } from "./catalog/publication-observer";
8
+ import { configEnablesRoutedNamespace, ocxRoutedNamespaceCounts } from "./catalog/routed-removal";
9
+ import { inspectCodexHomeOwner } from "./codex-home-owner";
10
+ import { shouldSyncCodexOnStart } from "./desired-state";
11
+ import { JOURNAL_PATH, journalOwner } from "./journal";
12
+ import { CODEX_HOME, DEFAULT_CATALOG_PATH, resolveCodexConfigPath } from "./paths";
13
+ import { siblingOfLivePort } from "./sibling-start";
14
+
15
+ export type { CatalogObservation } from "./catalog/heal-observation";
16
+ export const CATALOG_HEAL_TICK_MS = 30_000;
17
+ export const CATALOG_HEAL_RECHECK_MS = 5 * 60_000;
18
+ export const CATALOG_HEAL_WINDOW_MS = 60 * 60_000;
19
+ export const CATALOG_HEAL_MAX_ATTEMPTS = 6;
20
+ export const CATALOG_HEAL_MAX_HEALS = 3;
21
+
22
+ export type CatalogSelfHealGate = "sibling" | "exiting" | "idle" | "runtime-record" | "config-unavailable" | "codex-off" | "not-owner" | "client";
23
+ export interface CatalogSelfHealGates {
24
+ siblingOfLivePort(): number | null;
25
+ exiting(): boolean;
26
+ idle(): boolean;
27
+ runtimeOwned(): boolean;
28
+ /** File-backed config authority; missing, malformed or salvaged config returns null. */
29
+ loadConfig(): OcxConfig | null;
30
+ ownsCodexHome(): boolean;
31
+ clientConnected(): boolean;
32
+ clientJournalOwner(): boolean;
33
+ }
34
+ export interface CatalogHealOutcome { readonly committed: boolean }
35
+ export interface CatalogHealLifecycle {
36
+ readonly beforeCommit: () => boolean;
37
+ readonly expectedCatalogPath: string;
38
+ }
39
+ export interface CatalogSelfHealRecord {
40
+ readonly at: string;
41
+ readonly lostNamespaces: number;
42
+ readonly committed: boolean;
43
+ }
44
+ export interface CatalogSelfHealHandle {
45
+ stop(): void;
46
+ lastHeal(): CatalogSelfHealRecord | null;
47
+ tickForTests(): Promise<void>;
48
+ }
49
+ export interface CatalogSelfHealDeps {
50
+ scheduleFn?: (fn: () => void, ms: number) => { cancel(): void };
51
+ now?: () => number;
52
+ catalogPath?: () => string | null;
53
+ observe?: (path: string, readContent: boolean) => CatalogObservation | null;
54
+ converge?: (config: OcxConfig, lifecycle: CatalogHealLifecycle) => Promise<CatalogHealOutcome>;
55
+ subscribe?: typeof subscribeCatalogPublication;
56
+ gates?: Partial<CatalogSelfHealGates>;
57
+ log?: Pick<Console, "warn">;
58
+ }
59
+
60
+ function defaultSchedule(fn: () => void, ms: number): { cancel(): void } {
61
+ const timer = setTimeout(fn, ms);
62
+ timer.unref?.();
63
+ return { cancel: () => clearTimeout(timer) };
64
+ }
65
+
66
+ async function convergeOwnCatalog(config: OcxConfig, lifecycle: CatalogHealLifecycle): Promise<CatalogHealOutcome> {
67
+ const [{ armDetachedConfigBaseline }, { createManagementConvergeCodex }, { createCatalogConvergeRequest }] = await Promise.all([
68
+ import("../config"), import("./management-convergence"), import("./catalog-admission"),
69
+ ]);
70
+ if (!lifecycle.beforeCommit()) return { committed: false };
71
+ // Discovery persistence rebases this detached snapshot against current disk config.
72
+ armDetachedConfigBaseline(config);
73
+ const outcome = await createManagementConvergeCodex(config, lifecycle)(createCatalogConvergeRequest({ deadlineMs: 1_000 }));
74
+ return { committed: outcome.kind === "catalog-only" && outcome.catalogRefresh.status === "committed" };
75
+ }
76
+
77
+ export function evaluateCatalogSelfHealGates(gates: CatalogSelfHealGates):
78
+ { readonly open: true; readonly config: OcxConfig } | { readonly open: false; readonly gate: CatalogSelfHealGate } {
79
+ if (gates.siblingOfLivePort() !== null) return { open: false, gate: "sibling" };
80
+ if (gates.exiting()) return { open: false, gate: "exiting" };
81
+ if (!gates.idle()) return { open: false, gate: "idle" };
82
+ if (!gates.runtimeOwned()) return { open: false, gate: "runtime-record" };
83
+ const config = gates.loadConfig();
84
+ if (config === null) return { open: false, gate: "config-unavailable" };
85
+ if (!shouldSyncCodexOnStart(config)) return { open: false, gate: "codex-off" };
86
+ if (gates.clientConnected() || gates.clientJournalOwner()) return { open: false, gate: "client" };
87
+ if (!gates.ownsCodexHome()) return { open: false, gate: "not-owner" };
88
+ return { open: true, config };
89
+ }
90
+
91
+ type NamespaceObservation = { path: string; signature: string; namespaces: ReadonlySet<string> | null };
92
+
93
+ /** Owner lifecycle only: importing this module starts no timer or publication subscription. */
94
+ export function startCodexCatalogSelfHeal(options: { port?: number; deps?: CatalogSelfHealDeps } = {}): CatalogSelfHealHandle {
95
+ const deps = options.deps ?? {};
96
+ const scheduleFn = deps.scheduleFn ?? defaultSchedule;
97
+ const clock = deps.now ?? (() => performance.now());
98
+ const catalogPath = deps.catalogPath ?? (() => selectCatalogHealPath(JOURNAL_PATH, DEFAULT_CATALOG_PATH, resolveCodexConfigPath));
99
+ const observe = deps.observe ?? observeCatalogHealFile;
100
+ const converge = deps.converge ?? convergeOwnCatalog;
101
+ const gates: CatalogSelfHealGates = {
102
+ siblingOfLivePort,
103
+ exiting: () => isRecyclingForExit() || isShutdownDraining(),
104
+ idle: () => !isDraining() && getActiveTurnCount() === 0,
105
+ runtimeOwned: () => {
106
+ const runtime = readRuntimePort(process.pid);
107
+ return runtime !== null && runtime.siblingOfPort === undefined
108
+ && (options.port === undefined || runtime.port === options.port);
109
+ },
110
+ loadConfig: () => {
111
+ const snapshot = readConfigAdmissionSnapshot();
112
+ return snapshot.kind === "read" && snapshot.diagnostics.source === "file"
113
+ ? snapshot.diagnostics.config : null;
114
+ },
115
+ ownsCodexHome: () => {
116
+ const owner = inspectCodexHomeOwner(CODEX_HOME);
117
+ return owner.kind === "owned" || owner.kind === "unbound";
118
+ },
119
+ clientConnected: () => readClientConnectionState().kind !== "disconnected",
120
+ clientJournalOwner: () => journalOwner({ readOnly: true })?.kind === "client",
121
+ ...deps.gates,
122
+ };
123
+ const log = deps.log ?? console;
124
+ let stopped = false;
125
+ let released = false;
126
+ let generation = 0;
127
+ let timer: { cancel(): void } | undefined;
128
+ let running = false;
129
+ let last: CatalogSelfHealRecord | null = null;
130
+ let target: string | null = null;
131
+ let baseline: NamespaceObservation | null = null;
132
+ let observed: NamespaceObservation | null = null;
133
+ let pendingRetry: { path: string; lostNamespaces: readonly string[] } | null = null;
134
+ let recheckAt = 0;
135
+ const attempts: number[] = [];
136
+ const heals: number[] = [];
137
+
138
+ const read = (path: string): NamespaceObservation | null => {
139
+ const quick = observe(path, false);
140
+ if (quick === null) return null;
141
+ if (observed !== null && sameCatalogHealPath(observed.path, path) && observed.signature === quick.signature) return observed;
142
+ const seen = observe(path, true);
143
+ if (seen === null) return null;
144
+ observed = { path, signature: seen.signature, namespaces: seen.catalog ? new Set(ocxRoutedNamespaceCounts(seen.catalog).keys()) : null };
145
+ return observed;
146
+ };
147
+ const accept = (path: string): void => {
148
+ const seen = read(path);
149
+ if (seen?.namespaces != null) baseline = seen;
150
+ };
151
+ const clear = (): void => {
152
+ generation += 1;
153
+ baseline = observed = null;
154
+ pendingRetry = null;
155
+ recheckAt = 0;
156
+ };
157
+ const updateTarget = (path: string | null): void => {
158
+ // Unavailable selection is not evidence of a different target or lost authority.
159
+ if (path === null) return;
160
+ if (target !== null && sameCatalogHealPath(target, path)) return;
161
+ clear();
162
+ target = path;
163
+ };
164
+ const beforeCommit = (path: string, entryGeneration: number): boolean => {
165
+ try {
166
+ const selected = catalogPath();
167
+ return !stopped && !released && generation === entryGeneration
168
+ && selected !== null && sameCatalogHealPath(selected, path) && evaluateCatalogSelfHealGates(gates).open;
169
+ } catch { return false; }
170
+ };
171
+ const capRetryAt = (now: number): number => {
172
+ while (attempts.length && now - attempts[0]! >= CATALOG_HEAL_WINDOW_MS) attempts.shift();
173
+ while (heals.length && now - heals[0]! >= CATALOG_HEAL_WINDOW_MS) heals.shift();
174
+ return Math.max(
175
+ attempts.length >= CATALOG_HEAL_MAX_ATTEMPTS ? attempts[0]! + CATALOG_HEAL_WINDOW_MS : 0,
176
+ heals.length >= CATALOG_HEAL_MAX_HEALS ? heals[0]! + CATALOG_HEAL_WINDOW_MS : 0,
177
+ );
178
+ };
179
+
180
+ const tick = async (): Promise<void> => {
181
+ if (stopped || running || released) return;
182
+ running = true;
183
+ try {
184
+ const path = catalogPath();
185
+ updateTarget(path);
186
+ if (path === null) return;
187
+ // Pending cache/funnel failure is independent of catalog stat or apparent repaired rows.
188
+ if (pendingRetry) {
189
+ const config = gates.loadConfig();
190
+ if (config === null) return;
191
+ const enabled = pendingRetry.lostNamespaces.filter(ns => configEnablesRoutedNamespace(config, ns));
192
+ if (enabled.length === 0) {
193
+ pendingRetry = null;
194
+ recheckAt = 0;
195
+ const seen = read(path);
196
+ if (seen?.namespaces != null) baseline = seen;
197
+ return;
198
+ }
199
+ pendingRetry = { path, lostNamespaces: enabled };
200
+ }
201
+ const now = clock();
202
+ if (now < recheckAt) return;
203
+ const seen = read(path);
204
+ // Missing/corrupt custom paths are unavailable, never reconstructed from the default.
205
+ if (seen?.namespaces == null) return;
206
+ if (baseline === null) { baseline = seen; return; }
207
+ if (!pendingRetry) {
208
+ if (baseline.signature === seen.signature) return;
209
+ const lost = [...baseline.namespaces!].filter(ns => !seen.namespaces!.has(ns));
210
+ if (lost.length === 0) { baseline = seen; return; }
211
+ pendingRetry = { path, lostNamespaces: lost };
212
+ }
213
+ const gate = evaluateCatalogSelfHealGates(gates);
214
+ if (!gate.open) { recheckAt = now + CATALOG_HEAL_RECHECK_MS; return; }
215
+ const lostEnabled = pendingRetry.lostNamespaces.filter(ns => configEnablesRoutedNamespace(gate.config, ns));
216
+ if (lostEnabled.length === 0) { pendingRetry = null; baseline = seen; return; }
217
+ pendingRetry = { path, lostNamespaces: lostEnabled };
218
+ const cappedUntil = capRetryAt(now);
219
+ if (cappedUntil > now) { recheckAt = cappedUntil; return; }
220
+ attempts.push(now);
221
+ const entryGeneration = generation;
222
+ log.warn(`[catalog-self-heal] republishing ${lostEnabled.length} lost routed provider namespace${lostEnabled.length === 1 ? "" : "s"}`);
223
+ let committed = false;
224
+ try { committed = (await converge(gate.config, {
225
+ beforeCommit: () => beforeCommit(path, entryGeneration), expectedCatalogPath: path,
226
+ })).committed; }
227
+ catch { /* Failed attempts retain pending retry and consume the attempt budget. */ }
228
+ const selected = catalogPath();
229
+ if (stopped || released || generation !== entryGeneration || selected === null || !sameCatalogHealPath(selected, path)) return;
230
+ last = { at: new Date().toISOString(), lostNamespaces: lostEnabled.length, committed };
231
+ if (committed) {
232
+ heals.push(clock());
233
+ pendingRetry = null;
234
+ recheckAt = 0;
235
+ accept(path); // Authoritative emptiness is a successful owner baseline too.
236
+ } else recheckAt = clock() + CATALOG_HEAL_RECHECK_MS;
237
+ } catch {
238
+ log.warn("[catalog-self-heal] observation unavailable; deferred");
239
+ } finally { running = false; }
240
+ };
241
+
242
+ const unsubscribe = (deps.subscribe ?? subscribeCatalogPublication)(event => {
243
+ if (stopped) return;
244
+ // Native restoration can remove the journal before notification. Fence the known
245
+ // target (and target-less releases) without resolving possibly unavailable evidence.
246
+ const release = event.kind === "native-released" || event.intent === "restore";
247
+ if (release && (event.path === null || (target !== null && sameCatalogHealPath(event.path, target)))) {
248
+ clear();
249
+ released = true;
250
+ return;
251
+ }
252
+ const path = catalogPath();
253
+ if (event.path !== null && (target === null || !sameCatalogHealPath(event.path, target))
254
+ && (path === null || !sameCatalogHealPath(event.path, path))) return;
255
+ if (release) {
256
+ clear();
257
+ released = true; // Only a later accepted owner publication can re-arm this lifecycle.
258
+ return;
259
+ }
260
+ if (running) return; // Catalog-success/cache-failure must not accept a baseline.
261
+ // A committed owner publication supplies a read-only baseline even while heal writes are gated.
262
+ if (event.path === null || path === null || !sameCatalogHealPath(event.path, path)) return;
263
+ updateTarget(path);
264
+ released = false;
265
+ pendingRetry = null;
266
+ recheckAt = 0;
267
+ accept(event.path);
268
+ });
269
+ // Capture immediately after startup sync, before a foreign write can become the first tick.
270
+ try { updateTarget(catalogPath()); if (target !== null) accept(target); }
271
+ catch { /* Unavailable observation can be retried by the scheduled tick. */ }
272
+ const schedule = (): void => {
273
+ if (stopped) return;
274
+ timer = scheduleFn(() => {
275
+ void tick().catch(() => log.warn("[catalog-self-heal] scheduled observation failed")).finally(schedule);
276
+ }, CATALOG_HEAL_TICK_MS);
277
+ };
278
+ schedule();
279
+ return {
280
+ stop() { stopped = true; clear(); timer?.cancel(); timer = undefined; unsubscribe(); },
281
+ lastHeal: () => last,
282
+ tickForTests: tick,
283
+ };
284
+ }
@@ -28,12 +28,16 @@ import { chmodSync, lstatSync, realpathSync } from "node:fs";
28
28
 
29
29
  import { Database } from "bun:sqlite";
30
30
 
31
+ import { currentOpencodexHome, inspectCodexHomeOwner } from "./codex-home-owner";
32
+ import { appendCatalogWriteAudit } from "./catalog/write-audit";
33
+ import type { CatalogWriteAuditDetails, CatalogWriteIntent } from "./catalog/write-audit-contract";
31
34
  import {
32
35
  CodexUserIdentityRefusal,
33
36
  resolveCodexCatalogSerializationDatabasePath,
34
37
  resolveEffectiveUserIdentity,
35
38
  samePathIdentity,
36
39
  } from "./user-identity";
40
+ import { assertNotRealCodexHomeUnderTest } from "../lib/test-home-guard";
37
41
 
38
42
  /**
39
43
  * Authorization to perform the fixed sequence of catalog mutations inside ONE
@@ -51,9 +55,16 @@ export interface CatalogWritePermit {
51
55
 
52
56
  declare const catalogWritePermitBrand: unique symbol;
53
57
 
58
+ export type { CatalogWriteIntent } from "./catalog/write-audit-contract";
59
+
60
+ export interface CatalogWriteOptions {
61
+ readonly intent: CatalogWriteIntent;
62
+ readonly writer: string;
63
+ }
64
+
54
65
  export type CatalogSerializationOutcome<T> =
55
66
  | { kind: "completed"; value: T }
56
- | { kind: "unavailable"; reason: "busy" | "database" | "unsafe-path" };
67
+ | { kind: "unavailable"; reason: "busy" | "database" | "unsafe-path" | "foreign-owner" | "owner-unknown" };
57
68
 
58
69
  export class CatalogWritePermitRefusal extends Error {
59
70
  readonly code = "CODEX_CATALOG_WRITE_PERMIT_REFUSED";
@@ -69,6 +80,8 @@ interface PermitRegistration {
69
80
  readonly canonicalCodexHome: string;
70
81
  /** Identifies the acquisition, so a permit cannot outlive its transaction. */
71
82
  readonly transactionId: string;
83
+ readonly intent: CatalogWriteIntent;
84
+ readonly writer: string;
72
85
  live: boolean;
73
86
  }
74
87
 
@@ -121,6 +134,34 @@ export function assertCatalogWritePermit(
121
134
  }
122
135
  }
123
136
 
137
+ /** Only a live acquisition may expose its declared intent and writer. */
138
+ export function catalogWritePermitContext(permit: CatalogWritePermit): CatalogWriteOptions {
139
+ const registration = activePermits.get(permit as unknown as object);
140
+ if (!registration?.live) throw new CatalogWritePermitRefusal("The catalog write permit is not live.");
141
+ return { intent: registration.intent, writer: registration.writer };
142
+ }
143
+
144
+ /** Audit mutation is subject to the same live-home proof as catalog publication. */
145
+ export function auditCatalogWriteWithPermit(
146
+ permit: CatalogWritePermit,
147
+ canonicalCodexHome: string,
148
+ details: CatalogWriteAuditDetails,
149
+ ): "appended" | "created" | "skipped" {
150
+ assertCatalogWritePermit(permit, canonicalCodexHome);
151
+ const context = catalogWritePermitContext(permit);
152
+ try { return appendCatalogWriteAudit(canonicalCodexHome, {
153
+ target: details.target, outcome: details.outcome, reason: details.reason,
154
+ routedBefore: details.routedBefore, routedAfter: details.routedAfter,
155
+ configSource: details.configSource, intent: context.intent, writer: context.writer,
156
+ opencodexHome: currentOpencodexHome(),
157
+ }, { create: true }); } catch { return "skipped"; }
158
+ }
159
+
160
+ function ownerRefusal(canonicalCodexHome: string): "foreign-owner" | "owner-unknown" | null {
161
+ const owner = inspectCodexHomeOwner(canonicalCodexHome);
162
+ return owner.kind === "foreign" ? "foreign-owner" : owner.kind === "unknown" ? "owner-unknown" : null;
163
+ }
164
+
124
165
  /**
125
166
  * Acquire K for one canonical CODEX_HOME and run `write` while it is held.
126
167
  *
@@ -135,7 +176,14 @@ export function assertCatalogWritePermit(
135
176
  export function withCatalogWriteSerialization<T>(
136
177
  canonicalCodexHome: string,
137
178
  write: (permit: CatalogWritePermit) => T,
179
+ options: CatalogWriteOptions,
138
180
  ): CatalogSerializationOutcome<T> {
181
+ // First, ahead of the owner precheck and its diagnostics: a test process never gets a permit for
182
+ // the real Codex home, whatever its CODEX_HOME resolved to (#6529).
183
+ assertNotRealCodexHomeUnderTest(canonicalCodexHome);
184
+ // Re-evaluate on every acquisition; unknown evidence cannot authorize a takeover.
185
+ const refusedOwner = ownerRefusal(canonicalCodexHome);
186
+ // A precheck refusal may still acquire K for append-only diagnostics; it never gets a permit.
139
187
  let databasePath: string;
140
188
  try {
141
189
  databasePath = resolveCodexCatalogSerializationDatabasePath(
@@ -144,9 +192,9 @@ export function withCatalogWriteSerialization<T>(
144
192
  );
145
193
  } catch (error) {
146
194
  if (error instanceof CodexUserIdentityRefusal) {
147
- return { kind: "unavailable", reason: "unsafe-path" };
195
+ return { kind: "unavailable", reason: refusedOwner ?? "unsafe-path" };
148
196
  }
149
- return { kind: "unavailable", reason: "database" };
197
+ return { kind: "unavailable", reason: refusedOwner ?? "database" };
150
198
  }
151
199
 
152
200
  let database: Database | undefined;
@@ -159,12 +207,12 @@ export function withCatalogWriteSerialization<T>(
159
207
  try {
160
208
  const before = lstatSync(databasePath);
161
209
  if (before.isSymbolicLink() || !before.isFile()) {
162
- return { kind: "unavailable", reason: "unsafe-path" };
210
+ return { kind: "unavailable", reason: refusedOwner ?? "unsafe-path" };
163
211
  }
164
212
  if (process.platform !== "win32") {
165
213
  const uid = process.getuid?.();
166
214
  if (uid === undefined || before.uid !== uid || (before.mode & 0o777) !== 0o600) {
167
- return { kind: "unavailable", reason: "unsafe-path" };
215
+ return { kind: "unavailable", reason: refusedOwner ?? "unsafe-path" };
168
216
  }
169
217
  }
170
218
  } catch (cause) {
@@ -182,16 +230,31 @@ export function withCatalogWriteSerialization<T>(
182
230
  const opened = lstatSync(databasePath);
183
231
  if (opened.isSymbolicLink() || !opened.isFile()
184
232
  || !samePathIdentity(realpathSync.native(databasePath), databasePath)) {
185
- return { kind: "unavailable", reason: "unsafe-path" };
233
+ return { kind: "unavailable", reason: refusedOwner ?? "unsafe-path" };
186
234
  }
187
235
 
188
236
  database.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE");
189
237
  transactionOpen = true;
190
238
 
239
+ // Ownership may have changed while opening K. Refuse before minting a permit or invoking work.
240
+ const lockedOwnerRefusal = refusedOwner ?? ownerRefusal(canonicalCodexHome);
241
+ if (lockedOwnerRefusal) {
242
+ try { appendCatalogWriteAudit(canonicalCodexHome, {
243
+ target: options.intent === "cache" ? "cache" : "catalog",
244
+ outcome: "refused", reason: lockedOwnerRefusal,
245
+ intent: options.intent, writer: options.writer, opencodexHome: currentOpencodexHome(),
246
+ }, { create: false }); } catch { /* Diagnostic metadata cannot mask the owner refusal. */ }
247
+ database.exec("ROLLBACK");
248
+ transactionOpen = false;
249
+ return { kind: "unavailable", reason: lockedOwnerRefusal };
250
+ }
251
+
191
252
  acquisitionCounter += 1;
192
253
  registration = {
193
254
  canonicalCodexHome,
194
255
  transactionId: `${process.pid}:${acquisitionCounter}`,
256
+ intent: options.intent,
257
+ writer: options.writer,
195
258
  live: true,
196
259
  };
197
260
  // A bare object: nothing about it is guessable or reconstructable, because
@@ -224,8 +287,9 @@ export function withCatalogWriteSerialization<T>(
224
287
  if (permit) activePermits.delete(permit as unknown as object);
225
288
  }
226
289
  if (error instanceof CodexUserIdentityRefusal) {
227
- return { kind: "unavailable", reason: "unsafe-path" };
290
+ return { kind: "unavailable", reason: refusedOwner ?? "unsafe-path" };
228
291
  }
292
+ if (refusedOwner) return { kind: "unavailable", reason: refusedOwner };
229
293
  if (isBusy(error)) return { kind: "unavailable", reason: "busy" };
230
294
  // A callback failure is the caller's error, not a lock outcome: K acquired
231
295
  // fine. Reporting it as `unavailable` would tell the caller to retry
@@ -0,0 +1,124 @@
1
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, realpathSync, statSync } from "node:fs";
2
+ import { dirname, isAbsolute, join, resolve } from "node:path";
3
+ import { getConfigDir } from "../config/paths";
4
+ import { samePathIdentity } from "./user-identity";
5
+
6
+ export const CODEX_HOME_JOURNAL_FILE = "opencodex-journal.json";
7
+ const MAX_JOURNAL_BYTES = 1024 * 1024;
8
+
9
+ /** K imports this module; keep it independent of the config barrel and journal writer. */
10
+ export function currentOpencodexHome(): string {
11
+ const configured = getConfigDir();
12
+ try { return realpathSync.native(configured); }
13
+ catch { return resolve(configured); }
14
+ }
15
+
16
+ export type CodexHomeOwnership =
17
+ | { readonly kind: "unbound" }
18
+ | { readonly kind: "owned" }
19
+ | { readonly kind: "stale"; readonly boundHome: string }
20
+ | { readonly kind: "foreign"; readonly boundHome: string }
21
+ | { readonly kind: "unknown" };
22
+
23
+ export type CodexHomeJournalInspection =
24
+ | { kind: "missing" }
25
+ | { kind: "read"; journal: Record<string, unknown> }
26
+ | { kind: "unknown" };
27
+
28
+ function absent(error: unknown): boolean {
29
+ return (error as NodeJS.ErrnoException)?.code === "ENOENT";
30
+ }
31
+
32
+ /** ENOENT through a dangling link or an unresolvable ancestor is not proven absence. */
33
+ function provenAbsent(path: string): boolean {
34
+ try { lstatSync(path); return false; }
35
+ catch (error) { if (!absent(error)) return false; }
36
+ const parent = dirname(path);
37
+ if (parent === path) return false;
38
+ try { return statSync(realpathSync.native(parent)).isDirectory(); }
39
+ catch (error) { return absent(error) && provenAbsent(parent); }
40
+ }
41
+
42
+ function validJournal(value: unknown): value is Record<string, unknown> {
43
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
44
+ const journal = value as Record<string, unknown>;
45
+ return journal.version === 1 && typeof journal.originalConfig === "string"
46
+ && (journal.originalProfile === null || typeof journal.originalProfile === "string")
47
+ && (journal.opencodexHome === undefined || (typeof journal.opencodexHome === "string"
48
+ && isAbsolute(journal.opencodexHome) && !journal.opencodexHome.includes("\0")));
49
+ }
50
+
51
+ /** Bounded, regular-file-only observation. Failure never supplies cleanup authority. */
52
+ export function readCodexHomeJournal(journalPath: string): CodexHomeJournalInspection {
53
+ let fd: number | undefined;
54
+ try {
55
+ const before = lstatSync(journalPath);
56
+ if (!before.isFile() || before.isSymbolicLink() || before.size > MAX_JOURNAL_BYTES) return { kind: "unknown" };
57
+ fd = openSync(journalPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
58
+ const opened = fstatSync(fd);
59
+ if (!opened.isFile() || opened.size > MAX_JOURNAL_BYTES || before.dev !== opened.dev || before.ino !== opened.ino) return { kind: "unknown" };
60
+ const bytes = Buffer.alloc(opened.size + 1);
61
+ let length = 0;
62
+ while (length < bytes.length) {
63
+ const count = readSync(fd, bytes, length, bytes.length - length, null);
64
+ if (!count) break;
65
+ length += count;
66
+ }
67
+ const after = lstatSync(journalPath);
68
+ if (length !== opened.size || !after.isFile() || after.dev !== opened.dev || after.ino !== opened.ino
69
+ || after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || after.ctimeMs !== opened.ctimeMs) return { kind: "unknown" };
70
+ const journal: unknown = JSON.parse(bytes.subarray(0, length).toString("utf8"));
71
+ return validJournal(journal) ? { kind: "read", journal } : { kind: "unknown" };
72
+ } catch (error) {
73
+ return fd === undefined && absent(error) && provenAbsent(journalPath) ? { kind: "missing" } : { kind: "unknown" };
74
+ } finally { if (fd !== undefined) closeSync(fd); }
75
+ }
76
+
77
+ function inspectBinding(boundHome: string, current: string): CodexHomeOwnership {
78
+ let physical: string;
79
+ try {
80
+ physical = realpathSync.native(boundHome);
81
+ if (!statSync(physical).isDirectory()) return { kind: "unknown" };
82
+ } catch { return provenAbsent(boundHome) ? { kind: "stale", boundHome } : { kind: "unknown" }; }
83
+ try {
84
+ const own = realpathSync.native(current);
85
+ if (!statSync(own).isDirectory()) return { kind: "unknown" };
86
+ return samePathIdentity(physical, own) ? { kind: "owned" } : { kind: "foreign", boundHome };
87
+ } catch { return { kind: "unknown" }; }
88
+ }
89
+
90
+ export function inspectCodexHomeOwner(canonicalHome: string, current = currentOpencodexHome()): CodexHomeOwnership {
91
+ const read = readCodexHomeJournal(join(canonicalHome, CODEX_HOME_JOURNAL_FILE));
92
+ if (read.kind === "unknown") return { kind: "unknown" };
93
+ if (read.kind === "missing" || read.journal.opencodexHome === undefined) return { kind: "unbound" };
94
+ return inspectBinding(read.journal.opencodexHome as string, current);
95
+ }
96
+
97
+ /** Preserve extant and uncertain bindings; only legacy or proven stale records are adopted. */
98
+ export function opencodexHomeForInjection(recorded: string | undefined, current = currentOpencodexHome()): string {
99
+ if (recorded === undefined) return current;
100
+ return inspectBinding(recorded, current).kind === "stale" ? current : recorded;
101
+ }
102
+
103
+ export type CodexHomeOwnerRefusalReason = "foreign-owner" | "owner-unknown";
104
+ export class CodexHomeOwnerRefusal extends Error {
105
+ constructor(readonly reason: CodexHomeOwnerRefusalReason) {
106
+ super(reason === "foreign-owner"
107
+ ? "Codex home is bound to another OpenCodex home; configuration and journal were preserved. Restore from the owning home first."
108
+ : "Codex home ownership could not be verified; configuration and journal were preserved.");
109
+ this.name = "CodexHomeOwnerRefusal";
110
+ }
111
+ }
112
+ export function assertCodexHomeOwner(home: string): void {
113
+ const owner = inspectCodexHomeOwner(home);
114
+ if (owner.kind === "foreign" || owner.kind === "unknown") {
115
+ throw new CodexHomeOwnerRefusal(owner.kind === "foreign" ? "foreign-owner" : "owner-unknown");
116
+ }
117
+ }
118
+
119
+ /** Compensation must not replace evidence another home published during this operation. */
120
+ export function codexHomeOwnerBlocksCompensation(home: string, error: unknown): boolean {
121
+ if (!(error instanceof CodexHomeOwnerRefusal)) return false;
122
+ const owner = inspectCodexHomeOwner(home);
123
+ return owner.kind === "foreign" || owner.kind === "unknown";
124
+ }