@bitkyc08/opencodex 2.80.0 → 2.82.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (376) hide show
  1. package/README.md +3 -3
  2. package/bin/ocx.mjs +166 -73
  3. package/gui/dist/assets/App-Ds_qL7o8.js +54 -0
  4. package/gui/dist/assets/Tray-BqHF5Trd.js +1 -0
  5. package/gui/dist/assets/{index-Brk6F3fn.css → index-BAbcj51J.css} +1 -1
  6. package/gui/dist/assets/index-D3FBCNiP.js +94 -0
  7. package/gui/dist/assets/quota-summary-DTBC8n7-.js +1 -0
  8. package/gui/dist/index.html +2 -2
  9. package/gui/dist/provider-icons/claude-green.svg +1 -0
  10. package/package.json +3 -2
  11. package/src/adapters/anthropic-model-contract.ts +8 -2
  12. package/src/adapters/anthropic.ts +8 -3
  13. package/src/adapters/codebuddy/mcp-server.ts +2 -25
  14. package/src/adapters/codebuddy/tool-bridge.ts +1 -7
  15. package/src/adapters/coding-agent/mcp-server.ts +33 -0
  16. package/src/adapters/coding-agent/protocol.ts +122 -17
  17. package/src/adapters/coding-agent/tool-catalog.ts +75 -0
  18. package/src/adapters/coding-agent/turn.ts +101 -44
  19. package/src/adapters/cursor/catalog.ts +8 -0
  20. package/src/adapters/cursor/effort-map.ts +2 -0
  21. package/src/adapters/devin/live-models.ts +2 -0
  22. package/src/adapters/kiro-retry.ts +9 -4
  23. package/src/adapters/openai-chat/messages.ts +7 -6
  24. package/src/adapters/openai-responses/compaction-search-history.ts +109 -0
  25. package/src/adapters/openai-responses/passthrough.ts +9 -4
  26. package/src/adapters/openai-responses/tool-output-recovery.ts +20 -0
  27. package/src/adapters/physical-send.ts +8 -5
  28. package/src/adapters/qoder/adapter.ts +41 -6
  29. package/src/adapters/qoder/scaffold-guard.ts +2 -3
  30. package/src/adapters/zed.ts +16 -1
  31. package/src/claude/agents-inject.ts +14 -3
  32. package/src/claude/context-windows.ts +19 -17
  33. package/src/claude/desktop-3p.ts +88 -12
  34. package/src/claude/desktop-profile.ts +2 -1
  35. package/src/claude/gateway-cache.ts +18 -1
  36. package/src/claude/inbound-cache-stabilize.ts +4 -9
  37. package/src/claude/inbound.ts +78 -3
  38. package/src/claude/intercept/cli-catalog.ts +3 -0
  39. package/src/claude/intercept/model-bindings.ts +1 -0
  40. package/src/claude/intercept/picker-ca-persistence.ts +7 -6
  41. package/src/claude/intercept/picker-listener.ts +71 -4
  42. package/src/claude/intercept/picker-models.ts +14 -6
  43. package/src/claude/intercept/settings.ts +3 -0
  44. package/src/claude/long-context.ts +66 -0
  45. package/src/claude/model-info.ts +19 -16
  46. package/src/claude/outbound.ts +94 -9
  47. package/src/claude/subagent-model.ts +40 -11
  48. package/src/cli/account-anthropic-threshold.ts +14 -5
  49. package/src/cli/account-api.ts +3 -1
  50. package/src/cli/account-extended.ts +13 -9
  51. package/src/cli/account-policy-dto.ts +1 -1
  52. package/src/cli/account-policy.ts +13 -4
  53. package/src/cli/account.ts +3 -3
  54. package/src/cli/agent.ts +9 -5
  55. package/src/cli/aside-profile-recovery.ts +33 -0
  56. package/src/cli/capabilities-accounts.ts +2 -2
  57. package/src/cli/capabilities-agents-routing.ts +24 -0
  58. package/src/cli/capabilities-base.ts +5 -5
  59. package/src/cli/capabilities-integrations.ts +18 -2
  60. package/src/cli/cli-path-diagnostics.ts +132 -0
  61. package/src/cli/codex-shim-autorestore.ts +3 -0
  62. package/src/cli/desktop-runtime-guidance.ts +65 -0
  63. package/src/cli/dispatch.ts +15 -1
  64. package/src/cli/doctor.ts +16 -3
  65. package/src/cli/help.ts +4 -1
  66. package/src/cli/index.ts +25 -14
  67. package/src/cli/integration-aside-sync.ts +2 -1
  68. package/src/cli/integration-plan-dto.ts +1 -0
  69. package/src/cli/integration-preview.ts +8 -1
  70. package/src/cli/integrations.ts +48 -6
  71. package/src/cli/message-args.ts +34 -0
  72. package/src/cli/message-command.ts +43 -0
  73. package/src/cli/message-runtime.ts +6 -0
  74. package/src/cli/observe.ts +3 -1
  75. package/src/cli/provider.ts +29 -6
  76. package/src/cli/registry.ts +24 -2
  77. package/src/cli/resolve.ts +25 -2
  78. package/src/cli/root.ts +4 -2
  79. package/src/cli/status.ts +64 -12
  80. package/src/cli/update-restart-child.ts +4 -2
  81. package/src/cli/update-restart-home.ts +28 -10
  82. package/src/cli/update-restart-service-record.ts +116 -0
  83. package/src/cli/update-restart-supervision.ts +96 -0
  84. package/src/cli/update-restart.ts +59 -13
  85. package/src/cli/v2.ts +27 -20
  86. package/src/cli/version-skew-notice.ts +77 -0
  87. package/src/clients/config-export/commandcode.ts +131 -0
  88. package/src/clients/config-export/contracts.ts +12 -0
  89. package/src/clients/config-export.ts +64 -1
  90. package/src/clients/lazycodex.ts +1 -1
  91. package/src/clients/omo-role-models.ts +32 -7
  92. package/src/codex/auth-api/account-list.ts +11 -3
  93. package/src/codex/auth-api/account-selection.ts +107 -0
  94. package/src/codex/auth-api/main-account-probe.ts +25 -8
  95. package/src/codex/auth-api/pool-mode-gate.ts +33 -9
  96. package/src/codex/auth-api/pool-quota-probe.ts +15 -1
  97. package/src/codex/auth-api/routes.ts +11 -49
  98. package/src/codex/auth-context.ts +20 -1
  99. package/src/codex/autostart-health.ts +12 -3
  100. package/src/codex/catalog/auto-review.ts +13 -6
  101. package/src/codex/catalog/build-entries.ts +33 -7
  102. package/src/codex/catalog/bundled.ts +155 -26
  103. package/src/codex/catalog/combo-member.ts +3 -2
  104. package/src/codex/catalog/control-plane.ts +45 -0
  105. package/src/codex/catalog/effort.ts +37 -6
  106. package/src/codex/catalog/gather-capture.ts +10 -3
  107. package/src/codex/catalog/model-hints.ts +15 -8
  108. package/src/codex/catalog/model-visibility.ts +3 -1
  109. package/src/codex/catalog/operator-tiers.ts +38 -0
  110. package/src/codex/catalog/parsing.ts +2 -1
  111. package/src/codex/catalog/provider-models.ts +58 -44
  112. package/src/codex/catalog/retained-sync.ts +7 -5
  113. package/src/codex/catalog/routed-gather.ts +15 -1
  114. package/src/codex/catalog-auto-refresh-sources.ts +3 -4
  115. package/src/codex/catalog-auto-refresh.ts +19 -0
  116. package/src/codex/catalog.ts +1 -1
  117. package/src/codex/codex-home-owner.ts +3 -0
  118. package/src/codex/config-write-lock.ts +333 -0
  119. package/src/codex/control-plane-models.ts +6 -0
  120. package/src/codex/convergence.ts +2 -1
  121. package/src/codex/features.ts +257 -200
  122. package/src/codex/inject/config-write-section.ts +34 -0
  123. package/src/codex/inject/multi-agent-v2.ts +14 -6
  124. package/src/codex/inject/remove.ts +36 -11
  125. package/src/codex/inject/restore.ts +85 -20
  126. package/src/codex/inject-coordination.ts +15 -6
  127. package/src/codex/inject.ts +62 -20
  128. package/src/codex/journal.ts +62 -17
  129. package/src/codex/main-account-cache.ts +43 -0
  130. package/src/codex/paths.ts +2 -0
  131. package/src/codex/prepared-home.ts +18 -0
  132. package/src/codex/prompt-journal.ts +25 -12
  133. package/src/codex/prompt-layers.ts +49 -26
  134. package/src/codex/prompt-lock-claim.ts +134 -0
  135. package/src/codex/prompt-lock-evidence.ts +26 -0
  136. package/src/codex/prompt-lock-io.ts +16 -0
  137. package/src/codex/prompt-lock-owner.ts +130 -0
  138. package/src/codex/prompt-lock.ts +84 -53
  139. package/src/codex/routing/failure-window.ts +146 -0
  140. package/src/codex/routing/selection.ts +4 -1
  141. package/src/codex/routing.ts +13 -17
  142. package/src/codex/runtime.ts +106 -18
  143. package/src/codex/shim.ts +45 -19
  144. package/src/combos/jev-decision-contract.ts +25 -2
  145. package/src/combos/jev-service-exchange.ts +281 -0
  146. package/src/combos/jev.ts +34 -219
  147. package/src/combos/types.ts +1 -1
  148. package/src/config/atomic-write.ts +1 -1
  149. package/src/config/codex-ws-reuse-setting.ts +10 -0
  150. package/src/config/diagnostics.ts +27 -16
  151. package/src/config/live-reconcile.ts +17 -0
  152. package/src/config/load-degrade.ts +42 -0
  153. package/src/config/rebase-provenance.ts +11 -0
  154. package/src/config/schema/anthropic-account-pool.ts +143 -0
  155. package/src/config/schema/config-schema.ts +11 -1
  156. package/src/config/schema/leaf-validators.ts +4 -0
  157. package/src/config.ts +2 -0
  158. package/src/generated/compatibility-version.json +566 -298
  159. package/src/generated/model-metadata.ts +7 -6
  160. package/src/grok/inject.ts +50 -4
  161. package/src/grok/sync.ts +2 -1
  162. package/src/images/loop.ts +30 -11
  163. package/src/integrations/aside-profiles.ts +10 -3
  164. package/src/integrations/catalog-refresh.ts +19 -3
  165. package/src/integrations/mutation-plan.ts +7 -0
  166. package/src/integrations/registry.ts +8 -0
  167. package/src/integrations/state.ts +9 -2
  168. package/src/integrations/writer.ts +17 -5
  169. package/src/lib/bun-path-runtime.d.mts +32 -0
  170. package/src/lib/bun-path-runtime.mjs +95 -0
  171. package/src/lib/bun-runtime-preflight.ts +140 -0
  172. package/src/lib/desktop-cli-handoff.d.mts +20 -0
  173. package/src/lib/desktop-cli-handoff.mjs +92 -0
  174. package/src/lib/desktop-cli-record.d.mts +29 -0
  175. package/src/lib/desktop-cli-record.mjs +102 -0
  176. package/src/lib/lab-live-route-production.ts +9 -1
  177. package/src/lib/request-execution-budget.ts +244 -58
  178. package/src/lib/spend-pool-alias-validation.ts +79 -0
  179. package/src/lib/spend-pool-continuity.ts +34 -0
  180. package/src/lib/spend-reservation-ledger.ts +412 -50
  181. package/src/lib/state-store-registrations.ts +20 -3
  182. package/src/lib/synced-state-location.ts +46 -6
  183. package/src/lib/test-home-guard.ts +87 -1
  184. package/src/lib/translator-budget.ts +17 -4
  185. package/src/lib/upstream-retry.ts +17 -5
  186. package/src/lib/windows-atomic-replace.ts +38 -16
  187. package/src/lib/windows-elevation.ts +6 -0
  188. package/src/lib/windows-secret-acl.ts +92 -25
  189. package/src/lib/winsw.ts +2 -3
  190. package/src/lib/workflow-budget.ts +53 -11
  191. package/src/messaging/budget.ts +45 -0
  192. package/src/messaging/discovery.ts +57 -0
  193. package/src/messaging/envelope.ts +49 -0
  194. package/src/messaging/input.ts +37 -0
  195. package/src/messaging/rpc.ts +196 -0
  196. package/src/messaging/send.ts +58 -0
  197. package/src/messaging/socket.ts +72 -0
  198. package/src/messaging/types.ts +29 -0
  199. package/src/oauth/anthropic-account-refusal.ts +90 -18
  200. package/src/oauth/anthropic-account-threshold.ts +8 -2
  201. package/src/oauth/anthropic-continuity.ts +15 -1
  202. package/src/oauth/anthropic-model-quota.ts +120 -48
  203. package/src/oauth/anthropic-model-routes.ts +11 -3
  204. package/src/oauth/anthropic-oauth-definitions.ts +98 -0
  205. package/src/oauth/anthropic-pool-config.ts +30 -0
  206. package/src/oauth/anthropic-rate-limit-policy.ts +64 -9
  207. package/src/oauth/anthropic-routing.ts +1202 -866
  208. package/src/oauth/anthropic-send-ownership.ts +41 -0
  209. package/src/oauth/anthropic.ts +4 -1
  210. package/src/oauth/chatgpt.ts +113 -41
  211. package/src/oauth/generic-account-failover.ts +3 -1
  212. package/src/oauth/health.ts +8 -6
  213. package/src/oauth/index.ts +96 -175
  214. package/src/oauth/kiro.ts +22 -9
  215. package/src/oauth/login-cli.ts +2 -0
  216. package/src/oauth/model-discovery-auth.ts +43 -0
  217. package/src/oauth/pool-kernel.ts +11 -5
  218. package/src/oauth/pool-settings-capability.ts +5 -3
  219. package/src/oauth/store-anthropic-instance.ts +87 -0
  220. package/src/oauth/store.ts +68 -10
  221. package/src/oauth/token-guardian.ts +2 -0
  222. package/src/oauth/types.ts +2 -0
  223. package/src/protocols/plan-snapshot.ts +5 -0
  224. package/src/protocols/settings.ts +20 -5
  225. package/src/providers/anthropic-instance-id.ts +47 -0
  226. package/src/providers/anthropic-instance.ts +48 -0
  227. package/src/providers/anthropic-reset-grant-ledger.ts +11 -1
  228. package/src/providers/azure-model-metadata.ts +115 -0
  229. package/src/providers/derive.ts +23 -0
  230. package/src/providers/kiro-models.ts +3 -0
  231. package/src/providers/label.ts +1 -1
  232. package/src/providers/model-rename-fields.ts +2 -0
  233. package/src/providers/quota/account-cache.ts +37 -11
  234. package/src/providers/quota/anthropic-account-quota.ts +93 -0
  235. package/src/providers/quota/anthropic-cooldown-recovery.ts +222 -111
  236. package/src/providers/quota/report-cache.ts +24 -1
  237. package/src/providers/quota/vendor-probes-key.ts +127 -17
  238. package/src/providers/quota/vendor-probes-oauth.ts +46 -14
  239. package/src/providers/quota.ts +23 -33
  240. package/src/providers/registry/entries-core.ts +35 -30
  241. package/src/providers/registry/model-ids.ts +1 -0
  242. package/src/providers/registry/model-seeds.ts +7 -4
  243. package/src/providers/registry/types.ts +3 -0
  244. package/src/providers/registry.ts +3 -3
  245. package/src/responses/reasoning-envelope.ts +29 -1
  246. package/src/responses/reasoning-replay-cache.ts +17 -1
  247. package/src/responses/spill-store.ts +20 -3
  248. package/src/responses/state/metrics.ts +95 -0
  249. package/src/responses/state/snapshot-codec.ts +6 -0
  250. package/src/responses/state/spill-failure.ts +12 -0
  251. package/src/responses/state/spill-queue.ts +13 -5
  252. package/src/responses/state/unforced-store-false.ts +55 -0
  253. package/src/responses/state.ts +68 -76
  254. package/src/responses/task-input.ts +17 -0
  255. package/src/router.ts +28 -4
  256. package/src/routing/compatibility/assemble.ts +1 -1
  257. package/src/routing/quota.ts +7 -3
  258. package/src/server/auth-cors.ts +5 -0
  259. package/src/server/background-lifecycle.ts +27 -0
  260. package/src/server/chat-completions.ts +7 -1
  261. package/src/server/chat-native.ts +64 -28
  262. package/src/server/claude-messages.ts +59 -8
  263. package/src/server/gui-static.ts +38 -4
  264. package/src/server/inbound-body-admission.ts +1 -0
  265. package/src/server/index/claude-intercept-lifecycle.ts +3 -1
  266. package/src/server/index/serve-options.ts +10 -2
  267. package/src/server/index/spend-ledger-lifecycle.ts +12 -5
  268. package/src/server/index/startup-warnings.ts +19 -0
  269. package/src/server/index.ts +5 -2
  270. package/src/server/inference/context.ts +1 -1
  271. package/src/server/lifecycle.ts +1 -1
  272. package/src/server/management/agent-settings-routes.ts +123 -38
  273. package/src/server/management/anthropic-account-threshold.ts +12 -9
  274. package/src/server/management/anthropic-pool-settings.ts +72 -0
  275. package/src/server/management/anthropic-reset-grant-routes.ts +98 -39
  276. package/src/server/management/codex-agent-role-routes.ts +4 -2
  277. package/src/server/management/codex-prompt-routes.ts +1 -0
  278. package/src/server/management/codex-role-auto-assign.ts +13 -2
  279. package/src/server/management/config-routes.ts +85 -14
  280. package/src/server/management/context.ts +2 -2
  281. package/src/server/management/decision-routes.ts +2 -1
  282. package/src/server/management/integration-routes.ts +25 -2
  283. package/src/server/management/oauth-account-routes.ts +79 -98
  284. package/src/server/management/provider-instance-ownership.ts +44 -0
  285. package/src/server/management/provider-routes.ts +21 -28
  286. package/src/server/management/route-registry.ts +3 -1
  287. package/src/server/management/routing-profile-routes.ts +4 -4
  288. package/src/server/management/shared.ts +3 -3
  289. package/src/server/management/system-restart.ts +44 -14
  290. package/src/server/management/system-routes.ts +24 -2
  291. package/src/server/management/vision-sidecar-options.ts +9 -6
  292. package/src/server/management/web-search-sidecar-options.ts +51 -2
  293. package/src/server/management-auth.ts +6 -0
  294. package/src/server/messages-native-eligibility.ts +3 -2
  295. package/src/server/messages-native-oauth.ts +91 -45
  296. package/src/server/messages-native-selector.ts +39 -0
  297. package/src/server/messages-native.ts +133 -38
  298. package/src/server/messages-response-headers.ts +14 -0
  299. package/src/server/request-log-conversation.ts +48 -5
  300. package/src/server/request-log.ts +36 -2
  301. package/src/server/responses/adapter-continuation.ts +31 -19
  302. package/src/server/responses/adapter-dispatch.ts +75 -24
  303. package/src/server/responses/antigravity-validation-refusal.ts +20 -6
  304. package/src/server/responses/codex-ws-exchange.ts +17 -2
  305. package/src/server/responses/codex-ws-pool.ts +100 -13
  306. package/src/server/responses/codex-ws-wire.ts +9 -0
  307. package/src/server/responses/combo-requested-effort.ts +3 -1
  308. package/src/server/responses/compact.ts +25 -8
  309. package/src/server/responses/compaction-recovery.ts +1 -1
  310. package/src/server/responses/core-codex-account.ts +22 -1
  311. package/src/server/responses/core-combo-native.ts +24 -4
  312. package/src/server/responses/core-combo.ts +38 -25
  313. package/src/server/responses/core-lifetime.ts +3 -36
  314. package/src/server/responses/core-normalize.ts +1 -0
  315. package/src/server/responses/core-options.ts +8 -1
  316. package/src/server/responses/core-replay.ts +78 -0
  317. package/src/server/responses/core.ts +3 -3
  318. package/src/server/responses/fetch-helpers.ts +26 -3
  319. package/src/server/responses/passthrough-delivery.ts +30 -4
  320. package/src/server/responses/passthrough-dispatch.ts +39 -9
  321. package/src/server/responses/request-prepare.ts +41 -5
  322. package/src/server/responses/request-send-budget.ts +150 -50
  323. package/src/server/responses/request-spend.ts +325 -33
  324. package/src/server/responses/request-transport.ts +132 -52
  325. package/src/server/responses/response-effects.ts +1 -0
  326. package/src/server/responses/run-turn-execution.ts +13 -3
  327. package/src/server/responses/sidecar-execution.ts +113 -22
  328. package/src/server/responses/sidecar-send-budget.ts +58 -0
  329. package/src/server/responses/ws-upstream.ts +19 -6
  330. package/src/server/responses-undeclared-tool-guard.ts +5 -1
  331. package/src/server/startup-health-cache.ts +2 -1
  332. package/src/server/workflow-refusal.ts +33 -0
  333. package/src/service/cli.ts +20 -4
  334. package/src/service/desktop-command-guard.ts +32 -0
  335. package/src/service/desktop-startup.ts +66 -40
  336. package/src/service/desktop-supervision.d.mts +26 -0
  337. package/src/service/desktop-supervision.mjs +140 -0
  338. package/src/service/managing-cli.ts +1 -0
  339. package/src/service/orchestration.ts +46 -20
  340. package/src/service/ownership-mutation-lease.d.mts +1 -1
  341. package/src/service/ownership-mutation-lease.mjs +5 -7
  342. package/src/service/repair.ts +25 -9
  343. package/src/service/state.ts +4 -3
  344. package/src/service/systemd.ts +17 -8
  345. package/src/service/windows-ops.ts +61 -22
  346. package/src/sidecar/anthropic-binding.ts +79 -0
  347. package/src/sidecar/auth.ts +76 -2
  348. package/src/storage/policy-job.ts +5 -1
  349. package/src/storage/policy-worker.ts +3 -1
  350. package/src/storage/policy.ts +9 -0
  351. package/src/types/anthropic-account-pool.ts +33 -0
  352. package/src/types/config.ts +33 -28
  353. package/src/types/provider.ts +5 -0
  354. package/src/types/request.ts +5 -0
  355. package/src/types/tools.ts +7 -0
  356. package/src/types/wire.ts +1 -0
  357. package/src/types.ts +4 -0
  358. package/src/update/index.ts +57 -9
  359. package/src/update/restart-ownership.ts +10 -3
  360. package/src/update/runtime-ownership.d.mts +6 -0
  361. package/src/update/runtime-ownership.mjs +23 -7
  362. package/src/usage/cost.ts +3 -2
  363. package/src/usage/expected-prices.ts +66 -14
  364. package/src/vision/anthropic-describe.ts +16 -3
  365. package/src/vision/index.ts +3 -0
  366. package/src/vision/plan.ts +19 -6
  367. package/src/web-search/alpha-search.ts +7 -1
  368. package/src/web-search/anthropic-executor.ts +16 -4
  369. package/src/web-search/index.ts +7 -3
  370. package/src/web-search/loop.ts +32 -7
  371. package/src/web-search/passthrough-bridge.ts +4 -1
  372. package/src/web-search/sidecar-providers.ts +4 -3
  373. package/gui/dist/assets/App-MbDVfUeC.js +0 -54
  374. package/gui/dist/assets/Tray-CzkxK82A.js +0 -1
  375. package/gui/dist/assets/index-UJt6npXk.js +0 -94
  376. package/gui/dist/assets/quota-summary-DU3sD6Z-.js +0 -1
