@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
@@ -31,6 +31,7 @@ import {
31
31
  disableIntegrationCoordinated,
32
32
  overwriteIntegrationCoordinated,
33
33
  restoreIntegrationCoordinated,
34
+ type CoordinatedIntegrationOptions,
34
35
  type IntegrationRestoreInput,
35
36
  type IntegrationWriteInput,
36
37
  type WriteRefused,
@@ -45,7 +46,13 @@ import {
45
46
  import { jsonResponse } from "../auth-cors";
46
47
  import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body";
47
48
  import type { ManagementContext } from "./context";
48
- import { loadExportModels } from "./model-rows";
49
+ import { exportSnapshotIdentity, loadExportModels, previewExportSnapshot } from "./model-rows";
50
+ import {
51
+ previewIntegration,
52
+ type IntegrationMutationPlan,
53
+ type IntegrationPlanOperation,
54
+ type PreviewRequest,
55
+ } from "../../integrations/mutation-plan";
49
56
 
50
57
 
51
58
  const INTEGRATION_ROUTE_PREFIX = "/api/client-integrations/";
@@ -246,6 +253,37 @@ async function buildIntegrationWriteInput(
246
253
  };
247
254
  }
248
255
 
256
+ /**
257
+ * The same input a mutation would build, from the read-only roster.
258
+ *
259
+ * It differs from the mutation's in exactly one way, and the difference is deliberate: the roster
260
+ * comes from `previewExportModels`, which gathers without running the initial-selection finalizer
261
+ * that persists configuration. Everything else is shared, so a preview and the commit that follows
262
+ * it cannot disagree for any reason except the state genuinely moving.
263
+ */
264
+ async function buildIntegrationPreviewInput(
265
+ clientId: IntegrationClientId,
266
+ ctx: ManagementContext,
267
+ store: IntegrationStateStore,
268
+ ): Promise<{ input: IntegrationWriteInput; identity: string } | null> {
269
+ const snapshot = previewExportSnapshot(ctx.config);
270
+ // No cached roster means no honest snapshot to plan against. Gathering one here would make a
271
+ // read refresh credentials and write the provider cache, which is the thing preview must not do.
272
+ if (snapshot === null) return null;
273
+ return {
274
+ identity: snapshot.identity,
275
+ input: {
276
+ clientId,
277
+ models: snapshot.models,
278
+ config: ctx.config,
279
+ port: Number(ctx.url.port) || ctx.config.port,
280
+ store,
281
+ io: integrationMutationTestHooks?.io,
282
+ ...pathOverrides(),
283
+ },
284
+ };
285
+ }
286
+
249
287
  /**
250
288
  * The file's current bytes, or `null` when it is missing.
251
289
  *
@@ -271,6 +309,97 @@ function invalidClientResponse(ctx: ManagementContext): Response {
271
309
  }, 400, ctx.req, ctx.config);
272
310
  }
273
311
 
312
+ /**
313
+ * No cached model roster, so there is nothing honest to plan against.
314
+ *
315
+ * Answered as a bounded refusal rather than by gathering one: discovery refreshes credentials and
316
+ * writes the provider cache, and a preview that did either would be a write wearing a read's name.
317
+ * The caller opens the models view or performs the mutation directly.
318
+ */
319
+ function previewUnavailableResponse(ctx: ManagementContext): Response {
320
+ return jsonResponse({
321
+ error: "no model roster is cached yet, so this change cannot be planned",
322
+ code: "integration_preview_unavailable",
323
+ }, 409, ctx.req, ctx.config);
324
+ }
325
+
326
+ /**
327
+ * A confirmed plan, or a reason the request cannot carry one.
328
+ *
329
+ * Both fields or neither. A half-bound request is rejected rather than quietly treated as
330
+ * unbound, because dropping one half would answer 200 to a caller who believed their
331
+ * confirmation was being checked.
332
+ */
333
+ function planBindingOf(
334
+ body: Record<string, unknown>,
335
+ ): { operation: IntegrationPlanOperation; fingerprint: string } | "none" | "half" | "unknown-operation" {
336
+ const { operation, planFingerprint } = body;
337
+ if (operation === undefined && planFingerprint === undefined) return "none";
338
+ if (operation === undefined || typeof planFingerprint !== "string" || planFingerprint.length === 0) return "half";
339
+ if (operation !== "apply" && operation !== "overwrite" && operation !== "disable" && operation !== "restore") {
340
+ return "unknown-operation";
341
+ }
342
+ return { operation, fingerprint: planFingerprint };
343
+ }
344
+
345
+ function halfBoundResponse(ctx: ManagementContext): Response {
346
+ return jsonResponse({
347
+ error: "operation and planFingerprint must be sent together",
348
+ code: "invalid_preview_binding",
349
+ }, 400, ctx.req, ctx.config);
350
+ }
351
+
352
+ /**
353
+ * Re-plan and compare before the mutation runs.
354
+ *
355
+ * The fingerprint is an optimistic token, never authorization: management authentication and
356
+ * every ownership rule still apply. What it adds is that a confirmation stops meaning anything
357
+ * the moment the state it described moved, and the refusal carries a fresh plan so the operator
358
+ * decides again against what is true now.
359
+ */
360
+ function stalePlanGuard(
361
+ clientId: IntegrationClientId,
362
+ ctx: ManagementContext,
363
+ store: IntegrationStateStore,
364
+ request: PreviewRequest,
365
+ fingerprint: string,
366
+ capturedIdentity: string | null,
367
+ ): {
368
+ revalidate: NonNullable<CoordinatedIntegrationOptions["revalidate"]>;
369
+ response: () => Response | null;
370
+ } {
371
+ let stale: IntegrationMutationPlan | "unavailable" | null = null;
372
+ return {
373
+ revalidate: async frozen => {
374
+ /*
375
+ * Plan the coordinator's OWN frozen input, never a freshly built one. Rebuilding here let
376
+ * the check validate against one roster while the mutation wrote from another, because an
377
+ * ordinary load can replace the snapshot at any time and nothing serialises that against
378
+ * this lock. The captured identity is verified separately, so a replacement is detected
379
+ * without ever swapping the roster this mutation is about to use.
380
+ */
381
+ if (exportSnapshotIdentity(ctx.config) !== capturedIdentity) {
382
+ const refreshed = await buildIntegrationPreviewInput(clientId, ctx, store);
383
+ stale = refreshed === null ? "unavailable" : previewIntegration(refreshed.input, request);
384
+ return { ok: false, reason: "conflict", state: "conflict", clientId, message: "the model roster changed while confirming" };
385
+ }
386
+ const plan = previewIntegration(frozen, request);
387
+ if (plan.canApply && plan.fingerprint === fingerprint) return null;
388
+ stale = plan;
389
+ return { ok: false, reason: "conflict", state: plan.state, clientId, message: "that confirmation no longer describes this file" };
390
+ },
391
+ response: () => {
392
+ if (stale === null) return null;
393
+ if (stale === "unavailable") return previewUnavailableResponse(ctx);
394
+ return jsonResponse({
395
+ error: "integration preview is stale",
396
+ code: "integration_preview_stale",
397
+ plan: stale,
398
+ }, 409, ctx.req, ctx.config);
399
+ },
400
+ };
401
+ }
402
+
274
403
  function internalErrorResponse(error: unknown, ctx: ManagementContext): Response {
275
404
  return jsonResponse({
276
405
  error: error instanceof Error ? error.message : String(error),
@@ -565,6 +694,88 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise<R
565
694
  }
566
695
  }
567
696
 
697
+ if (url.pathname === "/api/client-integrations/preview") {
698
+ if (req.method !== "POST") return null;
699
+ const parsed = await readJsonBody(ctx);
700
+ if (parsed instanceof Response) return parsed;
701
+ if (!isPlainRecord(parsed)) {
702
+ return jsonResponse({ error: "preview body must be an object", code: "invalid_preview_body" }, 400, req, ctx.config);
703
+ }
704
+ const previewClient = parsed.clientId;
705
+ if (typeof previewClient !== "string"
706
+ || !(INTEGRATION_CLIENT_IDS as readonly string[]).includes(previewClient)) {
707
+ return invalidClientResponse(ctx);
708
+ }
709
+ /*
710
+ * Aside is a set of profiles, not one file, and every mutation it has requires a profile. A
711
+ * plan built here would describe the legacy single-account location and no bound mutation
712
+ * would accept it, so an operator could confirm something nothing can carry out. The canonical
713
+ * per-profile preview answers this question properly, and the mutation routes already refuse
714
+ * the unscoped spelling the same way.
715
+ */
716
+ if (previewClient === "aside") {
717
+ return jsonResponse({ error: "Use the canonical Aside profile path", code: "invalid_aside_profile_path" }, 400, req, ctx.config);
718
+ }
719
+ const operation = parsed.operation;
720
+ if (operation !== "apply" && operation !== "overwrite" && operation !== "disable") {
721
+ return jsonResponse({
722
+ error: "operation must be apply, overwrite or disable",
723
+ code: "invalid_preview_operation",
724
+ }, 400, req, ctx.config);
725
+ }
726
+ try {
727
+ const captured = await buildIntegrationPreviewInput(previewClient as IntegrationClientId, ctx, integrationStore());
728
+ if (!captured) return previewUnavailableResponse(ctx);
729
+ return jsonResponse(previewIntegration(captured.input, { operation }), 200, req, ctx.config);
730
+ } catch (error) {
731
+ return internalErrorResponse(error, ctx);
732
+ }
733
+ }
734
+
735
+ if (url.pathname === "/api/client-integrations/restore/preview") {
736
+ if (req.method !== "POST") return null;
737
+ const parsed = await readJsonBody(ctx);
738
+ if (parsed instanceof Response) return parsed;
739
+ if (!isPlainRecord(parsed) || typeof parsed.opId !== "string" || parsed.opId.trim().length === 0) {
740
+ return jsonResponse({ error: "opId must be a non-empty string", code: "invalid_op_id" }, 400, req, ctx.config);
741
+ }
742
+ if (parsed.confirmDrift !== undefined && typeof parsed.confirmDrift !== "boolean") {
743
+ return jsonResponse({ error: "confirmDrift must be a boolean", code: "invalid_confirm_drift" }, 400, req, ctx.config);
744
+ }
745
+ const opId = parsed.opId.trim();
746
+ try {
747
+ const store = integrationStore();
748
+ const operation = store.findOperation(opId);
749
+ /*
750
+ * Answered as "not found" rather than by reading the row back to the caller. A preview is
751
+ * reached before any confirmation, so it is the cheapest place to probe journal contents,
752
+ * and it declines to be one.
753
+ */
754
+ if (!operation) {
755
+ return jsonResponse({
756
+ error: "integration operation not found",
757
+ code: "integration_operation_not_found",
758
+ opId,
759
+ }, 404, req, ctx.config);
760
+ }
761
+ // Same rule, decided after the row is found so an unscoped undo of an Aside operation is
762
+ // refused for what it is rather than answered as a missing operation.
763
+ if (operation.clientId === "aside") {
764
+ return jsonResponse({ error: "Use the canonical Aside profile path", code: "invalid_aside_profile_path" }, 400, req, ctx.config);
765
+ }
766
+ const captured = await buildIntegrationPreviewInput(operation.clientId, ctx, store);
767
+ if (!captured) return previewUnavailableResponse(ctx);
768
+ const plan = previewIntegration(captured.input, {
769
+ operation: "restore",
770
+ opId,
771
+ confirmDrift: parsed.confirmDrift ?? false,
772
+ });
773
+ return jsonResponse(plan, 200, req, ctx.config);
774
+ } catch (error) {
775
+ return internalErrorResponse(error, ctx);
776
+ }
777
+ }
778
+
568
779
  if (url.pathname === "/api/client-integrations/restore") {
569
780
  if (req.method !== "POST") return null;
570
781
  const parsed = await readJsonBody(ctx);
@@ -584,7 +795,22 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise<R
584
795
 
585
796
  const opId = parsed.opId.trim();
586
797
  const confirmDrift = parsed.confirmDrift ?? false;
587
- const asideRestore = await asideRestoreResponse(ctx, { opId, confirmDrift }, profileOptions);
798
+ const restoreBinding = planBindingOf(parsed);
799
+ if (restoreBinding === "half") return halfBoundResponse(ctx);
800
+ if (restoreBinding === "unknown-operation" || (restoreBinding !== "none" && restoreBinding.operation !== "restore")) {
801
+ return jsonResponse({
802
+ error: "operation does not match the requested change",
803
+ code: "invalid_preview_operation",
804
+ }, 400, req, ctx.config);
805
+ }
806
+ // The binding travels with the request. Dropping it here routed a bound Aside restore into
807
+ // the unbound path, which executed the mutation while its confirmation went unexamined.
808
+ const asideRestore = await asideRestoreResponse(ctx, {
809
+ opId,
810
+ confirmDrift,
811
+ ...(parsed.operation === undefined ? {} : { operation: parsed.operation }),
812
+ ...(parsed.planFingerprint === undefined ? {} : { planFingerprint: parsed.planFingerprint }),
813
+ }, profileOptions);
588
814
  if (asideRestore) return asideRestore;
589
815
  let restoreClientId: IntegrationClientId | undefined;
590
816
  try {
@@ -607,7 +833,21 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise<R
607
833
  }, 410, req, ctx.config);
608
834
  }
609
835
 
610
- const writeInput = await buildIntegrationWriteInput(operation.clientId, ctx, store);
836
+ const boundRestore = restoreBinding === "none"
837
+ ? null
838
+ : await buildIntegrationPreviewInput(operation.clientId, ctx, store);
839
+ if (restoreBinding !== "none" && boundRestore === null) return previewUnavailableResponse(ctx);
840
+ const writeInput = boundRestore
841
+ ? boundRestore.input
842
+ : await buildIntegrationWriteInput(operation.clientId, ctx, store);
843
+ const restoreGuard = restoreBinding === "none" ? null : stalePlanGuard(
844
+ operation.clientId,
845
+ ctx,
846
+ store,
847
+ { operation: "restore", opId, confirmDrift },
848
+ restoreBinding.fingerprint,
849
+ boundRestore === null ? null : boundRestore.identity,
850
+ );
611
851
  const restoreInput: IntegrationRestoreInput = {
612
852
  ...writeInput,
613
853
  opId,
@@ -619,8 +859,11 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise<R
619
859
  writeInput.io?.now ?? Date.now,
620
860
  () => restoreIntegrationCoordinated(restoreInput, {
621
861
  lockSeams: integrationMutationTestHooks?.lockSeams,
862
+ ...(restoreGuard ? { revalidate: restoreGuard.revalidate } : {}),
622
863
  }),
623
864
  );
865
+ const restoreStale = restoreGuard?.response();
866
+ if (restoreStale) return restoreStale;
624
867
  if (!result.ok) {
625
868
  /*
626
869
  * Drift is NOT special-cased here.
@@ -698,20 +941,59 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise<R
698
941
  }, 400, req, ctx.config);
699
942
  }
700
943
 
944
+ const requestedOperation: IntegrationPlanOperation = parsed.enabled
945
+ ? (parsed.overwriteConflict === true ? "overwrite" : "apply")
946
+ : "disable";
947
+ const binding = planBindingOf(parsed);
948
+ if (binding === "half") return halfBoundResponse(ctx);
949
+ if (binding === "unknown-operation" || (binding !== "none" && binding.operation !== requestedOperation)) {
950
+ // A confirmation that names a different operation than the request performs is not a
951
+ // confirmation of this request.
952
+ return jsonResponse({
953
+ error: "operation does not match the requested change",
954
+ code: "invalid_preview_operation",
955
+ }, 400, req, ctx.config);
956
+ }
957
+
701
958
  try {
702
- const input = await buildIntegrationWriteInput(requestedClient, ctx, integrationStore());
959
+ /*
960
+ * A bound request is built from the same passive roster the guard re-plans against, and an
961
+ * unbound one keeps its existing refreshing path. Building the refreshing input first would
962
+ * have had the mutation and its own confirmation check disagree about the roster by
963
+ * construction, which is the disagreement this binding exists to detect.
964
+ */
965
+ const boundToggle = binding === "none"
966
+ ? null
967
+ : await buildIntegrationPreviewInput(requestedClient, ctx, integrationStore());
968
+ if (binding !== "none" && boundToggle === null) return previewUnavailableResponse(ctx);
969
+ const input = boundToggle
970
+ ? boundToggle.input
971
+ : await buildIntegrationWriteInput(requestedClient, ctx, integrationStore());
972
+ const guard = binding === "none" ? null : stalePlanGuard(
973
+ requestedClient,
974
+ ctx,
975
+ integrationStore(),
976
+ { operation: binding.operation },
977
+ binding.fingerprint,
978
+ boundToggle === null ? null : boundToggle.identity,
979
+ );
703
980
  const result = await runIntegrationMutationFlight(
704
981
  requestedClient,
705
982
  parsed.enabled ? (parsed.overwriteConflict === true ? "overwrite" : "apply") : "disable",
706
983
  input.io?.now ?? Date.now,
707
984
  () => {
708
- const options = { lockSeams: integrationMutationTestHooks?.lockSeams };
985
+ const options = {
986
+ lockSeams: integrationMutationTestHooks?.lockSeams,
987
+ ...(guard ? { revalidate: guard.revalidate } : {}),
988
+ };
709
989
  if (!parsed.enabled) return disableIntegrationCoordinated(input, options);
710
990
  return parsed.overwriteConflict === true
711
991
  ? overwriteIntegrationCoordinated(input, options)
712
992
  : applyIntegrationCoordinated(input, options);
713
993
  },
714
994
  );
995
+ const stale = guard?.response();
996
+ if (stale) return stale;
715
997
  if (!result.ok) return writerFailureResponse(requestedClient, result, ctx);
716
998
  return jsonResponse(result satisfies IntegrationToggleEnvelope, 200, req, ctx.config);
717
999
  } catch (error) {
@@ -22,6 +22,7 @@ import {
22
22
  upsertOAuthProvider,
23
23
  } from "../../oauth";
24
24
  import { removeCredential } from "../../oauth/store";
25
+ import { getFailureProjection } from "../../usage/failure-projection-cache";
25
26
  import { providerDestinationResolvedError } from "../../lib/destination-policy";
26
27
  import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers";
27
28
  import { deriveProviderPresets } from "../../providers/derive";
@@ -179,6 +180,24 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
179
180
  }
180
181
 
181
182
  if (url.pathname === "/api/usage" && req.method === "GET") {
183
+ // A sub-resource on the same route rather than a route of its own. It answers a different
184
+ // question -- which failures keep recurring, rather than what was spent -- and it costs a
185
+ // ledger scan, so it is opt-in: a dashboard asking for the usage summary must not pay for
186
+ // a projection it did not ask for.
187
+ if (url.searchParams.get("failures") === "1") {
188
+ const projection = await getFailureProjection();
189
+ return jsonResponse({
190
+ fingerprintVersion: projection.fingerprintVersion,
191
+ historyIncomplete: projection.historyIncomplete,
192
+ unattributedFailures: projection.unattributedFailures,
193
+ invalidTimestampFailures: projection.invalidTimestampFailures,
194
+ // Every field here is a closed roster member, a count or a timestamp. The scanner's
195
+ // checkpoint identity, the configured provider name, the model and the account label
196
+ // stay internal -- a grouping that promises to carry no content has to keep that
197
+ // promise at its boundary too.
198
+ failures: projection.groups,
199
+ });
200
+ }
182
201
  const range = parseRange(url.searchParams.get("range"));
183
202
  const surface = parseUsageSurface(url.searchParams.get("surface"));
184
203
  let window: UsageTimeWindow | undefined;
@@ -0,0 +1,20 @@
1
+ import type { ManagementContext } from "./context";
2
+
3
+ export function handleMetricsRoutes(ctx: ManagementContext): Response | null {
4
+ if (ctx.url.pathname === "/api/metrics" && ctx.req.method === "GET") {
5
+ if (!ctx.deps.requestMetrics) {
6
+ return Response.json({ error: { code: "not_found", message: "metrics export is disabled" } }, {
7
+ status: 404,
8
+ headers: { "Cache-Control": "no-store" },
9
+ });
10
+ }
11
+ return new Response(ctx.deps.requestMetrics.snapshot(), {
12
+ status: 200,
13
+ headers: {
14
+ "Cache-Control": "no-store",
15
+ "Content-Type": "text/plain;version=0.0.4",
16
+ },
17
+ });
18
+ }
19
+ return null;
20
+ }