@bitkyc08/opencodex 2.59.0 → 2.61.0-preview.20260922

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 (374) hide show
  1. package/AGENTS_INSTALL.md +64 -0
  2. package/README.md +28 -1
  3. package/bin/ocx.mjs +382 -209
  4. package/gui/dist/assets/App-E64Rzjap.js +50 -0
  5. package/gui/dist/assets/Tray-_nfzD8k4.js +1 -0
  6. package/gui/dist/assets/index-DpdfZWMK.js +86 -0
  7. package/gui/dist/assets/index-_bpvxJu0.css +1 -0
  8. package/gui/dist/assets/usage-companion-chart-DtoK7T6h.js +1 -0
  9. package/gui/dist/favicon.png +0 -0
  10. package/gui/dist/index.html +2 -2
  11. package/gui/dist/provider-icons/stepfun-color.svg +1 -0
  12. package/package.json +8 -1
  13. package/src/adapters/anthropic-image-codec.ts +16 -2
  14. package/src/adapters/anthropic-image-normalize.ts +49 -2
  15. package/src/adapters/anthropic.ts +20 -1
  16. package/src/adapters/base.ts +23 -0
  17. package/src/adapters/coding-agent/protocol.ts +36 -6
  18. package/src/adapters/coding-agent/turn.ts +32 -4
  19. package/src/adapters/command-code.ts +52 -4
  20. package/src/adapters/cursor/catalog.ts +51 -7
  21. package/src/adapters/cursor/checkpoint-store.ts +3 -0
  22. package/src/adapters/cursor/discovery.ts +11 -8
  23. package/src/adapters/cursor/live-transport.ts +26 -9
  24. package/src/adapters/cursor/protobuf-request.ts +6 -3
  25. package/src/adapters/cursor/request-builder.ts +20 -4
  26. package/src/adapters/cursor/transport.ts +19 -0
  27. package/src/adapters/cursor.ts +14 -3
  28. package/src/adapters/declaration-carrier.ts +45 -0
  29. package/src/adapters/devin/cloud-direct/chat.ts +3 -1
  30. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +42 -5
  31. package/src/adapters/devin.ts +125 -36
  32. package/src/adapters/google-antigravity-replay.ts +1 -1
  33. package/src/adapters/google-antigravity-wire.ts +34 -7
  34. package/src/adapters/google-errors.ts +7 -1
  35. package/src/adapters/google-http.ts +49 -10
  36. package/src/adapters/google-tool-schema.ts +595 -31
  37. package/src/adapters/google-wire-compiler.ts +93 -10
  38. package/src/adapters/google-wire-shape.ts +461 -0
  39. package/src/adapters/google.ts +66 -11
  40. package/src/adapters/image.ts +4 -1
  41. package/src/adapters/input-media-guard.ts +21 -9
  42. package/src/adapters/kiro/usage.ts +3 -2
  43. package/src/adapters/kiro-tool-fallback.ts +1 -1
  44. package/src/adapters/ollama-native.ts +6 -0
  45. package/src/adapters/openai-chat/developer-role.ts +61 -0
  46. package/src/adapters/openai-chat/messages.ts +46 -27
  47. package/src/adapters/openai-chat/parallel-tool-calls.ts +32 -0
  48. package/src/adapters/openai-chat/passthrough.ts +33 -9
  49. package/src/adapters/openai-chat/reasoning-wire.ts +89 -0
  50. package/src/adapters/openai-chat-images.ts +3 -1
  51. package/src/adapters/openai-chat.ts +23 -58
  52. package/src/adapters/openai-responses/image-gen.ts +8 -6
  53. package/src/adapters/openai-responses/passthrough.ts +17 -3
  54. package/src/adapters/openai-responses/reasoning.ts +7 -0
  55. package/src/adapters/opencode-go-additional-tools.ts +12 -2
  56. package/src/adapters/registry.ts +3 -2
  57. package/src/adapters/run-turn-queue.ts +178 -29
  58. package/src/adapters/xai-web-search.ts +16 -1
  59. package/src/bridge/errors.ts +8 -2
  60. package/src/bridge/response-json.ts +9 -1
  61. package/src/bridge/sse.ts +13 -151
  62. package/src/chat/inbound.ts +141 -5
  63. package/src/claude/desktop-3p.ts +7 -1
  64. package/src/claude/desktop-first-party.ts +183 -0
  65. package/src/claude/desktop-gateway-state.ts +41 -0
  66. package/src/claude/inbound-content-options.ts +6 -0
  67. package/src/claude/inbound.ts +32 -6
  68. package/src/claude/intercept/connect-proxy.ts +179 -0
  69. package/src/claude/intercept/listener.ts +122 -0
  70. package/src/claude/intercept/local-ca.ts +298 -0
  71. package/src/claude/intercept/runtime.ts +98 -0
  72. package/src/claude/intercept/settings.ts +189 -0
  73. package/src/cli/access.ts +87 -0
  74. package/src/cli/account-auth.ts +19 -0
  75. package/src/cli/account-extended.ts +4 -4
  76. package/src/cli/capabilities.ts +31 -0
  77. package/src/cli/claude-desktop.ts +206 -16
  78. package/src/cli/codex-shim-autorestore.ts +3 -0
  79. package/src/cli/companion.ts +56 -0
  80. package/src/cli/dispatch.ts +46 -7
  81. package/src/cli/doctor.ts +28 -9
  82. package/src/cli/ensure-desired-integrations.ts +43 -5
  83. package/src/cli/help.ts +7 -9
  84. package/src/cli/hub.ts +3 -2
  85. package/src/cli/index.ts +203 -59
  86. package/src/cli/init.ts +8 -0
  87. package/src/cli/integrations.ts +7 -1
  88. package/src/cli/opencode.ts +2 -2
  89. package/src/cli/provider.ts +13 -1
  90. package/src/cli/registry.ts +41 -2
  91. package/src/cli/resolve.ts +230 -0
  92. package/src/cli/root.ts +24 -1
  93. package/src/cli/start-ownership-publication.ts +56 -0
  94. package/src/cli/status-probes.ts +2 -18
  95. package/src/cli/status.ts +62 -0
  96. package/src/cli/stop-report.ts +143 -0
  97. package/src/cli/uninstall-plan.ts +9 -0
  98. package/src/client/machine-api.ts +2 -2
  99. package/src/client/machine-listener.ts +4 -7
  100. package/src/client/runtime.ts +26 -2
  101. package/src/clients/aside-profiles.ts +4 -0
  102. package/src/clients/config-export/zcode-store.ts +157 -0
  103. package/src/clients/config-export.ts +36 -0
  104. package/src/codex/account-store.ts +65 -0
  105. package/src/codex/app-server-processes.ts +72 -40
  106. package/src/codex/auth-api/account-list.ts +19 -11
  107. package/src/codex/auth-api/login-flow.ts +6 -1
  108. package/src/codex/auth-api/pool-quota-probe.ts +30 -7
  109. package/src/codex/autostart-health.ts +28 -0
  110. package/src/codex/catalog/build-entries.ts +2 -2
  111. package/src/codex/catalog/effort.ts +3 -3
  112. package/src/codex/catalog/gather-capture.ts +21 -2
  113. package/src/codex/catalog/model-hints.ts +29 -28
  114. package/src/codex/catalog/parsing.ts +7 -0
  115. package/src/codex/catalog/provider-models.ts +43 -17
  116. package/src/codex/catalog/retained-sync.ts +24 -28
  117. package/src/codex/catalog/routed-gather.ts +19 -0
  118. package/src/codex/context-compat.ts +5 -2
  119. package/src/codex/convergence.ts +2 -2
  120. package/src/codex/desired-state.ts +4 -1
  121. package/src/codex/history-job.ts +6 -6
  122. package/src/codex/history-provider.ts +31 -166
  123. package/src/codex/history-rollout-read.ts +174 -0
  124. package/src/codex/inject/config-toml.ts +41 -6
  125. package/src/codex/inject/paginated-openai-compat.ts +90 -0
  126. package/src/codex/inject.ts +18 -15
  127. package/src/codex/injected-marker.ts +18 -0
  128. package/src/codex/internal/catalog-writer.ts +33 -1
  129. package/src/codex/main-account.ts +6 -0
  130. package/src/codex/model-cache.ts +99 -6
  131. package/src/codex/model-entitlement-admission.ts +59 -0
  132. package/src/codex/model-entitlements.ts +116 -54
  133. package/src/codex/native-main-admission.ts +83 -0
  134. package/src/codex/observed-model-denials.ts +101 -8
  135. package/src/codex/prompt-text-probe.ts +9 -6
  136. package/src/codex/routing/health-store.ts +39 -0
  137. package/src/codex/routing/selection.ts +37 -1
  138. package/src/codex/routing.ts +12 -42
  139. package/src/codex/shim-templates.ts +29 -3
  140. package/src/codex/shim.ts +1 -1
  141. package/src/codex/subagent-model-fallback.ts +22 -4
  142. package/src/combos/failover.ts +3 -0
  143. package/src/companion/settings.ts +132 -0
  144. package/src/config/admitted-identity.ts +222 -0
  145. package/src/config/atomic-write.ts +117 -5
  146. package/src/config/diagnostics.ts +22 -1
  147. package/src/config/feature-flags.ts +5 -0
  148. package/src/config/load-degrade.ts +52 -7
  149. package/src/config/process-state.ts +1 -1
  150. package/src/config/proxy-env.ts +8 -2
  151. package/src/config/schema/compaction-triggers.ts +11 -0
  152. package/src/config/schema/config-schema.ts +31 -1
  153. package/src/config/schema/leaf-validators.ts +59 -0
  154. package/src/config.ts +3 -3
  155. package/src/generated/compatibility-version.json +604 -280
  156. package/src/grok/reset-coupons.ts +38 -19
  157. package/src/images/loop.ts +6 -1
  158. package/src/integrations/aside-profile-context.ts +37 -3
  159. package/src/integrations/aside-profile-journal.ts +68 -3
  160. package/src/integrations/aside-profiles.ts +128 -3
  161. package/src/integrations/config-io.ts +44 -10
  162. package/src/integrations/merge.ts +120 -13
  163. package/src/integrations/mutation-plan.ts +921 -0
  164. package/src/integrations/registry.ts +38 -0
  165. package/src/integrations/state.ts +78 -45
  166. package/src/integrations/target.ts +208 -0
  167. package/src/integrations/writer.ts +134 -110
  168. package/src/lab/conformance/fixture-provider.ts +5 -0
  169. package/src/lab/live/transport.ts +4 -0
  170. package/src/lab/live/types.ts +5 -0
  171. package/src/lab/subject/behavior-fingerprint.ts +1 -1
  172. package/src/lib/admin-secrets.ts +9 -1
  173. package/src/lib/browser-launch-notice.ts +59 -0
  174. package/src/lib/bun-runtime.ts +6 -2
  175. package/src/lib/debug-log-buffer.ts +6 -1
  176. package/src/lib/debug.ts +63 -0
  177. package/src/lib/errors.ts +79 -0
  178. package/src/lib/http-response-semantics.ts +57 -0
  179. package/src/lib/lab-live-pinned-sender.ts +26 -12
  180. package/src/lib/open-url.ts +51 -7
  181. package/src/lib/package-tree-integrity.ts +2 -1
  182. package/src/lib/package-version.ts +8 -0
  183. package/src/lib/pinned-http.ts +142 -2
  184. package/src/lib/plain-data.ts +103 -0
  185. package/src/lib/process-control.ts +13 -5
  186. package/src/lib/provider-egress.ts +310 -0
  187. package/src/lib/provider-outbound.ts +109 -16
  188. package/src/lib/proxy-env.ts +82 -7
  189. package/src/lib/request-execution-budget.ts +72 -0
  190. package/src/lib/request-failure-attribution.ts +183 -0
  191. package/src/lib/request-failure-model.ts +236 -0
  192. package/src/lib/request-resend-gate.ts +138 -0
  193. package/src/lib/socks5-fetch.ts +136 -26
  194. package/src/lib/spend-ledger-owner.ts +364 -0
  195. package/src/lib/spend-reservation-ledger.ts +218 -27
  196. package/src/lib/standalone.ts +16 -0
  197. package/src/lib/upstream-retry.ts +167 -16
  198. package/src/lib/windows-system-proxy.ts +16 -11
  199. package/src/lib/winsw.ts +2 -2
  200. package/src/oauth/callback-server.ts +4 -3
  201. package/src/oauth/generic-account-failover.ts +1 -0
  202. package/src/oauth/health.ts +12 -1
  203. package/src/oauth/index.ts +27 -108
  204. package/src/oauth/login-cli.ts +80 -29
  205. package/src/oauth/login-flow-state.ts +127 -0
  206. package/src/providers/api-key-resolve.ts +133 -0
  207. package/src/providers/api-key-selection.ts +5 -1
  208. package/src/providers/derive.ts +34 -17
  209. package/src/providers/devin-cli-authmode-migration.ts +14 -10
  210. package/src/providers/key-failover.ts +97 -19
  211. package/src/providers/key-store.ts +34 -110
  212. package/src/providers/model-rename-fields.ts +147 -0
  213. package/src/providers/model-rename-migration.ts +179 -38
  214. package/src/providers/model-rename-startup.ts +7 -5
  215. package/src/providers/openai-virtual-models.ts +42 -2
  216. package/src/providers/quota/antigravity.ts +22 -2
  217. package/src/providers/quota/vendor-probes-key.ts +38 -23
  218. package/src/providers/reasoning-metadata.ts +43 -18
  219. package/src/providers/registry/entries-core.ts +47 -20
  220. package/src/providers/registry/entries-extended.ts +63 -4
  221. package/src/providers/registry/model-ids.ts +168 -0
  222. package/src/providers/registry/model-seeds.ts +56 -10
  223. package/src/providers/registry/types.ts +2 -0
  224. package/src/providers/resolved-model-policy-merge.ts +167 -0
  225. package/src/providers/resolved-model-policy.ts +406 -0
  226. package/src/providers/stale-vision-classification-migration.ts +137 -0
  227. package/src/providers/xai-transport.ts +12 -1
  228. package/src/reasoning-effort.ts +8 -0
  229. package/src/responses/apply-patch-envelope.ts +0 -12
  230. package/src/responses/freeform-wrapper-scan.ts +279 -0
  231. package/src/responses/function-call-compat.ts +38 -1
  232. package/src/responses/inline-document.ts +65 -0
  233. package/src/responses/input-media.ts +42 -8
  234. package/src/responses/legacy-dotted-tool-name-repair.ts +134 -0
  235. package/src/responses/muse-tool-name-alias.ts +19 -0
  236. package/src/responses/parser-content.ts +8 -2
  237. package/src/responses/parser-tools.ts +3 -0
  238. package/src/responses/parser.ts +3 -1
  239. package/src/responses/progressive-freeform-input.ts +130 -0
  240. package/src/responses/reasoning-envelope.ts +30 -0
  241. package/src/responses/schema.ts +3 -0
  242. package/src/responses/state.ts +5 -12
  243. package/src/responses/tool-name-aliases.ts +15 -1
  244. package/src/router.ts +108 -117
  245. package/src/routing/compatibility/behavior.ts +9 -0
  246. package/src/routing/compatibility/subject.ts +16 -1
  247. package/src/server/adapter-resolve.ts +9 -0
  248. package/src/server/admission-model-scope.ts +219 -0
  249. package/src/server/audio-live.ts +9 -3
  250. package/src/server/audio-upstream.ts +18 -0
  251. package/src/server/auth-cors.ts +29 -0
  252. package/src/server/chat-completions.ts +60 -4
  253. package/src/server/chat-native.ts +19 -4
  254. package/src/server/claude-messages.ts +61 -20
  255. package/src/server/effort-row.ts +11 -3
  256. package/src/server/grok-responses-control-frame.ts +160 -1
  257. package/src/server/grok-responses-snapshot-repair.ts +113 -11
  258. package/src/server/gui-freshness.ts +103 -0
  259. package/src/server/gui-static.ts +7 -9
  260. package/src/server/images.ts +59 -6
  261. package/src/server/index/claude-intercept-lifecycle.ts +49 -0
  262. package/src/server/index/serve-options.ts +142 -39
  263. package/src/server/index/spend-ledger-lifecycle.ts +92 -0
  264. package/src/server/index/startup-warnings.ts +24 -0
  265. package/src/server/index/websocket-handler.ts +6 -1
  266. package/src/server/index.ts +34 -38
  267. package/src/server/lifecycle.ts +4 -4
  268. package/src/server/live-call-bindings.ts +6 -0
  269. package/src/server/live.ts +88 -3
  270. package/src/server/management/agent-settings-routes.ts +121 -36
  271. package/src/server/management/aside-profile-routes.ts +266 -7
  272. package/src/server/management/companion-routes.ts +77 -0
  273. package/src/server/management/config-routes.ts +18 -2
  274. package/src/server/management/context.ts +3 -0
  275. package/src/server/management/integration-routes.ts +287 -5
  276. package/src/server/management/logs-usage-routes.ts +19 -0
  277. package/src/server/management/metrics-routes.ts +20 -0
  278. package/src/server/management/model-rows.ts +224 -12
  279. package/src/server/management/native-integration-routes.ts +103 -6
  280. package/src/server/management/oauth-account-routes.ts +45 -7
  281. package/src/server/management/route-registry.ts +20 -0
  282. package/src/server/management/shared.ts +28 -4
  283. package/src/server/management/system-restart.ts +7 -2
  284. package/src/server/management/system-routes.ts +2 -0
  285. package/src/server/management/usage-aggregate-cache.ts +4 -0
  286. package/src/server/management/usage-timeline-routes.ts +44 -0
  287. package/src/server/management-api.ts +10 -9
  288. package/src/server/management-auth.ts +15 -1
  289. package/src/server/proxy-liveness.ts +75 -0
  290. package/src/server/readiness.ts +29 -10
  291. package/src/server/relay-eager.ts +24 -2
  292. package/src/server/relay.ts +138 -12
  293. package/src/server/request-log-failure-attribution.ts +99 -0
  294. package/src/server/request-log.ts +169 -2
  295. package/src/server/request-metrics.ts +298 -0
  296. package/src/server/responses/adapter-continuation.ts +3 -3
  297. package/src/server/responses/adapter-dispatch.ts +11 -6
  298. package/src/server/responses/codex-ws-wire.ts +34 -8
  299. package/src/server/responses/combo-stream-preflight.ts +168 -6
  300. package/src/server/responses/compact.ts +43 -10
  301. package/src/server/responses/compaction-routing.ts +111 -0
  302. package/src/server/responses/core-codex-account.ts +8 -3
  303. package/src/server/responses/core-combo.ts +7 -7
  304. package/src/server/responses/core-normalize.ts +6 -12
  305. package/src/server/responses/core-opaque-recovery.ts +91 -0
  306. package/src/server/responses/core-options.ts +4 -0
  307. package/src/server/responses/encrypted-payload.ts +20 -2
  308. package/src/server/responses/fetch-helpers.ts +124 -8
  309. package/src/server/responses/input-admission.ts +10 -0
  310. package/src/server/responses/passthrough-delivery.ts +45 -15
  311. package/src/server/responses/passthrough-dispatch.ts +215 -44
  312. package/src/server/responses/passthrough-error.ts +27 -8
  313. package/src/server/responses/policy-fallback.ts +5 -13
  314. package/src/server/responses/request-prepare.ts +109 -18
  315. package/src/server/responses/request-send-budget.ts +17 -1
  316. package/src/server/responses/request-sidecar-auth.ts +1 -1
  317. package/src/server/responses/request-transport.ts +26 -6
  318. package/src/server/responses/reset-replay.ts +108 -0
  319. package/src/server/responses/run-turn-execution.ts +25 -3
  320. package/src/server/responses/sidecar-execution.ts +17 -2
  321. package/src/server/responses/ws-upstream.ts +14 -27
  322. package/src/server/responses-custom-tool-repair.ts +27 -54
  323. package/src/server/responses-request-tool-scope.ts +214 -0
  324. package/src/server/responses-undeclared-tool-guard.ts +35 -2
  325. package/src/server/search.ts +25 -1
  326. package/src/server/sse-payload-rewrite.ts +1 -1
  327. package/src/server/usage-ledger-retention.ts +73 -0
  328. package/src/service/cli.ts +48 -2
  329. package/src/service/health.ts +3 -2
  330. package/src/service/install-state-contract.d.mts +27 -0
  331. package/src/service/install-state-contract.mjs +34 -0
  332. package/src/service/launchd.ts +1 -1
  333. package/src/service/orchestration.ts +2 -4
  334. package/src/service/ownership-compatibility.ts +164 -0
  335. package/src/service/ownership-mutation-lease.d.mts +32 -0
  336. package/src/service/ownership-mutation-lease.mjs +211 -0
  337. package/src/service/repair.ts +45 -1
  338. package/src/service/state-lock.ts +269 -0
  339. package/src/service/state-record.d.mts +36 -0
  340. package/src/service/state-record.mjs +138 -0
  341. package/src/service/state.ts +582 -68
  342. package/src/service/windows-taskxml.ts +11 -10
  343. package/src/service.ts +7 -3
  344. package/src/tray/windows-tray.ps1 +156 -4
  345. package/src/types/config.ts +48 -3
  346. package/src/types/provider.ts +91 -0
  347. package/src/types/request.ts +30 -2
  348. package/src/types/tools.ts +33 -0
  349. package/src/types.ts +4 -0
  350. package/src/update/index.ts +207 -63
  351. package/src/update/job.ts +9 -5
  352. package/src/update/ownership-transaction.ts +47 -0
  353. package/src/update/restart-ownership.ts +54 -0
  354. package/src/update/runtime-ownership.d.mts +40 -0
  355. package/src/update/runtime-ownership.mjs +122 -0
  356. package/src/usage/attempt-delivery.ts +198 -0
  357. package/src/usage/cache-diagnostic.ts +305 -0
  358. package/src/usage/failure-fingerprint.ts +118 -0
  359. package/src/usage/failure-projection-cache.ts +174 -0
  360. package/src/usage/failure-projection.ts +174 -0
  361. package/src/usage/ledger-retention.ts +165 -0
  362. package/src/usage/log.ts +126 -79
  363. package/src/usage/request-outcome.ts +150 -0
  364. package/src/usage/retention-contract.ts +28 -0
  365. package/src/usage/summary.ts +2 -2
  366. package/src/usage/telemetry-contract.ts +237 -0
  367. package/src/usage/timeline.ts +236 -0
  368. package/src/vision/eligibility.ts +88 -9
  369. package/src/vision/plan.ts +34 -10
  370. package/src/web-search/alpha-search.ts +21 -1
  371. package/src/web-search/executor.ts +41 -2
  372. package/src/web-search/loop.ts +6 -1
  373. package/gui/dist/assets/index-C5IebErG.js +0 -136
  374. package/gui/dist/assets/index-OESInAjC.css +0 -1