@@ -0,0 +1,102 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, posix, resolve, win32 } from "node:path";
5
+
6
+ export const DESKTOP_CLI_RECORD_MAX_BYTES = 64 * 1024;
7
+
8
+ export function desktopCliRecordPath(options = {}) {
9
+ const platform = options.platform ?? process.platform;
10
+ const env = options.env ?? process.env;
11
+ const home = options.home ?? (platform === "win32" ? env.USERPROFILE || homedir() : homedir());
12
+ return (platform === "win32" ? win32 : posix).join(home, ".opencodex-desktop", "cli.json");
13
+ }
14
+
15
+ // Keep these lexical rules aligned with Rust's absolute_for in cli_command_record.rs.
16
+ function validTargetPath(path, platform) {
17
+ if ([...path].length > 4096 || /[\x00\r\n]/.test(path)) return false;
18
+ const absolute = platform === "win32"
19
+ ? /^[A-Za-z]:[/\\]/.test(path) || path.startsWith("\\\\") || path.startsWith("//")
20
+ : path.startsWith("/");
21
+ return absolute && !path.split(platform === "win32" ? /[/\\]/ : /\//)
22
+ .some(part => part === "." || part === "..");
23
+ }
24
+
25
+ function noMacAcl(directory, path) {
26
+ const result = spawnSync("/bin/ls", ["-lde", directory, path], {
27
+ encoding: "utf8", timeout: 1000, shell: false, windowsHide: true,
28
+ env: { ...process.env, LC_ALL: "C" },
29
+ });
30
+ if (result.error || result.status !== 0 || result.stderr) return false;
31
+ // Each path has one metadata line; ACL entries add numbered lines and a "+" marker.
32
+ const lines = result.stdout.trimEnd().split("\n");
33
+ return lines.length === 2 && lines.every(line => /^[d-][rwxStTs-]{9}[.@]?\s/.test(line));
34
+ }
35
+
36
+ export function readDesktopCliRecord(options = {}, deps = {}) {
37
+ const platform = options.platform ?? process.platform;
38
+ const path = options.recordPath ?? desktopCliRecordPath(options);
39
+ const directory = dirname(resolve(path));
40
+ const lstat = deps.lstat ?? lstatSync;
41
+ const fstat = deps.fstat ?? fstatSync;
42
+ const open = deps.open ?? openSync;
43
+ const unsafe = () => ({ state: "invalid", path, issue: "record-unsafe" });
44
+ const posixHost = platform !== "win32";
45
+ let fd;
46
+ let checkingSafety = true;
47
+ let absenceAllowed = true;
48
+ try {
49
+ const dirInfo = lstat(directory);
50
+ if (dirInfo.isSymbolicLink() || !dirInfo.isDirectory()) return unsafe();
51
+ const uid = posixHost ? deps.euid ?? process.geteuid?.() : undefined;
52
+ if (posixHost && (uid === undefined || dirInfo.uid !== uid || (dirInfo.mode & 0o077) !== 0)) return unsafe();
53
+ const fileInfo = lstat(path);
54
+ absenceAllowed = false;
55
+ if (fileInfo.isSymbolicLink() || !fileInfo.isFile()
56
+ || (posixHost && (fileInfo.uid !== uid || (fileInfo.mode & 0o077) !== 0))) return unsafe();
57
+ const flags = constants.O_RDONLY | (posixHost ? constants.O_NOFOLLOW | constants.O_NONBLOCK : 0);
58
+ fd = open(path, flags);
59
+ const info = fstat(fd);
60
+ if (!info.isFile() || ["dev", "ino", "uid", "mode"].some(key => info[key] !== fileInfo[key])) return unsafe();
61
+ if (platform === "darwin" && !(deps.checkAcl ?? noMacAcl)(directory, resolve(path))) return unsafe();
62
+ checkingSafety = false;
63
+ if (info.size > DESKTOP_CLI_RECORD_MAX_BYTES) {
64
+ return { state: "invalid", path, issue: "record-too-large" };
65
+ }
66
+ const bytes = Buffer.alloc(DESKTOP_CLI_RECORD_MAX_BYTES + 1);
67
+ let used = 0;
68
+ while (used < bytes.length) {
69
+ const n = readSync(fd, bytes, used, bytes.length - used, null);
70
+ if (n === 0) break;
71
+ used += n;
72
+ }
73
+ if (used > DESKTOP_CLI_RECORD_MAX_BYTES) {
74
+ return { state: "invalid", path, issue: "record-too-large" };
75
+ }
76
+ let value;
77
+ try { value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, used))); }
78
+ catch { return { state: "invalid", path, issue: "record-invalid" }; }
79
+ if (!value || typeof value !== "object" || Array.isArray(value)
80
+ || value.version !== 1 || typeof value.enabled !== "boolean") {
81
+ return { state: "invalid", path, issue: "record-invalid" };
82
+ }
83
+ if (!value.enabled) return { state: "disabled", path, cleanupPending: value.pending != null };
84
+ if (value.pending != null) return { state: "invalid", path, issue: "record-pending" };
85
+ const bundle = value.bundle;
86
+ const kind = { darwin: "macos-app", win32: "windows-install", linux: "linux-deb" }[platform];
87
+ if (!bundle || typeof bundle !== "object" || Array.isArray(bundle)
88
+ || !kind || bundle.platform !== platform || bundle.kind !== kind
89
+ || typeof bundle.cliExecutable !== "string" || !validTargetPath(bundle.cliExecutable, platform)) {
90
+ return { state: "invalid", path, issue: "record-invalid" };
91
+ }
92
+ return { state: "ready", path, record: {
93
+ platform, kind, cliExecutable: bundle.cliExecutable,
94
+ } };
95
+ } catch (error) {
96
+ return error?.code === "ENOENT" && absenceAllowed
97
+ ? { state: "missing", path }
98
+ : checkingSafety ? unsafe() : { state: "unreadable", path, issue: "record-unreadable" };
99
+ } finally {
100
+ if (fd !== undefined) closeSync(fd);
101
+ }
102
+ }
@@ -15,6 +15,7 @@ import {
15
15
  UnsupportedOAuthProviderError,
16
16
  } from "../oauth";
