@bitkyc08/opencodex 2.76.0 → 2.78.0-preview.20261005

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 (353) hide show
  1. package/bin/ocx.mjs +11 -10
  2. package/gui/dist/assets/App-CppMz-6w.js +54 -0
  3. package/gui/dist/assets/App-lCIslZ1L.css +1 -0
  4. package/gui/dist/assets/{Tray-DjNwwrWS.js → Tray-CJWQqtVv.js} +1 -1
  5. package/gui/dist/assets/index-DQt9srNi.js +94 -0
  6. package/gui/dist/assets/index-DmQB8D_R.css +1 -0
  7. package/gui/dist/assets/quota-summary-BMsyoZH2.js +1 -0
  8. package/gui/dist/index.html +4 -3
  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/antigravity-refusal.ts +28 -0
  15. package/src/adapters/cursor/protobuf-request.ts +6 -0
  16. package/src/adapters/cursor/request-builder.ts +3 -0
  17. package/src/adapters/cursor/structured-output.ts +13 -0
  18. package/src/adapters/cursor/types.ts +2 -0
  19. package/src/adapters/devin/cloud-direct/chat.ts +7 -2
  20. package/src/adapters/devin/trajectory.ts +49 -0
  21. package/src/adapters/devin.ts +13 -0
  22. package/src/adapters/ollama-native.ts +62 -19
  23. package/src/adapters/openai-responses/passthrough.ts +133 -103
  24. package/src/adapters/openai-responses/request-strips.ts +10 -4
  25. package/src/adapters/zed.ts +26 -4
  26. package/src/bridge/response-json.ts +14 -14
  27. package/src/bridge/sse.ts +15 -5
  28. package/src/chatgpt/app-server-shim/bundle-trust.ts +40 -10
  29. package/src/chatgpt/app-server-shim/gate-rewrite.ts +3 -0
  30. package/src/claude/agents-inject.ts +4 -43
  31. package/src/claude/gateway-cache.ts +16 -13
  32. package/src/claude/inbound-model-options.ts +9 -2
  33. package/src/claude/intercept/cli-catalog.ts +27 -16
  34. package/src/claude/intercept/client-hello.ts +103 -0
  35. package/src/claude/intercept/model-bindings.ts +3 -2
  36. package/src/claude/intercept/picker-bootstrap.ts +37 -16
  37. package/src/claude/intercept/picker-budget.ts +41 -0
  38. package/src/claude/intercept/picker-listener.ts +279 -66
  39. package/src/claude/outbound.ts +2 -0
  40. package/src/claude/subagent-force-status.ts +53 -0
  41. package/src/claude/subagent-model.ts +93 -0
  42. package/src/cli/access-audio-input.ts +84 -0
  43. package/src/cli/access-audio-live.ts +99 -0
  44. package/src/cli/access-audio.ts +123 -0
  45. package/src/cli/access-data-client.ts +127 -0
  46. package/src/cli/access-data-plane.ts +104 -0
  47. package/src/cli/access-data-response.ts +76 -0
  48. package/src/cli/access.ts +65 -2
  49. package/src/cli/account-api.ts +33 -6
  50. package/src/cli/account-auth.ts +111 -42
  51. package/src/cli/account-key-quota.ts +118 -0
  52. package/src/cli/account-main.ts +11 -4
  53. package/src/cli/account-next-actions.ts +63 -0
  54. package/src/cli/account-policy-dto.ts +68 -0
  55. package/src/cli/account-policy.ts +167 -0
  56. package/src/cli/account-target.ts +8 -0
  57. package/src/cli/account.ts +37 -9
  58. package/src/cli/agent-runtime-settings.ts +130 -0
  59. package/src/cli/agent-settings.ts +101 -0
  60. package/src/cli/agent.ts +29 -2
  61. package/src/cli/alias.ts +7 -2
  62. package/src/cli/aside-profiles.ts +2 -2
  63. package/src/cli/capabilities-access-remote.ts +147 -0
  64. package/src/cli/capabilities-accounts.ts +195 -0
  65. package/src/cli/capabilities-agents-routing.ts +414 -0
  66. package/src/cli/capabilities-base.ts +783 -0
  67. package/src/cli/capabilities-command.ts +1 -0
  68. package/src/cli/capabilities-integrations.ts +264 -0
  69. package/src/cli/capabilities-lab.ts +214 -0
  70. package/src/cli/capabilities-observe-system.ts +662 -0
  71. package/src/cli/capabilities-provider-models.ts +302 -0
  72. package/src/cli/capabilities.ts +30 -1190
  73. package/src/cli/capability-types.ts +54 -0
  74. package/src/cli/catalog-command-result.ts +57 -0
  75. package/src/cli/chatgpt-command.ts +35 -9
  76. package/src/cli/claude-agent-startup-sync.ts +17 -0
  77. package/src/cli/claude-desktop-profile.ts +100 -0
  78. package/src/cli/claude-desktop.ts +39 -6
  79. package/src/cli/claude.ts +22 -6
  80. package/src/cli/codex-shim-autorestore.ts +2 -1
  81. package/src/cli/codex-shim-readiness.ts +109 -0
  82. package/src/cli/combo-input.ts +88 -0
  83. package/src/cli/combo-result.ts +52 -0
  84. package/src/cli/combo-stats.ts +123 -0
  85. package/src/cli/combo.ts +37 -14
  86. package/src/cli/companion-timeline.ts +142 -0
  87. package/src/cli/companion-usage.ts +146 -0
  88. package/src/cli/companion.ts +12 -3
  89. package/src/cli/config-command.ts +24 -8
  90. package/src/cli/connect.ts +19 -0
  91. package/src/cli/dispatch.ts +55 -82
  92. package/src/cli/doctor.ts +22 -4
  93. package/src/cli/export-command.ts +20 -9
  94. package/src/cli/gui.ts +8 -3
  95. package/src/cli/help-catalog.ts +101 -0
  96. package/src/cli/help-models-context.ts +17 -0
  97. package/src/cli/help-navigation.ts +50 -0
  98. package/src/cli/help-recovery.ts +66 -0
  99. package/src/cli/help.ts +69 -11
  100. package/src/cli/index.ts +38 -27
  101. package/src/cli/injection-follow.ts +35 -0
  102. package/src/cli/inspect.ts +5 -0
  103. package/src/cli/integration-aside-sync.ts +69 -0
  104. package/src/cli/integration-cursor.ts +106 -0
  105. package/src/cli/integration-input.ts +60 -0
  106. package/src/cli/integration-journal.ts +49 -0
  107. package/src/cli/integration-plan-dto.ts +156 -0
  108. package/src/cli/integration-preview.ts +215 -0
  109. package/src/cli/integrations.ts +24 -18
  110. package/src/cli/json-input.ts +134 -0
  111. package/src/cli/link.ts +32 -4
  112. package/src/cli/local-sync-result.ts +41 -0
  113. package/src/cli/log-follow.ts +84 -0
  114. package/src/cli/log-view-filter.ts +146 -0
  115. package/src/cli/logout-command.ts +44 -0
  116. package/src/cli/model-picker-ordering.ts +137 -0
  117. package/src/cli/models-custom-input.ts +107 -0
  118. package/src/cli/models-custom-runtime.ts +112 -0
  119. package/src/cli/models-order.ts +194 -0
  120. package/src/cli/models-runtime-subcommands.ts +2 -0
  121. package/src/cli/models-runtime.ts +9 -1
  122. package/src/cli/models.ts +66 -81
  123. package/src/cli/observe-stream.ts +142 -0
  124. package/src/cli/observe.ts +75 -34
  125. package/src/cli/opencode.ts +54 -10
  126. package/src/cli/provider-batch.ts +77 -0
  127. package/src/cli/provider-lifecycle-runtime.ts +170 -0
  128. package/src/cli/provider-result.ts +124 -0
  129. package/src/cli/provider-runtime.ts +15 -12
  130. package/src/cli/provider-settings.ts +143 -0
  131. package/src/cli/provider.ts +116 -64
  132. package/src/cli/registry.ts +90 -24
  133. package/src/cli/remote-workspace-hub.ts +83 -0
  134. package/src/cli/remote-workspace.ts +6 -1
  135. package/src/cli/restart-failure.ts +27 -0
  136. package/src/cli/root.ts +42 -9
  137. package/src/cli/route-policy-write.ts +137 -0
  138. package/src/cli/route-policy.ts +8 -0
  139. package/src/cli/runtime-api.ts +57 -80
  140. package/src/cli/secret-args.ts +149 -0
  141. package/src/cli/settings-result.ts +30 -0
  142. package/src/cli/storage.ts +77 -7
  143. package/src/cli/system-command.ts +60 -2
  144. package/src/cli/system-restart-client.ts +20 -3
  145. package/src/cli/system-settings-parity.ts +83 -0
  146. package/src/cli/tray-proxy.ts +3 -2
  147. package/src/cli/update-restart-candidate.ts +21 -0
  148. package/src/cli/update-restart-child.ts +81 -0
  149. package/src/cli/update-restart-home.ts +38 -0
  150. package/src/cli/update-restart-transport.ts +193 -0
  151. package/src/cli/update-restart.ts +260 -0
  152. package/src/cli/usage-model-search.ts +58 -0
  153. package/src/cli/usage-report.ts +13 -5
  154. package/src/cli/v2-input.ts +66 -0
  155. package/src/cli/v2-local-output.ts +37 -0
  156. package/src/cli/v2-runtime.ts +90 -0
  157. package/src/cli/v2.ts +102 -144
  158. package/src/clients/config-export/contracts.ts +100 -0
  159. package/src/clients/config-export/droid.ts +54 -2
  160. package/src/clients/config-export/dsh.ts +21 -0
  161. package/src/clients/config-export/kilo.ts +12 -2
  162. package/src/clients/config-export/model-metadata.ts +10 -4
  163. package/src/clients/config-export/reasoning-metadata.ts +47 -0
  164. package/src/clients/config-export.ts +53 -67
  165. package/src/clients/omo-role-models.ts +17 -3
  166. package/src/codex/account-credit-use.ts +4 -2
  167. package/src/codex/account-runtime-state.ts +21 -0
  168. package/src/codex/account-store.ts +11 -47
  169. package/src/codex/account-usability.ts +11 -8
  170. package/src/codex/auth-api/account-list.ts +73 -63
  171. package/src/codex/auth-api/main-account-probe.ts +25 -12
  172. package/src/codex/auth-api/pool-mode-gate.ts +6 -9
  173. package/src/codex/auth-api/pool-quota-probe.ts +2 -4
  174. package/src/codex/auth-api/reset-credit-service.ts +3 -2
  175. package/src/codex/auth-context.ts +30 -11
  176. package/src/codex/catalog/aggregation.ts +3 -0
  177. package/src/codex/catalog/build-entries.ts +1 -1
  178. package/src/codex/catalog/bundled.ts +9 -2
  179. package/src/codex/catalog/heal-observation.ts +66 -0
  180. package/src/codex/catalog/model-hints.ts +2 -1
  181. package/src/codex/catalog/model-visibility.ts +6 -3
  182. package/src/codex/catalog/parsing.ts +2 -0
  183. package/src/codex/catalog/provider-models.ts +13 -4
  184. package/src/codex/catalog/publication-observer.ts +19 -0
  185. package/src/codex/catalog/remote.ts +13 -15
  186. package/src/codex/catalog/restore.ts +19 -6
  187. package/src/codex/catalog/retained-sync.ts +60 -11
  188. package/src/codex/catalog/routed-removal.ts +116 -0
  189. package/src/codex/catalog/write-audit-contract.ts +21 -0
  190. package/src/codex/catalog/write-audit.ts +171 -0
  191. package/src/codex/catalog-self-heal.ts +284 -0
  192. package/src/codex/catalog-write-serialization.ts +71 -7
  193. package/src/codex/chatgpt-refresh-failure.ts +56 -0
  194. package/src/codex/codex-home-owner.ts +124 -0
  195. package/src/codex/convergence.ts +57 -16
  196. package/src/codex/features.ts +8 -1
  197. package/src/codex/inject/restore.ts +46 -4
  198. package/src/codex/inject.ts +11 -1
  199. package/src/codex/internal/catalog-writer.ts +120 -13
  200. package/src/codex/journal.ts +49 -13
  201. package/src/codex/main-account.ts +144 -11
  202. package/src/codex/management-convergence.ts +3 -0
  203. package/src/codex/quota-refresh-outcome.ts +17 -2
  204. package/src/codex/quota-types.ts +36 -1
  205. package/src/codex/quota.ts +67 -16
  206. package/src/codex/refresh.ts +20 -9
  207. package/src/codex/routing/cooldown-math.ts +10 -4
  208. package/src/codex/routing/selection.ts +11 -4
  209. package/src/codex/routing.ts +9 -9
  210. package/src/codex/runtime.ts +7 -1
  211. package/src/codex/shim-diagnostics.ts +80 -0
  212. package/src/codex/shim-fingerprint.ts +7 -2
  213. package/src/codex/shim-migration.ts +153 -0
  214. package/src/codex/shim-overlay.ts +509 -0
  215. package/src/codex/shim-path-resolution.ts +145 -0
  216. package/src/codex/shim-probe.ts +4 -3
  217. package/src/codex/shim-state-file.ts +93 -7
  218. package/src/codex/shim.ts +54 -107
  219. package/src/codex/subagent-model-fallback.ts +2 -1
  220. package/src/codex/sync.ts +24 -4
  221. package/src/combos/failover.ts +101 -1
  222. package/src/combos/index.ts +1 -0
  223. package/src/combos/jev.ts +13 -3
  224. package/src/combos/resolve.ts +12 -0
  225. package/src/config/atomic-write.ts +13 -8
  226. package/src/config/diagnostics.ts +23 -1
  227. package/src/config/load-degrade.ts +7 -0
  228. package/src/config/provider-validation.ts +3 -0
  229. package/src/config/schema/chatgpt-desktop.ts +22 -0
  230. package/src/config/schema/compaction-triggers.ts +8 -0
  231. package/src/config/schema/config-schema.ts +43 -14
  232. package/src/config/schema/leaf-validators.ts +8 -3
  233. package/src/config/subagent-models.ts +12 -0
  234. package/src/generated/compatibility-version.json +645 -237
  235. package/src/grok/session-identity.ts +33 -0
  236. package/src/integrations/catalog-refresh.ts +1 -1
  237. package/src/integrations/merge.ts +13 -5
  238. package/src/integrations/mutation-plan.ts +50 -5
  239. package/src/integrations/omp-yaml-source.ts +181 -1
  240. package/src/integrations/ownership-policy.ts +66 -0
  241. package/src/integrations/registry.ts +50 -1
  242. package/src/integrations/state.ts +106 -19
  243. package/src/integrations/target.ts +25 -2
  244. package/src/integrations/writer.ts +107 -35
  245. package/src/lib/gui-pair-capability.ts +17 -0
  246. package/src/lib/package-tree-integrity.ts +42 -1
  247. package/src/lib/provider-client-headers.ts +62 -0
  248. package/src/lib/proxy-env.ts +3 -0
  249. package/src/lib/test-home-guard.ts +2 -1
  250. package/src/oauth/anthropic-account-refusal.ts +19 -8
  251. package/src/oauth/anthropic-continuity.ts +65 -0
  252. package/src/oauth/anthropic-identity.ts +75 -0
  253. package/src/oauth/anthropic-routing.ts +20 -15
  254. package/src/oauth/anthropic.ts +34 -3
  255. package/src/oauth/generic-account-failover.ts +21 -0
  256. package/src/oauth/health.ts +32 -5
  257. package/src/oauth/index.ts +42 -35
  258. package/src/oauth/local-token-detect.ts +7 -0
  259. package/src/oauth/pool-settings-capability.ts +9 -2
  260. package/src/oauth/refresh-policy.ts +2 -0
  261. package/src/oauth/store.ts +55 -6
  262. package/src/oauth/types.ts +9 -0
  263. package/src/protocols/encoders/messages.ts +4 -3
  264. package/src/protocols/plan-snapshot.ts +1 -1
  265. package/src/protocols/settings.ts +28 -4
  266. package/src/providers/antigravity-effort-families.ts +75 -0
  267. package/src/providers/antigravity-models.ts +54 -16
  268. package/src/providers/antigravity-wire-snapshot.ts +66 -0
  269. package/src/providers/derive.ts +2 -0
  270. package/src/providers/model-rename-fields.ts +3 -0
  271. package/src/providers/new-model-policy.ts +48 -10
  272. package/src/providers/registry/entries-extended.ts +3 -0
  273. package/src/providers/registry/model-ids.ts +2 -0
  274. package/src/providers/registry/model-seeds.ts +5 -0
  275. package/src/providers/registry/types.ts +4 -0
  276. package/src/responses/compaction.ts +19 -0
  277. package/src/router.ts +6 -0
  278. package/src/server/auth-cors.ts +9 -0
  279. package/src/server/caller-session-identity.ts +32 -0
  280. package/src/server/chat-completions.ts +10 -0
  281. package/src/server/claude-messages.ts +29 -15
  282. package/src/server/droid-reasoning-default.ts +50 -0
  283. package/src/server/github-copilot-responses-repair.ts +3 -6
  284. package/src/server/grok-responses-control-frame.ts +2 -2
  285. package/src/server/grok-responses-snapshot-repair.ts +4 -5
  286. package/src/server/gui-session.ts +33 -11
  287. package/src/server/index/claude-intercept-lifecycle.ts +4 -0
  288. package/src/server/index/serve-options.ts +13 -7
  289. package/src/server/index.ts +1 -1
  290. package/src/server/management/agent-settings-routes.ts +12 -134
  291. package/src/server/management/config-routes.ts +1 -1
  292. package/src/server/management/context.ts +8 -1
  293. package/src/server/management/cursor-integration-routes.ts +3 -3
  294. package/src/server/management/integration-routes.ts +69 -11
  295. package/src/server/management/link-routes.ts +20 -13
  296. package/src/server/management/model-routes.ts +3 -3
  297. package/src/server/management/model-row-export-metadata.ts +150 -0
  298. package/src/server/management/model-rows.ts +148 -9
  299. package/src/server/management/native-integration-routes.ts +2 -2
  300. package/src/server/management/oauth-account-routes.ts +96 -30
  301. package/src/server/management/route-registry.ts +29 -27
  302. package/src/server/management/shared.ts +19 -5
  303. package/src/server/management/subagent-model-routes.ts +214 -0
  304. package/src/server/messages-native-eligibility.ts +3 -6
  305. package/src/server/messages-native-oauth.ts +82 -35
  306. package/src/server/messages-native.ts +78 -16
  307. package/src/server/relay.ts +14 -44
  308. package/src/server/request-log-generation-window.ts +38 -0
  309. package/src/server/request-log.ts +13 -0
  310. package/src/server/responses/adapter-dispatch.ts +152 -28
  311. package/src/server/responses/codex-auth-error.ts +10 -2
  312. package/src/server/responses/combo-stream-preflight.ts +29 -3
  313. package/src/server/responses/compaction-routing.ts +10 -1
  314. package/src/server/responses/core-auth.ts +67 -30
  315. package/src/server/responses/core-codex-account.ts +6 -3
  316. package/src/server/responses/core-combo-failure.ts +5 -1
  317. package/src/server/responses/core-combo-native.ts +7 -1
  318. package/src/server/responses/core-combo.ts +64 -7
  319. package/src/server/responses/core-normalize.ts +7 -0
  320. package/src/server/responses/core-options.ts +5 -0
  321. package/src/server/responses/fetch-helpers.ts +9 -0
  322. package/src/server/responses/passthrough-delivery.ts +1 -1
  323. package/src/server/responses/policy-fallback.ts +1 -1
  324. package/src/server/responses/request-prepare.ts +5 -0
  325. package/src/server/responses/request-spend.ts +30 -30
  326. package/src/server/responses-custom-tool-repair.ts +2 -2
  327. package/src/server/responses-hosted-image-display.ts +70 -9
  328. package/src/server/responses-terminal-repair.ts +15 -2
  329. package/src/server/sse-frame-buffer.ts +38 -36
  330. package/src/server/sse-payload-rewrite.ts +78 -49
  331. package/src/server/ws-bridge.ts +1 -11
  332. package/src/service/cli.ts +6 -1
  333. package/src/service/ownership-mutation-lease.d.mts +26 -0
  334. package/src/service/ownership-mutation-lease.mjs +76 -1
  335. package/src/service/windows-taskxml.ts +1 -2
  336. package/src/service-manager-probe.ts +36 -2
  337. package/src/types/config.ts +6 -0
  338. package/src/types/provider.ts +28 -10
  339. package/src/update/index.ts +2 -1
  340. package/src/update/npm-invocation.mjs +35 -3
  341. package/src/update/proxy-liveness-probe.mjs +38 -8
  342. package/src/update/update-failure-guidance.d.mts +6 -0
  343. package/src/update/update-failure-guidance.mjs +30 -5
  344. package/src/usage/expected-prices.ts +9 -0
  345. package/src/usage/log.ts +7 -0
  346. package/src/usage/summary.ts +82 -1
  347. package/src/web-search/loop.ts +11 -8
  348. package/src/web-search/run-turn-loop.ts +10 -7
  349. package/gui/dist/assets/App-CddorHdA.js +0 -51
  350. package/gui/dist/assets/App-DMudtISW.css +0 -1
  351. package/gui/dist/assets/index-Btze1I3T.css +0 -1
  352. package/gui/dist/assets/index-CvnY7E6i.js +0 -86
  353. package/gui/dist/assets/quota-summary-B_AWpHfP.js +0 -1
@@ -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
+ }
@@ -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