@@ -0,0 +1,921 @@
1
+ /**
2
+ * Value-free planning for integration mutations.
3
+ *
4
+ * An operator confirming "apply", "overwrite", "disable" or "undo" is agreeing to consequences
5
+ * nobody has shown them. This module computes those consequences as bounded managed schema paths
6
+ * and closed change kinds, and binds them to a fingerprint over every input the decision rested
7
+ * on, so a confirmation can be refused when the state it described has moved.
8
+ *
9
+ * Two rules give the output its safety. Nothing here is a value: paths are structural, and a
10
+ * segment that is not representable in the managed grammar is dropped rather than echoed, because
11
+ * an ownership record on disk accepts arbitrary strings and is not a validation authority. And
12
+ * nothing here writes: this module owns no IO, takes no lock, and must never import the writer.
13
+ * The dependency direction is state/ownership/merge into here, and here into the writer and the
14
+ * preview route.
15
+ */
16
+ import { createHash } from "node:crypto";
17
+ import { canonicalContribution, fingerprint, type OwnershipRecord } from "./ownership";
18
+ import { ClientPathError, EXPORT_CLIENTS, type ExportModel, type ManagedContribution } from "../clients/config-export";
19
+ import { OPENCODE_PROVIDER_ID } from "../clients/config-export/constants";
20
+ import {
21
+ ZCODE_STORE_MODEL_RULES_PATH,
22
+ ZCODE_STORE_PROVIDER_RULES_PATH,
23
+ } from "../clients/config-export/zcode-store";
24
+ import { createClineIO, ClineTransactionError } from "./cline-io";
25
+ import { parseClineDocument } from "./cline-document";
26
+ import { PARSE_FAILED, defaultIntegrationIO, loadTarget, parseConfig, type IntegrationIO } from "./config-io";
27
+ import {
28
+ INTEGRATION_CLIENTS,
29
+ isLoopbackOnly,
30
+ resolveIntegrationPaths,
31
+ type IntegrationClientId,
32
+ } from "./registry";
33
+ import { declaredIntegrationTarget, resolveIntegrationTarget, type IntegrationTarget } from "./target";
34
+ import { shouldInjectApiAuthHeader } from "../codex/inject";
35
+ import { classifyIntegration, exportContextOf, readPath, type IntegrationState, type StateReason } from "./state";
36
+ import { InvalidSelectorError } from "./merge";
37
+ import { createIntegrationStateStore, type IntegrationStateStore } from "./store";
38
+ import type { OcxConfig } from "../types";
39
+ import { matchesOperationResult, type JournalEntry } from "./journal";
40
+
41
+ /**
42
+ * Why a mutation refused. Declared here rather than in the writer so the planner can report a
43
+ * refusal without depending on the module that performs writes; the writer re-exports it, so this
44
+ * is a move rather than a second vocabulary.
45
+ */
46
+ export type RefusalReason =
47
+ | "not_installed"
48
+ | "conflict"
49
+ | "unsafe"
50
+ | "non_loopback"
51
+ | "superseded_store"
52
+ | "drift_requires_confirm"
53
+ | "snapshot_expired"
54
+ | "write_failed";
55
+
56
+ export type IntegrationPlanOperation = "apply" | "overwrite" | "disable" | "restore";
57
+ export type IntegrationPlanChangeKind = "add" | "replace" | "remove" | "snapshot" | "ownership" | "journal";
58
+ export type IntegrationPlanForeignEdit = "none" | "unowned" | "foreign-edit" | "drift";
59
+
60
+ /**
61
+ * Effects that are not places in the client's document. They carry no disk location and no value,
62
+ * so an operator learns that history will be written without learning where it lives.
63
+ */
64
+ export const PLAN_SNAPSHOT_PATH = "$snapshot";
65
+ export const PLAN_OWNERSHIP_PATH = "$ownership";
66
+ export const PLAN_JOURNAL_PATH = "$journal";
67
+
68
+ /**
69
+ * Upper bound on reported changes. Above the largest contribution any registered client builds and
70
+ * well below a response worth truncating, so the cap is a guard rather than a routine limit.
71
+ */
72
+ export const PLAN_CHANGE_LIMIT = 256;
73
+
74
+ export interface IntegrationPlanChange {
75
+ readonly kind: IntegrationPlanChangeKind;
76
+ readonly path: string;
77
+ }
78
+
79
+ export interface IntegrationMutationPlan {
80
+ readonly version: 1;
81
+ readonly clientId: IntegrationClientId;
82
+ readonly operation: IntegrationPlanOperation;
83
+ readonly state: IntegrationState;
84
+ readonly foreignEdit: IntegrationPlanForeignEdit;
85
+ readonly changes: readonly IntegrationPlanChange[];
86
+ readonly fingerprint: string;
87
+ readonly canApply: boolean;
88
+ /**
89
+ * Whether confirming would write at all.
90
+ *
91
+ * An apply against an already-current file and a disable against an absent one both succeed
92
+ * while writing nothing, so reporting a snapshot and a journal row for them would describe
93
+ * consequences that never happen.
94
+ */
95
+ readonly willChange: boolean;
96
+ readonly refusalReason?: RefusalReason;
97
+ readonly profileId?: number;
98
+ }
99
+
100
+ /** A position whose observed value is never published, only its presence. */
101
+ export const DYNAMIC_SEGMENT = "*";
102
+
103
+ /**
104
+ * Where each client's managed fragments live, declared rather than inferred.
105
+ *
106
+ * A general "looks like a plain key" rule is not good enough, and Kimi is the proof: it writes one
107
+ * fragment per model at `models.<alias>`, so an alphanumeric allowlist would publish a user's
108
+ * model identifier verbatim. The same rule would accept any plain path sitting in an ownership
109
+ * record, and a record on disk is not a validation authority.
110
+ *
111
+ * So a path is published only when it matches one of these templates exactly. Static segments must
112
+ * match literally, a DYNAMIC_SEGMENT position accepts any observed segment, and the string that
113
+ * leaves this module is the TEMPLATE rather than the observed path. That is what makes publishing
114
+ * a value structurally impossible instead of merely unlikely.
115
+ *
116
+ * The satisfies clause makes a new client a type error here, so nobody can add one whose managed
117
+ * paths silently have no declaration.
118
+ */
119
+ const CLIENT_MANAGED_PATHS = {
120
+ opencode: [["provider", OPENCODE_PROVIDER_ID], ["providers", OPENCODE_PROVIDER_ID]],
121
+ pi: [["providers", OPENCODE_PROVIDER_ID]],
122
+ omp: [["providers", OPENCODE_PROVIDER_ID]],
123
+ hermes: [["providers", OPENCODE_PROVIDER_ID]],
124
+ openclaw: [["models", "providers", OPENCODE_PROVIDER_ID]],
125
+ kimi: [["providers", OPENCODE_PROVIDER_ID], ["models", DYNAMIC_SEGMENT]],
126
+ gajae: [["providers", OPENCODE_PROVIDER_ID]],
127
+ dsh: [["llm-pi-ai", "providers", OPENCODE_PROVIDER_ID]],
128
+ mcode: [["custom_provider", OPENCODE_PROVIDER_ID]],
129
+ zcode: [
130
+ ["provider", OPENCODE_PROVIDER_ID],
131
+ /*
132
+ * The store the current client reads. The model rule carries the model id in
133
+ * its own selector, so the published segment is the dynamic one: the template
134
+ * leaves this module, never the observed path.
135
+ */
136
+ [...ZCODE_STORE_PROVIDER_RULES_PATH, `[providerId=${OPENCODE_PROVIDER_ID}]`],
137
+ [...ZCODE_STORE_MODEL_RULES_PATH, DYNAMIC_SEGMENT],
138
+ ],
139
+ prime: [["providers", OPENCODE_PROVIDER_ID]],
140
+ aside: [["providers", OPENCODE_PROVIDER_ID]],
141
+ raycast: [["providers", `[id=${OPENCODE_PROVIDER_ID}]`]],
142
+ omo: [["providers", OPENCODE_PROVIDER_ID]],
143
+ cline: [
144
+ ["settings", "providers", OPENCODE_PROVIDER_ID],
145
+ ["catalog", "providers", OPENCODE_PROVIDER_ID],
146
+ ],
147
+ } satisfies Record<IntegrationClientId, readonly (readonly string[])[]>;
148
+
149
+ /** Not a configuration surface. Exported so a parity case can compare it against the shipped clients. */
150
+ export const MANAGED_PATH_TEMPLATES: Readonly<Record<IntegrationClientId, readonly (readonly string[])[]>> = CLIENT_MANAGED_PATHS;
151
+
152
+ function matchesTemplate(template: readonly string[], path: readonly string[]): boolean {
153
+ if (template.length !== path.length) return false;
154
+ return template.every((segment, index) => {
155
+ const observed = path[index];
156
+ if (observed === undefined || observed.length === 0) return false;
157
+ return segment === DYNAMIC_SEGMENT || segment === observed;
158
+ });
159
+ }
160
+
161
+ /**
162
+ * The managed schema path this change touches, or null when the path is outside the client's
163
+ * declared grammar.
164
+ *
165
+ * Null is not an error to work around. A path nobody declared is either a record written by a
166
+ * different version or something arbitrary, and neither is safe to name, so the caller reports the
167
+ * fixed ownership pseudo-path or a refusal instead of inventing a description.
168
+ */
169
+ export function canonicalSchemaPath(clientId: IntegrationClientId, path: readonly string[]): string | null {
170
+ if (path.length === 0) return null;
171
+ for (const template of CLIENT_MANAGED_PATHS[clientId]) {
172
+ if (matchesTemplate(template, path)) return template.join(".");
173
+ }
174
+ return null;
175
+ }
176
+
177
+ const KIND_ORDER: readonly IntegrationPlanChangeKind[] = ["add", "replace", "remove", "snapshot", "ownership", "journal"];
178
+
179
+ /**
180
+ * Deterministic, deduplicated and capped. Deterministic because the fingerprint is taken over this
181
+ * projection, so an unstable order would stale a plan that did not change.
182
+ */
183
+ export function orderPlanChanges(changes: readonly IntegrationPlanChange[]): readonly IntegrationPlanChange[] {
184
+ const seen = new Set<string>();
185
+ const unique: IntegrationPlanChange[] = [];
186
+ for (const change of changes) {
187
+ const key = `${change.kind}\u0000${change.path}`;
188
+ if (seen.has(key)) continue;
189
+ seen.add(key);
190
+ unique.push(change);
191
+ }
192
+ unique.sort((left, right) => {
193
+ const byKind = KIND_ORDER.indexOf(left.kind) - KIND_ORDER.indexOf(right.kind);
194
+ if (byKind !== 0) return byKind;
195
+ return left.path < right.path ? -1 : left.path > right.path ? 1 : 0;
196
+ });
197
+ return Object.freeze(unique.slice(0, PLAN_CHANGE_LIMIT));
198
+ }
199
+
200
+ /**
201
+ * Every input the plan's authority rests on.
202
+ *
203
+ * `models` is here because the desired contribution is derived from it, so a model roster that
204
+ * changed between preview and commit changes what would be written. `snapshot` carries a digest of
205
+ * the bytes a restore would actually publish rather than only the operation id, because the id
206
+ * names the row and the bytes are what lands in the user's file.
207
+ */
208
+ export interface PlanFingerprintInput {
209
+ readonly operation: IntegrationPlanOperation;
210
+ readonly clientId: IntegrationClientId;
211
+ readonly profileId?: number;
212
+ readonly configPath: string;
213
+ readonly detectDir: string;
214
+ /**
215
+ * What the detect directory actually was when observed, not merely where it is.
216
+ *
217
+ * Binding only the path leaves a confirmation valid across an uninstall: the contribution is
218
+ * unchanged, so every other component matches, while the answer to "is this client installed"
219
+ * has flipped. The observed kind is the input the not_installed refusal is derived from.
220
+ */
221
+ readonly installKind: string;
222
+ /**
223
+ * Whether admission policy blocks this integration, which is the non_loopback refusal's input.
224
+ *
225
+ * Config eligibility can change without touching the file, the record or the contribution, so a
226
+ * plan that did not bind it could be confirmed after the proxy stopped being a legal target.
227
+ */
228
+ readonly admissionBlocked: boolean;
229
+ /**
230
+ * Why a write to the target would not reach the client, or null.
231
+ *
232
+ * Bound for the same reason `installKind` is: it can flip without touching the
233
+ * file, the record or the contribution. A client that creates its new store
234
+ * while a confirmation is outstanding has changed whether the write can reach
235
+ * it, and a plan that did not bind this would still authorize the write. The
236
+ * reason travels with the location because both can move on their own: a store
237
+ * whose schema version changes under an unchanged path is the same class of
238
+ * flip as a store appearing.
239
+ */
240
+ readonly ineffectiveWrite: string | null;
241
+ /** Exact current bytes, or null when the target is missing. Missing and empty are not equal. */
242
+ readonly before: string | null;
243
+ readonly contribution: ManagedContribution | null;
244
+ readonly record: OwnershipRecord | null;
245
+ readonly models: readonly ExportModel[];
246
+ readonly restore?: {
247
+ readonly opId: string;
248
+ readonly entry: JournalEntry;
249
+ readonly snapshotKind: string;
250
+ /** Digest of the snapshot's exact text, or null when it holds none. */
251
+ readonly snapshotText: string | null;
252
+ readonly confirmDrift: boolean;
253
+ /**
254
+ * Whether the target has changed since the operation being undone, which is the
255
+ * drift_requires_confirm predicate. Passed in rather than recomputed so the plan and the
256
+ * mutation read drift from the same comparison.
257
+ */
258
+ readonly driftsFromResult: boolean;
259
+ };
260
+ }
261
+
262
+ const PLAN_FINGERPRINT_VERSION = "p2";
263
+
264
+ function digest(value: string): string {
265
+ return createHash("sha256").update(value).digest("hex").slice(0, 32);
266
+ }
267
+
268
+ /**
269
+ * An opaque optimistic-concurrency token, not authorization.
270
+ *
271
+ * Longer than the 16-hex ownership fingerprint because this one is supplied by a caller and
272
+ * compared for equality, so accidental collision matters more than it does for a stored digest.
273
+ * The version prefix means a future input set invalidates old tokens instead of silently
274
+ * comparing two different meanings.
275
+ */
276
+ export function planFingerprint(input: PlanFingerprintInput): string {
277
+ const restore = input.restore;
278
+ const components = [
279
+ PLAN_FINGERPRINT_VERSION,
280
+ input.operation,
281
+ input.clientId,
282
+ input.profileId === undefined ? null : input.profileId,
283
+ input.configPath,
284
+ input.detectDir,
285
+ input.installKind,
286
+ input.admissionBlocked,
287
+ input.ineffectiveWrite,
288
+ input.before === null ? "\u0000absent" : fingerprint(input.before),
289
+ input.contribution === null ? null : fingerprint(canonicalContribution(input.contribution)),
290
+ input.record === null ? null : fingerprint(JSON.stringify(input.record)),
291
+ fingerprint(JSON.stringify(input.models)),
292
+ restore === undefined ? null : [
293
+ restore.opId,
294
+ fingerprint(JSON.stringify(restore.entry)),
295
+ restore.snapshotKind,
296
+ restore.snapshotText === null ? "\u0000none" : fingerprint(restore.snapshotText),
297
+ restore.confirmDrift,
298
+ restore.driftsFromResult,
299
+ ],
300
+ ];
301
+ return `${PLAN_FINGERPRINT_VERSION}:${digest(JSON.stringify(components))}`;
302
+ }
303
+
304
+ /** The observation facts a plan is derived from, beside the fingerprint inputs. */
305
+ export interface PlanInput extends PlanFingerprintInput {
306
+ readonly classified: { readonly state: IntegrationState; readonly reason?: StateReason };
307
+ /**
308
+ * The parsed target document. Whether a managed place is occupied is a fact about the file, not
309
+ * about our record: an overwrite of a key somebody else wrote replaces a value even though no
310
+ * record of ours mentions it.
311
+ */
312
+ readonly parsed: unknown;
313
+ }
314
+
315
+ type PlanOutcome =
316
+ | { readonly kind: "refuse"; readonly reason: RefusalReason }
317
+ | { readonly kind: "noop" }
318
+ | { readonly kind: "change" };
319
+
320
+ const CHANGE: PlanOutcome = { kind: "change" };
321
+ const NOOP: PlanOutcome = { kind: "noop" };
322
+ const deny = (reason: RefusalReason): PlanOutcome => ({ kind: "refuse", reason });
323
+
324
+ function foreignEditOf(input: PlanInput): IntegrationPlanForeignEdit {
325
+ if (input.restore?.driftsFromResult) return "drift";
326
+ if (input.classified.reason === "unowned-key") return "unowned";
327
+ if (input.classified.reason === "foreign-edit") return "foreign-edit";
328
+ return "none";
329
+ }
330
+
331
+ /**
332
+ * Why this operation would refuse, in the writer's own order.
333
+ *
334
+ * The order is not cosmetic. An uninstalled client is reported as not installed rather than as
335
+ * whatever its leftover file happens to classify as, and an unreadable or unparseable file is
336
+ * reported before either, because that is the sequence the writer itself refuses in. A plan that
337
+ * named a different reason than the mutation would name is worse than no plan.
338
+ */
339
+ function applyOutcome(input: PlanInput): PlanOutcome {
340
+ if (input.installKind !== "dir") return deny("not_installed");
341
+ if (input.admissionBlocked) return deny("non_loopback");
342
+ /*
343
+ * Before any file state. The document may be perfectly writable and our block
344
+ * may already be current in it; neither says anything about whether the
345
+ * client reads it, and reporting a change to a file nobody opens is the
346
+ * defect this refusal exists for.
347
+ */
348
+ if (input.ineffectiveWrite !== null) return deny("superseded_store");
349
+ // Overwrite exists precisely to proceed through a conflict the operator has been shown.
350
+ if (input.classified.state === "conflict" && input.operation !== "overwrite") return deny("conflict");
351
+ if (input.classified.state === "unsafe") return deny("unsafe");
352
+ if (input.classified.state === "current") return NOOP;
353
+ return CHANGE;
354
+ }
355
+
356
+ /**
357
+ * Disable answers a different question, so it asks different ones.
358
+ *
359
+ * It never checks installation or admission: removing what we wrote from a file that still exists
360
+ * is meaningful whether or not the client is installed now, and it emits nothing that admission
361
+ * policy could object to. An absent block is a success that writes nothing rather than a refusal.
362
+ */
363
+ function disableOutcome(input: PlanInput): PlanOutcome {
364
+ if (input.classified.state === "absent") return NOOP;
365
+ if (input.classified.state === "conflict") return deny("conflict");
366
+ if (input.classified.state === "unsafe") return deny("unsafe");
367
+ return CHANGE;
368
+ }
369
+
370
+ function restoreOutcome(input: PlanInput): PlanOutcome {
371
+ if (input.restore === undefined) return deny("unsafe");
372
+ if (input.restore.snapshotKind === "expired") return deny("snapshot_expired");
373
+ if (input.restore.driftsFromResult && !input.restore.confirmDrift) return deny("drift_requires_confirm");
374
+ return CHANGE;
375
+ }
376
+
377
+ /**
378
+ * What this operation would do, decided the way the operation itself decides it.
379
+ *
380
+ * Applying one global sequence to all four was wrong: it reported disable as refused on an
381
+ * uninstalled client the writer would have accepted, and it ranked the classifier's unsafe ahead
382
+ * of a conflict that apply reports first. A plan is only useful if it reaches the same verdict,
383
+ * for the same reason, as the mutation it describes.
384
+ */
385
+ function outcomeOf(input: PlanInput): PlanOutcome {
386
+ if (input.operation === "restore") return restoreOutcome(input);
387
+ if (input.operation === "disable") return disableOutcome(input);
388
+ return applyOutcome(input);
389
+ }
390
+
391
+
392
+ /**
393
+ * The managed places this operation would touch, plus the history it would write.
394
+ *
395
+ * A path that does not canonicalize is omitted rather than guessed at. For a shipped client that
396
+ * cannot happen, and the parity case proves it; what it does cover is a record written by another
397
+ * version, where declining to describe a path is the honest answer and the fixed ownership entry
398
+ * still tells the operator that ownership changes.
399
+ */
400
+ function changesOf(input: PlanInput): readonly IntegrationPlanChange[] {
401
+ const changes: IntegrationPlanChange[] = [];
402
+ if (input.operation === "apply" || input.operation === "overwrite") {
403
+ for (const fragment of input.contribution?.fragments ?? []) {
404
+ const path = canonicalSchemaPath(input.clientId, fragment.path);
405
+ if (path === null) continue;
406
+ // Occupied is a fact about the document. Deciding from our own record instead would call an
407
+ // overwrite of somebody else's key an addition, which is the one case overwrite exists for.
408
+ const occupied = readPath(input.parsed, fragment.path) !== undefined;
409
+ changes.push({ kind: occupied ? "replace" : "add", path });
410
+ }
411
+ }
412
+ if (input.operation === "disable") {
413
+ for (const owned of input.record?.fragmentPaths ?? []) {
414
+ const path = canonicalSchemaPath(input.clientId, owned);
415
+ if (path === null) continue;
416
+ changes.push({ kind: "remove", path });
417
+ }
418
+ }
419
+ if (input.operation === "restore") {
420
+ /*
421
+ * An undo replaces the whole document, so what it changes is the difference between the
422
+ * places that are ours now and the places the row recorded as ours before that operation ran.
423
+ *
424
+ * Reading only the prior record got both common undos wrong. Undoing an initial apply has no
425
+ * prior record, so the plan described a change to nothing at all while the undo removed the
426
+ * managed block. Undoing a disable has a prior record and an empty document, so the plan said
427
+ * it would replace paths the file does not currently have.
428
+ *
429
+ * Provenance is still restored rather than re-derived: the prior record is what says which
430
+ * places are ours afterwards. The document only answers whether each of them is there now.
431
+ */
432
+ const prior = new Map<string, readonly string[]>();
433
+ for (const fragment of input.restore?.entry.priorRecord?.fragmentPaths ?? []) {
434
+ const path = canonicalSchemaPath(input.clientId, fragment);
435
+ if (path !== null) prior.set(path, fragment);
436
+ }
437
+ /*
438
+ * A document we could not read says nothing about whether a place is there, and restore does
439
+ * not need it read: eligibility is a question about bytes. Where it cannot be read, each place
440
+ * is reported as a replacement, which is what this list said before any of it was derived.
441
+ */
442
+ const documentKnown = input.parsed !== PARSE_FAILED;
443
+ for (const [path, fragment] of prior) {
444
+ let absent = false;
445
+ if (documentKnown) {
446
+ try {
447
+ absent = readPath(input.parsed, fragment) === undefined;
448
+ } catch (error) {
449
+ /*
450
+ * Restore eligibility is byte-based and does not parse ownership paths.
451
+ * A malformed versioned selector therefore makes this ONE descriptive
452
+ * comparison unknown; it must not make preview execute a different
453
+ * selector, and it must not hide the backup behind an internal error.
454
+ */
455
+ if (!(error instanceof InvalidSelectorError)) throw error;
456
+ }
457
+ }
458
+ changes.push({ kind: absent ? "add" : "replace", path });
459
+ }
460
+ for (const fragment of input.record?.fragmentPaths ?? []) {
461
+ const path = canonicalSchemaPath(input.clientId, fragment);
462
+ if (path === null || prior.has(path)) continue;
463
+ changes.push({ kind: "remove", path });
464
+ }
465
+ }
466
+ changes.push({ kind: "snapshot", path: PLAN_SNAPSHOT_PATH });
467
+ changes.push({ kind: "ownership", path: PLAN_OWNERSHIP_PATH });
468
+ changes.push({ kind: "journal", path: PLAN_JOURNAL_PATH });
469
+ return orderPlanChanges(changes);
470
+ }
471
+
472
+ /**
473
+ * The whole plan, value-free.
474
+ *
475
+ * A refused plan is still worth returning: knowing that undo is blocked because the backup expired
476
+ * is the answer an operator needs, and it carries no more detail than an allowed one.
477
+ */
478
+ export function buildMutationPlan(input: PlanInput): IntegrationMutationPlan {
479
+ const outcome = outcomeOf(input);
480
+ return Object.freeze({
481
+ version: 1 as const,
482
+ clientId: input.clientId,
483
+ operation: input.operation,
484
+ state: input.classified.state,
485
+ foreignEdit: foreignEditOf(input),
486
+ // Only a plan that would actually write describes places to write.
487
+ changes: outcome.kind === "change" ? changesOf(input) : Object.freeze([]),
488
+ fingerprint: planFingerprint(input),
489
+ canApply: outcome.kind !== "refuse",
490
+ willChange: outcome.kind === "change",
491
+ ...(outcome.kind === "refuse" ? { refusalReason: outcome.reason } : {}),
492
+ ...(input.profileId === undefined ? {} : { profileId: input.profileId }),
493
+ });
494
+ }
495
+
496
+ /**
497
+ * The token a plan carries when observation itself refused.
498
+ *
499
+ * Such a plan never read the state it would have bound, so there is nothing to bind. It is safe
500
+ * for every one of them to share this value because `canApply` is false, and a mutation may only
501
+ * be bound to a plan that could apply.
502
+ */
503
+ export const PLAN_UNBOUND_FINGERPRINT = `${PLAN_FINGERPRINT_VERSION}:unbound`;
504
+
505
+ function unboundPlan(
506
+ clientId: IntegrationClientId,
507
+ operation: IntegrationPlanOperation,
508
+ failure: IntegrationObservationFailure,
509
+ profileId?: number,
510
+ ): IntegrationMutationPlan {
511
+ return Object.freeze({
512
+ version: 1 as const,
513
+ clientId,
514
+ operation,
515
+ state: failure.state,
516
+ foreignEdit: "none" as const,
517
+ changes: Object.freeze([]),
518
+ fingerprint: PLAN_UNBOUND_FINGERPRINT,
519
+ canApply: false,
520
+ willChange: false,
521
+ refusalReason: failure.reason,
522
+ ...(profileId === undefined ? {} : { profileId }),
523
+ });
524
+ }
525
+
526
+ /**
527
+ * Restore reads a different specification, so it gets its own observation.
528
+ *
529
+ * The writer's undo path never parses and never classifies: it compares the resolved config path
530
+ * against the one the journal row was recorded for, reads the snapshot, and reads the target's
531
+ * BYTES. Routing a preview through the general observation therefore refused an undo of a file
532
+ * that was readable but unparseable, which is the state that most needs restoring, and accepted a
533
+ * row recorded against a previous home, which is the single case path equality exists to refuse.
534
+ */
535
+ export function observeRestore(
536
+ input: IntegrationWriteInput,
537
+ opId: string,
538
+ effects: ObservationEffects,
539
+ /**
540
+ * The row a caller already selected, with the store it came from.
541
+ *
542
+ * Aside can hold more than one valid copy of the same operation, so re-resolving here could
543
+ * legitimately pick a different row than the mutation will. The plan would then describe an
544
+ * operation the confirmation was never about. A caller that has resolved one passes it in, and
545
+ * neither side resolves again.
546
+ */
547
+ selectedOperation?: { entry: JournalEntry; store: IntegrationStateStore },
548
+ ) {
549
+ const store = selectedOperation?.store ?? input.store ?? createIntegrationStateStore();
550
+ let io = input.io ?? defaultIntegrationIO(store);
551
+ const clientId = input.clientId;
552
+ let resolved: { configPath: string; detectDir: string };
553
+ try {
554
+ resolved = input.resolvedPaths ?? resolveIntegrationPaths(clientId, input.env, input.home);
555
+ } catch (error) {
556
+ if (!(error instanceof ClientPathError)) throw error;
557
+ return { failed: observationFailure("unsafe", "unsafe", error.message) } as const;
558
+ }
559
+ // Coordinated restore refuses this before it looks at the row at all: an undo will not create
560
+ // the client's home, so a writer-lock client without one has nothing to restore into.
561
+ if (INTEGRATION_CLIENTS[clientId].writerLock && io.statKind(resolved.detectDir) !== "dir") {
562
+ return {
563
+ failed: observationFailure("unsafe", "unsafe", "the client home is missing; restore will not create it"),
564
+ } as const;
565
+ }
566
+ const entry = selectedOperation?.entry ?? store.findOperation(opId);
567
+ if (!entry || entry.clientId !== clientId) {
568
+ return { failed: observationFailure("unsafe", "unsafe", "that operation cannot be undone") } as const;
569
+ }
570
+ const configPath = entry.configPath;
571
+ /*
572
+ * An undo acts on the path the operation was journaled against. A row recorded for one home must
573
+ * never be allowed to rewrite a file in another — but a client may legally have written more than
574
+ * one file, so the test is whether this client still names that location, not whether it is the
575
+ * config file. The answer also carries the document shape those bytes are in.
576
+ */
577
+ const rowTarget = declaredIntegrationTarget({
578
+ clientId, configPath, resolvedConfigPath: resolved.configPath, env: input.env, home: input.home,
579
+ });
580
+ if (rowTarget === null) {
581
+ return {
582
+ failed: observationFailure("conflict", "conflict", "that operation was recorded for a different location"),
583
+ } as const;
584
+ }
585
+ if (clientId === "cline") {
586
+ try { io = createClineIO(io, configPath, store, effects.recover); }
587
+ catch (error) {
588
+ if (!(error instanceof ClineTransactionError)) throw error;
589
+ return { failed: { ...observationFailure("unsafe", "unsafe", error.message, error.snapshotPath), residual: true } } as const;
590
+ }
591
+ }
592
+ const snapshot = store.readSnapshot(entry);
593
+ /*
594
+ * Expiry is decided before the target is read, exactly as the writer decides it. Reading first
595
+ * let an expired backup over an unreadable file report the file as the problem, when the answer
596
+ * the operator needs is that the backup is gone.
597
+ */
598
+ if (snapshot.kind === "expired") {
599
+ return { failed: observationFailure("snapshot_expired", "absent", "that backup has expired") } as const;
600
+ }
601
+ const target = loadTarget(io, configPath);
602
+ if (!target.ok) {
603
+ return { failed: observationFailure("unsafe", "unsafe", "the target cannot be read safely") } as const;
604
+ }
605
+ const before = target.before;
606
+ return {
607
+ failed: undefined,
608
+ clientId,
609
+ configPath,
610
+ format: rowTarget.format,
611
+ detectDir: resolved.detectDir,
612
+ installKind: io.statKind(resolved.detectDir),
613
+ entry,
614
+ snapshotKind: snapshot.kind,
615
+ snapshotText: snapshot.kind === "stored" ? snapshot.text : null,
616
+ before,
617
+ // Bytes only. Parsing here is exactly what must not happen.
618
+ driftsFromResult: !matchesOperationResult(entry, before),
619
+ } as const;
620
+ }
621
+
622
+ export interface PreviewRequest {
623
+ readonly operation: IntegrationPlanOperation;
624
+ /** Required for restore; names the journalled operation being undone. */
625
+ readonly opId?: string;
626
+ readonly confirmDrift?: boolean;
627
+ readonly profileId?: number;
628
+ /** A row and store the caller already selected, so neither side resolves it twice. */
629
+ readonly resolved?: { entry: JournalEntry; store: IntegrationStateStore };
630
+ }
631
+
632
+ /**
633
+ * Plan an operation without performing it.
634
+ *
635
+ * Observation runs with both write-capable effects off, so this path prunes nothing, recovers
636
+ * nothing, takes no lock and enters no mutation flight. Everything it reads is a read: the target
637
+ * file, the ownership records, and for restore the journal row and its snapshot.
638
+ */
639
+ export function previewIntegration(input: IntegrationWriteInput, request: PreviewRequest): IntegrationMutationPlan {
640
+ // Restore never reaches the general observation, because the writer's undo path never parses
641
+ // or classifies and a preview that did would answer a different question.
642
+ if (request.operation === "restore") return previewRestore(input, request);
643
+ const observed = observeIntegration(input, { maintenance: false, recover: false });
644
+ if (observed.failed) return unboundPlan(input.clientId, request.operation, observed.failed, request.profileId);
645
+
646
+ const shared = {
647
+ operation: request.operation,
648
+ clientId: observed.clientId,
649
+ configPath: observed.configPath,
650
+ detectDir: observed.detectDir,
651
+ installKind: observed.io.statKind(observed.detectDir),
652
+ // Loopback-only clients cannot carry the admission header a non-loopback bind requires.
653
+ admissionBlocked: isLoopbackOnly(observed.clientId) && shouldInjectApiAuthHeader(input.config),
654
+ ineffectiveWrite: observed.ineffectiveWrite,
655
+ before: observed.before,
656
+ contribution: observed.contribution,
657
+ record: observed.record,
658
+ models: input.models,
659
+ classified: observed.classified,
660
+ parsed: observed.parsed,
661
+ ...(request.profileId === undefined ? {} : { profileId: request.profileId }),
662
+ };
663
+
664
+ return buildMutationPlan(shared);
665
+ }
666
+
667
+ /**
668
+ * Which places are ours in the file this undo would rewrite, read the same way the general
669
+ * observation reads them.
670
+ *
671
+ * Read from the store bound to the target being rewritten, never from the store a historical row
672
+ * was selected out of. Aside keeps a copy of an operation in the root store while the profile's
673
+ * own store holds the ownership for its file, so asking the selected row's store would have
674
+ * described the wrong file's ownership. The selected store stays what it is for: the row and its
675
+ * snapshot. A record written for another location grants nothing here either, which is the same
676
+ * rule the writer applies.
677
+ */
678
+ function currentRecordFor(
679
+ input: IntegrationWriteInput,
680
+ clientId: IntegrationClientId,
681
+ configPath: string,
682
+ ): OwnershipRecord | null {
683
+ const store = input.store ?? createIntegrationStateStore();
684
+ const stored = store.readRecords()[clientId] ?? null;
685
+ return stored && stored.clientId === clientId && stored.configPath === configPath ? stored : null;
686
+ }
687
+
688
+ /**
689
+ * Plan an undo the way the writer performs one.
690
+ *
691
+ * State is derived from bytes alone: a missing target is absent, a target that no longer matches
692
+ * the row's recorded result is a conflict, and anything else is current. Admission is not asked
693
+ * about, because restore emits nothing an admission policy could object to and the writer does not
694
+ * ask either.
695
+ */
696
+ function previewRestore(input: IntegrationWriteInput, request: PreviewRequest): IntegrationMutationPlan {
697
+ const refusal = (message: string): IntegrationMutationPlan =>
698
+ unboundPlan(input.clientId, "restore", { reason: "unsafe", state: "unsafe", message }, request.profileId);
699
+ if (request.opId === undefined) return refusal("that operation cannot be undone");
700
+
701
+ const observed = observeRestore(
702
+ input,
703
+ request.opId,
704
+ { maintenance: false, recover: false },
705
+ request.resolved,
706
+ );
707
+ if (observed.failed) return unboundPlan(input.clientId, "restore", observed.failed, request.profileId);
708
+
709
+ /*
710
+ * Drift decides first. A row that recorded a file and now finds none has drifted, and calling
711
+ * that absent would report a missing file as an ordinary undo while the writer refuses it
712
+ * pending confirmation. Absent is only honest when the recorded result was absence too.
713
+ */
714
+ const state: IntegrationState = observed.driftsFromResult
715
+ ? "conflict"
716
+ : observed.before === null ? "absent" : "current";
717
+
718
+ return buildMutationPlan({
719
+ operation: "restore",
720
+ clientId: observed.clientId,
721
+ configPath: observed.configPath,
722
+ detectDir: observed.detectDir,
723
+ installKind: observed.installKind,
724
+ admissionBlocked: false,
725
+ /*
726
+ * Undo puts back bytes this project already wrote to this file. Whether the
727
+ * client still reads the file does not change whether those bytes may be
728
+ * restored, and refusing here would strand a user on a state they asked to
729
+ * leave.
730
+ */
731
+ ineffectiveWrite: null,
732
+ before: observed.before,
733
+ contribution: null,
734
+ // Descriptive, never decisive. The record says which places are ours now and the document
735
+ // says which of them the file holds, so the change list can distinguish a place this undo
736
+ // adds back from one it replaces and one it takes away. Neither is allowed to refuse: an
737
+ // unreadable document is reported as PARSE_FAILED and leaves every place a replacement, and
738
+ // restore eligibility stays the byte comparison it was.
739
+ record: currentRecordFor(input, observed.clientId, observed.configPath),
740
+ models: input.models,
741
+ classified: { state },
742
+ parsed: observed.before === null
743
+ ? {}
744
+ : observed.clientId === "cline"
745
+ ? parseClineDocument(observed.before)
746
+ : parseConfig(observed.before, observed.format),
747
+ restore: {
748
+ opId: observed.entry.opId,
749
+ entry: observed.entry,
750
+ snapshotKind: observed.snapshotKind,
751
+ snapshotText: observed.snapshotText,
752
+ confirmDrift: request.confirmDrift === true,
753
+ driftsFromResult: observed.driftsFromResult,
754
+ },
755
+ ...(request.profileId === undefined ? {} : { profileId: request.profileId }),
756
+ });
757
+ }
758
+
759
+ /**
760
+ * What a mutation is asked to do. Declared here because the observation below consumes it and the
761
+ * planner must not depend on the writer; the writer re-exports it, so callers are unaffected.
762
+ */
763
+ export interface IntegrationWriteInput {
764
+ clientId: IntegrationClientId;
765
+ models: readonly ExportModel[];
766
+ config: OcxConfig;
767
+ port: number;
768
+ env?: NodeJS.ProcessEnv;
769
+ home?: string;
770
+ store?: IntegrationStateStore;
771
+ io?: IntegrationIO;
772
+ /** Frozen once by the async coordinator; synchronous callers may omit it. */
773
+ resolvedPaths?: { configPath: string; detectDir: string };
774
+ }
775
+
776
+ /** A refusal in the planner's own vocabulary, so observation does not depend on the writer's result type. */
777
+ export interface IntegrationObservationFailure {
778
+ readonly reason: RefusalReason;
779
+ readonly state: IntegrationState;
780
+ readonly message: string;
781
+ readonly snapshotPath?: string;
782
+ /** A Cline transaction left residue that only a mutation may clear. */
783
+ readonly residual?: boolean;
784
+ }
785
+
786
+ function observationFailure(
787
+ reason: RefusalReason,
788
+ state: IntegrationState,
789
+ message: string,
790
+ snapshotPath?: string,
791
+ ): IntegrationObservationFailure {
792
+ return { reason, state, message, ...(snapshotPath ? { snapshotPath } : {}) };
793
+ }
794
+
795
+ /**
796
+ * What preview and mutation are allowed to touch while looking.
797
+ *
798
+ * Both are false for a preview and both are true for a mutation, and neither defaults, because the
799
+ * difference is the whole safety argument. `maintenance` runs pending snapshot pruning, which
800
+ * writes; `recover` lets the Cline adapter repair a pending transaction, which also writes. A
801
+ * preview that quietly inherited either would be a mutation wearing a read's name.
802
+ */
803
+ export interface ObservationEffects {
804
+ readonly maintenance: boolean;
805
+ readonly recover: boolean;
806
+ }
807
+
808
+ /**
809
+ * Detect, gate, read, parse and classify, once, for both preview and mutation.
810
+ *
811
+ * Extracted from the writer so a plan and the mutation it authorizes rest on the same
812
+ * classification rather than two independent reads that can disagree. The ordering of refusals is
813
+ * load-bearing and is preserved exactly as the writer had it.
814
+ */
815
+ export function observeIntegration(input: IntegrationWriteInput, effects: ObservationEffects) {
816
+ const store = input.store ?? createIntegrationStateStore();
817
+ let io = input.io ?? defaultIntegrationIO(store);
818
+ const clientId = input.clientId;
819
+ const spec = INTEGRATION_CLIENTS[clientId];
820
+ const exportSpec = EXPORT_CLIENTS[clientId];
821
+ /*
822
+ * Resolution itself can refuse: a relative OPENCLAW_* selector is rejected
823
+ * because we cannot know the gateway's working directory. That is a refusal
824
+ * about the user's configuration, not an internal fault, so it must not
825
+ * escape as an exception — the collection route would answer 500 for the
826
+ * whole Integrations page because one client is misconfigured.
827
+ */
828
+ let configPath: string;
829
+ let detectDir: string;
830
+ let effective: IntegrationTarget;
831
+ let stored: OwnershipRecord | null;
832
+ try {
833
+ /*
834
+ * Resolve the PAIR, never one half.
835
+ *
836
+ * The coordinated path hands us a frozen pair, but applyIntegration,
837
+ * refreshIntegration and disableIntegration are public and may be called
838
+ * without one. Resolving configPath here and detectDir separately later let
839
+ * an Aside account switch land between the two, so a direct apply could
840
+ * verify account 1 was installed and then write account 0's catalog.
841
+ */
842
+ const resolved = input.resolvedPaths ?? resolveIntegrationPaths(clientId, input.env, input.home);
843
+ detectDir = resolved.detectDir;
844
+ if (clientId === "cline") io = createClineIO(io, resolved.configPath, store, effects.recover);
845
+ /*
846
+ * A record proves ownership of the file it was written FOR, and it is also
847
+ * one of the inputs the target is chosen from: a block we already wrote to
848
+ * the config file keeps this operation on that file, so disable removes what
849
+ * we wrote from where we wrote it. Matching by path happens after the
850
+ * target is known, because that is the path it has to match.
851
+ */
852
+ stored = store.readRecords()[clientId] ?? null;
853
+ /*
854
+ * Inside the same guard as resolution, because this resolver can refuse the
855
+ * same way: the store is named by a client env var, and a relative one is a
856
+ * misconfiguration to report rather than an exception to leak through the
857
+ * collection route.
858
+ */
859
+ effective = resolveIntegrationTarget({
860
+ clientId, configPath: resolved.configPath, io, record: stored, env: input.env, home: input.home,
861
+ });
862
+ configPath = effective.configPath;
863
+ } catch (error) {
864
+ if (error instanceof ClineTransactionError) {
865
+ return { failed: { ...observationFailure("unsafe", "unsafe", error.message, error.snapshotPath), residual: true } } as const;
866
+ }
867
+ if (!(error instanceof ClientPathError)) throw error;
868
+ return { failed: observationFailure("unsafe", "unsafe", error.message) } as const;
869
+ }
870
+ // Pruning writes, so only a mutation may perform it. Preview reports the state it finds.
871
+ if (effects.maintenance) store.retryPendingPrunes();
872
+
873
+ const loaded = loadTarget(io, configPath);
874
+ if (!loaded.ok) {
875
+ return {
876
+ failed: observationFailure("unsafe", "unsafe",
877
+ loaded.why === "read-failed"
878
+ ? `${configPath} exists but could not be read`
879
+ : `${configPath} is not a regular file`),
880
+ } as const;
881
+ }
882
+ const before = loaded.before;
883
+ const parsed = clientId === "cline" ? parseClineDocument(before) : parseConfig(before, effective.format);
884
+ if (parsed === PARSE_FAILED) {
885
+ return { failed: observationFailure("unsafe", "unsafe",
886
+ `${configPath} could not be parsed, or holds something opencodex cannot rewrite without changing it (a non-finite number, a large integer or a tiny one a rewrite would round, -0, a duplicate member, or nesting deeper than 1000 levels)`) } as const;
887
+ }
888
+ /*
889
+ * The shape the TARGET's reader understands, which is not always the
890
+ * client's config format: a client that moved its providers to another file
891
+ * reads a different document there, and a write in the config file's shape
892
+ * would be as unread as a write to the config file itself.
893
+ */
894
+ const contribution = effective.buildContribution(exportContextOf(input));
895
+ // A record proves ownership of the file it was written FOR. Matching only by
896
+ // client id let a record for one home authorize a write to another whose
897
+ // bytes happened to hash the same — which deleted a config we never touched.
898
+ const record = stored && stored.clientId === clientId && stored.configPath === configPath
899
+ ? stored
900
+ : null;
901
+ // `configPath`/`clientId` are load-bearing, not decoration: a record proves
902
+ // ownership of ONE file, and the writer mutates whatever path resolves NOW.
903
+ // Without them a record written for another home directory would grant
904
+ // ownership here and disable would delete fragments it never wrote.
905
+ const classified = classifyIntegration({
906
+ fileText: before, fileIsRegular: true, parsed, record, contribution, configPath, clientId,
907
+ format: effective.format,
908
+ });
909
+ return {
910
+ failed: undefined, store, io, clientId, spec, exportSpec, target: effective, configPath, detectDir,
911
+ /*
912
+ * One token for the plan, because the location alone is not the input: a
913
+ * store whose schema stops being one we recognise changes the answer while
914
+ * its path stays exactly the same.
915
+ */
916
+ ineffectiveWrite: effective.ineffective === null
917
+ ? null
918
+ : `${effective.ineffective.why}\u0000${effective.ineffective.store}`,
919
+ before, parsed, contribution, record, classified,
920
+ } as const;
921
+ }