17
17
  import type { OcxConfig } from "../types";
18
+ import { configuredAnthropicInstance, isAnthropicOAuthInstance } from "../providers/anthropic-instance";
18
19
  import { createCredentialLease } from "../lab/live/credential-lease";
19
20
  import {
20
21
  liveUpstreamRequestPath,
@@ -34,7 +35,8 @@ export interface ProductionLabRouteExecutorDeps {
34
35
  loadConfig: () => OcxConfig;
35
36
  }
36
37
 
37
- async function buildLabProviderAuthHeaders(
38
+ /** @internal exported for the Pool 2 ownership regression test. */
39
+ export async function buildLabProviderAuthHeaders(
38
40
  routeContext: LabRouteContext,
39
41
  config: OcxConfig,
40
42
  ): Promise<Record<string, string>> {
@@ -64,6 +66,12 @@ async function buildLabProviderAuthHeaders(
64
66
  if (protocol !== "https:") {
65
67
  throw new TransportError("auth_blocked", "OAuth lab probes require HTTPS");
66
68
  }
69
+ // The Pool 2 credential namespace belongs to the marked builtin row only; an unmarked or
70
+ // orphaned `anthropic2` OAuth row must never obtain its bearer through this generic path.
71
+ if (isAnthropicOAuthInstance(routeContext.providerId)
72
+ && configuredAnthropicInstance(config, routeContext.providerId) !== routeContext.providerId) {
73
+ throw new TransportError("auth_blocked", "anthropic instance unavailable");
74
+ }
67
75
  try {
68
76
  const snapshot = await getValidAccessTokenSnapshot(routeContext.providerId);
69
77
  headers.Authorization = `Bearer ${snapshot.accessToken}`;
@@ -1,3 +1,4 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
1
2
  /**
2
3
  * One logical request, one execution budget (#4546).
3
4
  *
@@ -14,6 +15,7 @@
14
15
  * sends plus one alternate -- by funding the alternate from a reserve that a validated
15
16
  * sanitized repair can spend instead, but never both.
16
17
  */
18
+ import type { SpendSeed, SpendScopes, SpendReservationProof, SpendAdmissionPolicy } from "./spend-reservation-ledger";
17
19
  import type { TransientSendBudget } from "./upstream-retry";
18
20
 
19
21
  export type SendClass =
@@ -97,6 +99,8 @@ export interface SingleUseDispatchPermit {
97
99
  * false, which is what keeps a retry thunk from sending twice on one permit.
98
100
  */
99
101
  use(): boolean;
102
+ /** Keep the selected start rebindable until its executor finishes. */
103
+ execute?<T>(run: () => Promise<T>): Promise<T>;
100
104
  /**
101
105
  * Hand back a reservation that never dispatched -- a credential move that found no alternate,
102
106
  * a rebuild abandoned before the send. Idempotent, and a no-op once the permit was used or
@@ -145,9 +149,18 @@ export interface RequestSendObserver {
145
149
  * would cross a ceiling never joins the total, the total stays just under, and the ceiling
146
150
  * never fires for any later request either.
147
151
  */
148
- charge(options?: { alreadySent?: boolean }): boolean;
152
+ charge(options?: { alreadySent?: boolean; deferDispatch?: boolean; onReserved?: (proof: SpendReservationProof) => void; targetKey?: string }): boolean;
153
+ /** Confirm the exact reservation once its dispatch is known. */
154
+ readonly enforced?: boolean;
155
+ readonly policyStarted?: boolean;
156
+ readonly spendAdmissionPolicy?: SpendAdmissionPolicy;
157
+ /** Capture request policy at its first admission or actual physical start. */
158
+ startRequest?(target?: SpendScopes & { targetKey?: string }): void;
159
+ ensureSeed?(target: SpendScopes & { targetKey?: string }, proof?: SpendReservationProof): SpendSeed | undefined;
160
+ beginReporter?(): { start(seed: SpendSeed, ordinal: number): void; rebindTarget(target: SpendScopes & { targetKey?: string }, ordinal: number): boolean; report(sends: number): void; close(): void };
161
+ dispatch?(proof: SpendReservationProof): void;
149
162
  /** Give back a booking whose send never happened. */
150
- refund(): void;
163
+ refund(proof?: SpendReservationProof | null): void;
151
164
  }
152
165
 
153
166
  /**
@@ -156,6 +169,14 @@ export interface RequestSendObserver {
156
169
  * model send; the reserve is what the fourth send draws on once the base allowance is gone.
157
170
  */
158
171
  export interface RequestExecutionBudget extends TransientSendBudget {
172
+ readonly physicalStarted?: number;
173
+ readonly physicalLimit?: number;
174
+ readonly spendEnforced?: boolean;
175
+ readonly spendPolicyStarted?: boolean;
176
+ readonly spendAdmissionPolicy?: SpendAdmissionPolicy;
177
+ startRequest?(target?: SpendScopes & { targetKey?: string }): void;
178
+ claimPhysicalSend?(): number | undefined;
179
+ beginSpendProducer?(): { close(): void } | undefined;
159
180
  readonly logicalRequestId: string;
160
181
  readonly policyVersion: string;
161
182
  readonly policy: RequestExecutionBudgetPolicy;
@@ -173,6 +194,8 @@ export interface RequestExecutionBudget extends TransientSendBudget {
173
194
  readonly alternateTargetSends: number;
174
195
  readonly targetTransitions: number;
175
196
  readonly lastTargetKey: string | undefined;
197
+ /** Seed a fresh derived scope with the actual endpoint of its already-paid initial send. */
198
+ bindPrepaidTarget?(targetKey: string): void;
176
199
  /**
177
200
  * Spend one operator-granted replacement for an AMBIGUOUS failure of this logical request,
178
201
  * up to `limit`. False once the request has none left.
@@ -223,7 +246,9 @@ let logicalRequestSeq = 0;
223
246
  */
224
247
  interface SharedSendLedger {
225
248
  spent: number;
226
- pendingExternalSends: number;
249
+ physicalStarted: number;
250
+ physicalLimit?: number;
251
+ pendingExternalSends: Set<object>;
227
252
  /**
228
253
  * Spend one of this logical request's replacements for an ambiguous failure. Beside `spent`
229
254
  * for the same reason `pendingExternalSends` is: a derived scope that shared one without the
@@ -239,6 +264,102 @@ interface SharedSendLedger {
239
264
  }
240
265
 
241
266
  const sharedSendLedgers = new WeakMap<RequestExecutionBudget, SharedSendLedger>();
267
+ const dispatchSpendProofs = new WeakMap<SingleUseDispatchPermit, {
268
+ owner: SharedSendLedger;
269
+ claim(): SpendReservationProof | undefined;
270
+ report(): boolean;
271
+ }>();
272
+
273
+ /** One preflight for the exact prepaid dispatch, never another budget or a replayed permit. */
274
+ export function claimDispatchSpendProof(budget: RequestExecutionBudget, permit?: SingleUseDispatchPermit): SpendReservationProof | undefined {
275
+ const proof = permit && dispatchSpendProofs.get(permit);
276
+ return proof && proof.owner === sharedSendLedgers.get(budget) ? proof.claim() : undefined;
277
+ }
278
+
279
+ /** Reports actual sends against only the named permit on this shared ledger. */
280
+ export function reportDispatchSends(
281
+ budget: TransientSendBudget,
282
+ sends: number,
283
+ permit?: SingleUseDispatchPermit,
284
+ ): void {
285
+ const count = Number.isFinite(sends) ? Math.max(0, Math.trunc(sends)) : 0;
286
+ if (count === 0) return;
287
+ const receipt = permit && dispatchSpendProofs.get(permit);
288
+ const prepaid = receipt && receipt.owner === sharedSendLedgers.get(budget as RequestExecutionBudget)
289
+ && receipt.report() ? 1 : 0;
290
+ // Unnamed, foreign, released or already-reported permits cannot consume another receipt.
291
+ budget.used += count - prepaid;
292
+ }
293
+
294
+ /** A helper owns its starts and can never consume another helper's receipts. */
295
+ export type PhysicalSendReporter = ((sends: number) => void) & {
296
+ beforeSend?: () => boolean;
297
+ close?: () => void;
298
+ execute?: <T>(run: () => Promise<T>) => Promise<T>;
299
+ bind?: (target: SpendScopes & { targetKey?: string }) => boolean;
300
+ };
301
+ const activePhysicalReporters = new AsyncLocalStorage<PhysicalSendReporter>();
302
+ /** Selection owners rebind this helper's current start before invoking the executor. */
303
+ export function rebindPhysicalSend(budget: TransientSendBudget | undefined, target: SpendScopes & { targetKey?: string }): boolean {
304
+ if (!(budget as RequestExecutionBudget | undefined)?.spendEnforced) return true;
305
+ return activePhysicalReporters.getStore()?.bind?.(target) ?? true;
306
+ }
307
+ export function createPhysicalSendReporter(
308
+ budget: TransientSendBudget,
309
+ target: () => SpendScopes & { targetKey?: string },
310
+ permit?: SingleUseDispatchPermit,
311
+ telemetry?: (sends: number) => void,
312
+ ): PhysicalSendReporter {
313
+ const counter = sharedSendLedgers.get(budget as RequestExecutionBudget);
314
+ const observer = counter?.observer;
315
+ let producer: ReturnType<NonNullable<RequestSendObserver["beginReporter"]>> | undefined;
316
+ let closed = false;
317
+ let started = 0;
318
+ let reported = 0;
319
+ let currentOrdinal: number | undefined;
320
+ const report: PhysicalSendReporter = (sends) => {
321
+ if (closed) return;
322
+ if (!producer) { if (observer?.enforced !== true) { reportDispatchSends(budget, sends, permit); telemetry?.(sends); } return; }
323
+ const count = Math.min(Math.max(0, Math.trunc(sends)), started - reported);
324
+ try { producer.report(count); }
325
+ finally {
326
+ reported += count;
327
+ const receipt = permit && dispatchSpendProofs.get(permit);
328
+ const prepaid = count > 0 && receipt && receipt.owner === counter && receipt.report() ? 1 : 0;
329
+ if (counter) counter.spent += count - prepaid;
330
+ telemetry?.(count);
331
+ }
332
+ };
333
+ if (observer?.startRequest || observer?.enforced === true) report.beforeSend = () => {
334
+ if (closed) return false;
335
+ const selected = target();
336
+ if ((budget as RequestExecutionBudget).startRequest) (budget as RequestExecutionBudget).startRequest!(selected);
337
+ else observer?.startRequest?.(selected);
338
+ if (observer?.enforced !== true) return true;
339
+ producer ??= observer.beginReporter?.();
340
+ const receipt = permit && dispatchSpendProofs.get(permit);
341
+ const proof = receipt?.owner === counter ? receipt?.claim() : undefined;
342
+ const seed = observer.ensureSeed?.(selected, proof);
343
+ if (!seed) return false;
344
+ const ordinal = (budget as RequestExecutionBudget).claimPhysicalSend?.();
345
+ if (ordinal === undefined) return false;
346
+ producer?.start(seed, ordinal);
347
+ currentOrdinal = ordinal;
348
+ started++;
349
+ return true;
350
+ };
351
+ report.execute = run => activePhysicalReporters.run(report, run);
352
+ report.bind = (selected: SpendScopes & { targetKey?: string }) => {
353
+ if (!producer || currentOrdinal === undefined || observer?.enforced !== true) return true;
354
+ return producer.rebindTarget(selected, currentOrdinal);
355
+ };
356
+ report.close = () => {
357
+ if (closed) return;
358
+ try { if (producer) report(started - reported); }
359
+ finally { closed = true; producer?.close(); }
360
+ };
361
+ return report;
362
+ }
242
363
 
243
364
  /**
244
365
  * One logical request's replacement grant: how many it has spent, and the ceiling it is held
@@ -271,6 +392,7 @@ function createAmbiguousResendGrant(): Pick<SharedSendLedger, "claimAmbiguousRes
271
392
  };
272
393
  }
273
394
 
395
+ /** Keep target-local transition state over the shared ledger and its single-use bookings. */
274
396
  function createRequestExecutionBudgetWithLedger(
275
397
  policy: RequestExecutionBudgetPolicy,
276
398
  logicalRequestId: string | undefined,
@@ -281,22 +403,35 @@ function createRequestExecutionBudgetWithLedger(
281
403
  let alternateTargetSends = 0;
282
404
  let targetTransitions = 0;
283
405
  let lastTargetKey: string | undefined;
406
+ const targetReservations: Array<{ targetKey: string }> = [];
284
407
 
285
408
  const budget: RequestExecutionBudget = {
409
+ get physicalStarted(): number { return counter.physicalStarted; },
410
+ get physicalLimit(): number | undefined { return counter.physicalLimit; },
411
+ get spendEnforced(): boolean { return observer?.enforced === true; },
412
+ get spendPolicyStarted(): boolean { return observer?.policyStarted === true; },
413
+ get spendAdmissionPolicy(): SpendAdmissionPolicy | undefined { return observer?.spendAdmissionPolicy; },
414
+ startRequest(target) {
415
+ observer?.startRequest?.(target);
416
+ if (observer?.enforced && counter.physicalLimit === undefined) {
417
+ counter.physicalLimit = Number.isSafeInteger(policy.maxTotalModelSends) && policy.maxTotalModelSends > 0 ? policy.maxTotalModelSends : 0;
418
+ }
419
+ },
420
+ beginSpendProducer() { return observer?.enforced ? observer.beginReporter?.() : undefined; },
421
+ claimPhysicalSend(): number | undefined {
422
+ budget.startRequest?.();
423
+ if (observer?.enforced !== true || counter.physicalLimit === undefined) return undefined;
424
+ if (counter.physicalStarted >= counter.physicalLimit) return undefined;
425
+ return ++counter.physicalStarted;
426
+ },
286
427
  get used(): number { return counter.spent; },
287
428
  set used(next: number) {
288
- // The retry helpers report their real send count by assigning through this field. A
289
- // reservation taken with `countedExternally` has already booked one of those sends, so
290
- // the report settles the pending booking first and only the surplus is charged.
291
- const delta = next - counter.spent;
292
- if (delta <= 0) {
293
- counter.spent = Math.max(0, next);
294
- return;
295
- }
296
- const settled = Math.min(delta, counter.pendingExternalSends);
297
- counter.pendingExternalSends -= settled;
298
- const charged = delta - settled;
299
- counter.spent += charged;
429
+ // A numeric report has no receipt identity. Only reportDispatchSends may settle a
430
+ // prepaid permit; guessing by reservation order can refund another leg's actual send.
431
+ // Enforced sends can only be reconciled through their producer's claimed starts.
432
+ if (observer?.enforced === true) return;
433
+ const charged = next - counter.spent;
434
+ counter.spent = Math.max(0, next);
300
435
  // These sends have already left. The ledger records them even past a ceiling it would
301
436
  // have refused, because refusing after the fact only hides spend that was really
302
437
  // incurred -- the refusal has to happen at the reservation below, or not at all.
@@ -309,6 +444,11 @@ function createRequestExecutionBudgetWithLedger(
309
444
  get alternateTargetSends() { return alternateTargetSends; },
310
445
  get targetTransitions() { return targetTransitions; },
311
446
  get lastTargetKey() { return lastTargetKey; },
447
+ /** Seed only a fresh scope; never reset recovery history on an existing target. */
448
+ bindPrepaidTarget(targetKey: string): void {
449
+ // Only a fresh scope may bind: this is initial identity, never a recovery rebase.
450
+ if (lastTargetKey === undefined) lastTargetKey = targetKey;
451
+ },
312
452
  remainingBaseSends(cap: number): number {
313
453
  const capped = Number.isFinite(cap) ? Math.trunc(cap) : 0;
314
454
  return Math.max(0, Math.min(capped, policy.baseSendAllowance - counter.spent));
@@ -349,59 +489,104 @@ function createRequestExecutionBudgetWithLedger(
349
489
  // Consulted last, because it is the only bound here that WRITES. A ledger entry booked
350
490
  // for a dispatch a cheaper check above would have refused is spend this request never
351
491
  // makes, and it would hold those tokens against the scope until retention expired.
352
- if (observer && !observer.charge()) return { allowed: false, reason: "spend-exhausted" };
492
+ budget.startRequest?.({ targetKey: intent.targetKey });
493
+ let spendProof: SpendReservationProof | undefined;
494
+ if (observer && !observer.charge({ targetKey: intent.targetKey, deferDispatch: true, onReserved: proof => { spendProof = proof; } })) {
495
+ return { allowed: false, reason: "spend-exhausted" };
496
+ }
353
497
 
354
498
  // THE RESERVATION IS THE CHARGE. Deciding here and charging in `use()` left a window in
355
499
  // which two legs read the same remainder, both received a permit, and both dispatched:
356
500
  // one remaining send admitted two physical sends, which is the per-request multiplication
357
501
  // this budget exists to stop. Everything is booked now; `release()` is the way back.
358
- const previousTargetKey = lastTargetKey;
359
502
  counter.spent += 1;
360
- if (intent.countedExternally === true) counter.pendingExternalSends += 1;
503
+ const receipt = { targetKey: intent.targetKey };
504
+ targetReservations.push(receipt);
505
+ if (intent.countedExternally === true) counter.pendingExternalSends.add(receipt);
361
506
  if (drawsReserve) reserveSpent = true;
362
507
  if (chargesAlternateTarget) alternateTargetSends += 1;
363
508
  if (chargesTransition) targetTransitions += 1;
364
509
  lastTargetKey = intent.targetKey;
365
510
 
511
+ let producer: ReturnType<NonNullable<RequestSendObserver["beginReporter"]>> | undefined;
512
+ let executing = false;
513
+ let physicalOrdinal: number | undefined;
514
+ const startPhysical = (): boolean => {
515
+ if (observer?.enforced !== true) return true;
516
+ const seed = observer.ensureSeed?.({ targetKey: intent.targetKey }, spendProof);
517
+ if (!seed) return false;
518
+ const ordinal = budget.claimPhysicalSend?.();
519
+ if (ordinal === undefined) return false;
520
+ producer = observer.beginReporter?.();
521
+ producer?.start(seed, ordinal);
522
+ physicalOrdinal = ordinal;
523
+ if (!executing) { try { producer?.report(1); } finally { producer?.close(); } }
524
+ return true;
525
+ };
366
526
  let settled: "open" | "used" | "released" = "open";
367
- return {
368
- allowed: true,
369
- permit: {
370
- sendClass: intent.sendClass,
371
- use(): boolean {
372
- if (settled !== "open") return false;
373
- settled = "used";
374
- return true;
375
- },
376
- assumeCharge(): boolean {
377
- if (settled !== "open") return false;
378
- settled = "used";
379
- // The booking this reservation made for an external reporter is now owned by the
380
- // caller. Leaving it pending is not harmless: the next `used` report of this request
381
- // would settle against it and one real send would go uncharged.
382
- if (intent.countedExternally === true && counter.pendingExternalSends > 0) {
383
- counter.pendingExternalSends -= 1;
384
- }
385
- return true;
386
- },
387
- release(): void {
388
- if (settled !== "open") return;
389
- settled = "released";
390
- // An externally counted reservation the reporter already settled paid for a send
391
- // that physically happened. Refunding it would hand the request a free send back.
392
- if (intent.countedExternally === true) {
393
- if (counter.pendingExternalSends === 0) return;
394
- counter.pendingExternalSends -= 1;
395
- }
396
- counter.spent -= 1;
397
- observer?.refund();
398
- if (drawsReserve) reserveSpent = false;
399
- if (chargesAlternateTarget) alternateTargetSends -= 1;
400
- if (chargesTransition) targetTransitions -= 1;
401
- lastTargetKey = previousTargetKey;
402
- },
527
+ const permit: SingleUseDispatchPermit = {
528
+ sendClass: intent.sendClass,
529
+ async execute<T>(run: () => Promise<T>): Promise<T> {
530
+ if (executing) throw new Error("Dispatch permit executor already active");
531
+ executing = true;
532
+ const binding: PhysicalSendReporter = () => {};
533
+ binding.bind = (target: SpendScopes & { targetKey?: string }) => physicalOrdinal === undefined || !producer
534
+ ? true : producer.rebindTarget(target, physicalOrdinal);
535
+ try { return await activePhysicalReporters.run(binding, run); }
536
+ finally {
537
+ executing = false;
538
+ try { producer?.report(1); } finally { producer?.close(); }
539
+ }
540
+ },
541
+ use(): boolean {
542
+ if (settled !== "open") return false;
543
+ if (intent.countedExternally !== true && !startPhysical()) return false;
544
+ settled = "used";
545
+ if (intent.countedExternally !== true && spendProof) observer?.dispatch?.(spendProof);
546
+ return true;
547
+ },
548
+ assumeCharge(): boolean {
549
+ if (settled !== "open" || (intent.countedExternally === true && !counter.pendingExternalSends.has(receipt))) return false;
550
+ if (!startPhysical()) return false;
551
+ settled = "used";
552
+ // The booking this reservation made for an external reporter is now owned by the
553
+ // caller. Close only its own receipt so a later reporter cannot spend it again.
554
+ if (intent.countedExternally === true) counter.pendingExternalSends.delete(receipt);
555
+ if (spendProof) observer?.dispatch?.(spendProof);
556
+ return true;
557
+ },
558
+ release(): void {
559
+ if (settled !== "open") return;
560
+ settled = "released";
561
+ // An externally counted reservation the reporter already settled paid for a send
562
+ // that physically happened. Refunding it would hand the request a free send back.
563
+ if (intent.countedExternally === true) {
564
+ if (!counter.pendingExternalSends.delete(receipt)) return;
565
+ }
566
+ counter.spent -= 1;
567
+ observer?.refund(spendProof ?? null);
568
+ if (drawsReserve) reserveSpent = false;
569
+ if (chargesAlternateTarget) alternateTargetSends -= 1;
570
+ if (chargesTransition) targetTransitions -= 1;
571
+ targetReservations.splice(targetReservations.indexOf(receipt), 1);
572
+ lastTargetKey = targetReservations.at(-1)?.targetKey;
403
573
  },
404
574
  };
575
+ let preflightClaimed = false;
576
+ dispatchSpendProofs.set(permit, { owner: counter, report: () => {
577
+ if (!counter.pendingExternalSends.has(receipt)) return false;
578
+ if (spendProof) observer?.dispatch?.(spendProof);
579
+ counter.pendingExternalSends.delete(receipt);
580
+ // Reset-only helpers report just BEFORE calling the dispatch thunk. Its one use()
581
+ // remains available, but release/proof cannot refund or reuse this reported receipt.
582
+ return true;
583
+ }, claim: () => {
584
+ if (preflightClaimed || settled === "released"
585
+ || (intent.countedExternally === true ? !counter.pendingExternalSends.has(receipt) : settled !== "open")) return undefined;
586
+ preflightClaimed = true;
587
+ return spendProof;
588
+ } });
589
+ return { allowed: true, permit };
405
590
  },
406
591
  };
407
592
  sharedSendLedgers.set(budget, counter);
@@ -416,7 +601,8 @@ export function createRequestExecutionBudget(
416
601
  const grant = createAmbiguousResendGrant();
417
602
  return createRequestExecutionBudgetWithLedger(policy, logicalRequestId, {
418
603
  spent: 0,
419
- pendingExternalSends: 0,
604
+ physicalStarted: 0,
605
+ pendingExternalSends: new Set(),
420
606
  claimAmbiguousResend: grant.claimAmbiguousResend,
421
607
  get ambiguousResendSpent(): boolean { return grant.ambiguousResendSpent; },
422
608
  ...(observer ? { observer } : {}),
@@ -460,7 +646,7 @@ const bridgedGrantClaims = new WeakMap<RequestExecutionBudget, { claimed: boolea
460
646
  function ledgerFor(parent: RequestExecutionBudget): SharedSendLedger {
461
647
  const existing = sharedSendLedgers.get(parent);
462
648
  if (existing) return existing;
463
- let pendingExternalSends = 0;
649
+ const pendingExternalSends = new Set<object>();
464
650
  let bridged = bridgedGrantClaims.get(parent);
465
651
  if (!bridged) {
466
652
  bridged = { claimed: false };
@@ -470,8 +656,8 @@ function ledgerFor(parent: RequestExecutionBudget): SharedSendLedger {
470
656
  return {
471
657
  get spent(): number { return parent.used; },
472
658
  set spent(next: number) { parent.used = next; },
473
- get pendingExternalSends(): number { return pendingExternalSends; },
474
- set pendingExternalSends(next: number) { pendingExternalSends = next; },
659
+ pendingExternalSends,
660
+ physicalStarted: 0,
475
661
  // Asked of the parent rather than counted here. A local counter is a SECOND grant: two
476
662
  // scopes derived from one bridged parent, or one scope beside the parent it was derived
477
663
  // from, each replaced an unknown-state send once. Pending bookings and the durable-spend
@@ -0,0 +1,79 @@
1
+ import { createHash } from "node:crypto";
2
+ import { constants, lstatSync, openSync, fstatSync, readFileSync, closeSync, type Stats } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ /** Read an existing single-owner salt without creating or hardening any file. */
6
+ export function readExistingSpendSalt(home: string): string | undefined {
7
+ let fd: number | undefined;
8
+ let salt: string | undefined;
9
+ try {
10
+ const path = join(home, "spend-ledger.salt");
11
+ const before = lstatSync(path);
12
+ if (!before.isFile() || before.nlink !== 1) return undefined;
13
+ const matches = (stat: Stats): boolean => stat.isFile() && stat.nlink === 1
14
+ && stat.dev === before.dev && stat.ino === before.ino;
15
+ fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
16
+ if (!matches(fstatSync(fd))) return undefined;
17
+ const contents = readFileSync(fd, "utf8").trim();
18
+ if (matches(fstatSync(fd)) && matches(lstatSync(path)) && /^[0-9a-f]{32,}$/.test(contents)) salt = contents;
19
+ } catch { salt = undefined; }
20
+ finally {
21
+ if (fd !== undefined) {
22
+ try { closeSync(fd); } catch { salt = undefined; }
23
+ }
24
+ }
25
+ return salt;
26
+ }
27
+
28
+ /**
29
+ * The one owner of the spend ledger's salted alias hash. The ledger, config diagnostics and
30
+ * runtime policy validation must all compute the same alias for the same (salt, domain, id),
31
+ * so the hash lives here and nowhere else. The raw id never leaves this function.
32
+ */
33
+ export function hashSpendAlias(salt: string, kind: string, id: string): string {
34
+ return createHash("sha256").update(salt).update("\u0000").update(kind).update("\u0000").update(id)
35
+ .digest("hex").slice(0, 32);
36
+ }
37
+
38
+ const isAlias = (value: unknown): value is string =>
39
+ typeof value === "string" && /^[0-9a-f]{32}$/.test(value);
40
+
41
+ /** Shape and optional roster check, outside `spend` so old schemas keep configured ceilings. */
42
+ export function spendPoolAliasesError(value: unknown, providerIds?: Iterable<string>): string | undefined {
43
+ if (value === undefined) return undefined;
44
+ if (!value || typeof value !== "object" || Array.isArray(value)) return "spendPoolAliases must be an object";
45
+ if (Object.keys(value).length > 4_096) return "spendPoolAliases has too many entries";
46
+ const providers = providerIds === undefined ? undefined : new Set(providerIds);
47
+ for (const [alias, provider] of Object.entries(value)) {
48
+ if (!isAlias(alias) || typeof provider !== "string" || !provider.trim() || provider.trim() !== provider || provider.length > 256) {
49
+ return "spendPoolAliases requires exact 32-character salted pool aliases and nonempty provider IDs";
50
+ }
51
+ if (providers && !providers.has(provider)) return "spendPoolAliases targets a provider that is not configured";
52
+ }
53
+ return undefined;
54
+ }
55
+
56
+ /**
57
+ * Semantic owner check: an alias that is the salted pool alias of a configured provider P
58
+ * belongs to P. Mapping it to any other provider Q would move P's own history into Q's pool,
59
+ * so `aliases[h("pool", P)] = Q` with Q !== P is rejected. Re-run whenever the provider set
60
+ * changes: adding P later turns an existing mapping into a conflict.
61
+ */
62
+ export function validatePoolAliasOwners(
63
+ aliases: unknown,
64
+ salt: string,
65
+ providerIds: Iterable<string>,
66
+ ): string | undefined {
67
+ const shape = spendPoolAliasesError(aliases);
68
+ if (shape) return shape;
69
+ if (aliases === undefined) return undefined;
70
+ const mapping = aliases as Record<string, string>;
71
+ for (const provider of new Set(providerIds)) {
72
+ const owned = hashSpendAlias(salt, "pool", provider);
73
+ const target = Object.hasOwn(mapping, owned) ? mapping[owned] : undefined;
74
+ if (target !== undefined && target !== provider) {
75
+ return "spendPoolAliases maps a configured provider's own pool alias to a different provider";
76
+ }
77
+ }
78
+ return undefined;
79
+ }