@bitkyc08/opencodex 2.58.0 → 2.60.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 (303) hide show
  1. package/README.md +28 -10
  2. package/gui/dist/assets/index-BTuCbqQd.css +1 -0
  3. package/gui/dist/assets/index-DoBVdPHP.js +134 -0
  4. package/gui/dist/index.html +2 -2
  5. package/gui/dist/provider-icons/crusoe.svg +1 -0
  6. package/gui/dist/provider-icons/opper.svg +3 -0
  7. package/package.json +4 -1
  8. package/src/adapters/anthropic-image-codec.ts +16 -2
  9. package/src/adapters/anthropic-image-normalize.ts +49 -2
  10. package/src/adapters/anthropic.ts +4 -1
  11. package/src/adapters/base.ts +34 -1
  12. package/src/adapters/coding-agent/turn.ts +22 -2
  13. package/src/adapters/command-code.ts +50 -3
  14. package/src/adapters/cursor/catalog.ts +11 -0
  15. package/src/adapters/cursor/checkpoint-store.ts +3 -0
  16. package/src/adapters/cursor/discovery.ts +11 -8
  17. package/src/adapters/cursor/effort-map.ts +16 -2
  18. package/src/adapters/cursor/envelope-echo.ts +55 -2
  19. package/src/adapters/cursor/live-transport.ts +26 -9
  20. package/src/adapters/cursor/message-mapper.ts +3 -2
  21. package/src/adapters/cursor/protobuf-request.ts +8 -5
  22. package/src/adapters/cursor/request-builder.ts +21 -4
  23. package/src/adapters/cursor/thread-continuity.ts +105 -31
  24. package/src/adapters/cursor/tool-guidance.ts +5 -4
  25. package/src/adapters/cursor/transport.ts +19 -0
  26. package/src/adapters/cursor.ts +45 -2
  27. package/src/adapters/devin/cloud-direct/chat.ts +14 -3
  28. package/src/adapters/devin/cloud-direct/index.ts +7 -0
  29. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +140 -0
  30. package/src/adapters/devin.ts +125 -26
  31. package/src/adapters/google-antigravity-replay.ts +1 -1
  32. package/src/adapters/google-antigravity-wire.ts +55 -4
  33. package/src/adapters/google-http.ts +57 -11
  34. package/src/adapters/google-tool-schema.ts +595 -31
  35. package/src/adapters/google-wire-compiler.ts +93 -10
  36. package/src/adapters/google-wire-shape.ts +461 -0
  37. package/src/adapters/google.ts +60 -10
  38. package/src/adapters/openai-chat/response-events.ts +61 -0
  39. package/src/adapters/openai-chat-images.ts +3 -1
  40. package/src/adapters/openai-chat.ts +10 -11
  41. package/src/adapters/openai-responses/image-gen.ts +8 -6
  42. package/src/adapters/openai-responses/passthrough.ts +25 -4
  43. package/src/adapters/openai-responses/reasoning.ts +7 -0
  44. package/src/adapters/openai-responses/tool-output-recovery.ts +75 -0
  45. package/src/adapters/openai-responses/tool-schema.ts +19 -7
  46. package/src/adapters/opencode-go-additional-tools.ts +12 -2
  47. package/src/adapters/responses-tool-schema.ts +76 -46
  48. package/src/adapters/run-turn-queue.ts +17 -4
  49. package/src/bridge/response-json.ts +1 -1
  50. package/src/bridge/sse.ts +28 -35
  51. package/src/claude/context-windows.ts +22 -0
  52. package/src/claude/outbound.ts +35 -4
  53. package/src/cli/account-api.ts +4 -3
  54. package/src/cli/account-extended.ts +26 -6
  55. package/src/cli/account-orca-import.ts +63 -0
  56. package/src/cli/account.ts +32 -4
  57. package/src/cli/capabilities.ts +40 -0
  58. package/src/cli/claude.ts +29 -1
  59. package/src/cli/codex-cli-update.ts +97 -2
  60. package/src/cli/dispatch.ts +57 -3
  61. package/src/cli/doctor.ts +218 -4
  62. package/src/cli/help.ts +4 -1
  63. package/src/cli/hub.ts +3 -2
  64. package/src/cli/index.ts +95 -22
  65. package/src/cli/models-runtime.ts +33 -4
  66. package/src/cli/opencode.ts +2 -2
  67. package/src/cli/provider.ts +13 -1
  68. package/src/cli/registry.ts +11 -1
  69. package/src/cli/runtime-api.ts +44 -0
  70. package/src/cli/start-args.ts +94 -0
  71. package/src/cli/system-command.ts +2 -0
  72. package/src/client/machine-api.ts +6 -5
  73. package/src/client/machine-listener.ts +16 -3
  74. package/src/client/runtime.ts +26 -2
  75. package/src/clients/config-export/constants.ts +2 -3
  76. package/src/clients/config-export.ts +5 -5
  77. package/src/codex/account-store.ts +146 -5
  78. package/src/codex/auth-api/account-list.ts +19 -11
  79. package/src/codex/auth-api/pool-quota-probe.ts +44 -10
  80. package/src/codex/auth-api/routes.ts +17 -2
  81. package/src/codex/auth-context.ts +16 -12
  82. package/src/codex/catalog/build-entries.ts +25 -4
  83. package/src/codex/catalog/derive-entry.ts +8 -1
  84. package/src/codex/catalog/effort.ts +10 -6
  85. package/src/codex/catalog/gather-capture.ts +22 -2
  86. package/src/codex/catalog/model-hints.ts +66 -33
  87. package/src/codex/catalog/parsing.ts +90 -5
  88. package/src/codex/catalog/provider-models.ts +19 -2
  89. package/src/codex/catalog/reserve-warn.ts +96 -0
  90. package/src/codex/catalog/retained-sync.ts +41 -26
  91. package/src/codex/catalog/routed-gather.ts +61 -3
  92. package/src/codex/cli-installation-identity.ts +210 -0
  93. package/src/codex/cli-installation-targets.ts +158 -0
  94. package/src/codex/context-compat.ts +5 -2
  95. package/src/codex/convergence.ts +5 -0
  96. package/src/codex/desired-state.ts +4 -1
  97. package/src/codex/history-job.ts +6 -6
  98. package/src/codex/history-provider.ts +24 -167
  99. package/src/codex/history-rollout-read.ts +174 -0
  100. package/src/codex/history-state-open.ts +105 -0
  101. package/src/codex/inject/config-toml.ts +44 -2
  102. package/src/codex/inject.ts +3 -2
  103. package/src/codex/internal/catalog-writer.ts +33 -1
  104. package/src/codex/lineage.ts +83 -32
  105. package/src/codex/loopback-target.ts +31 -0
  106. package/src/codex/main-account-hard-lock.ts +2 -1
  107. package/src/codex/main-account.ts +10 -3
  108. package/src/codex/main-device-reauth.ts +17 -9
  109. package/src/codex/model-cache.ts +47 -0
  110. package/src/codex/model-entitlements.ts +80 -2
  111. package/src/codex/observed-model-denials.ts +230 -0
  112. package/src/codex/orca-auth-source.ts +94 -0
  113. package/src/codex/orca-import.ts +219 -0
  114. package/src/codex/prompt-text-probe.ts +289 -16
  115. package/src/codex/quota-401-recovery.ts +12 -0
  116. package/src/codex/quota-types.ts +65 -0
  117. package/src/codex/quota.ts +24 -19
  118. package/src/codex/routing/cooldown-math.ts +8 -47
  119. package/src/codex/routing/pin-drain.ts +57 -0
  120. package/src/codex/routing.ts +20 -16
  121. package/src/codex/shim.ts +1 -1
  122. package/src/codex/subagent-model-fallback.ts +114 -2
  123. package/src/codex/windows-installation-files.ts +224 -0
  124. package/src/combos/failover.ts +125 -5
  125. package/src/config/admitted-identity.ts +222 -0
  126. package/src/config/diagnostics.ts +43 -1
  127. package/src/config/feature-flags.ts +5 -0
  128. package/src/config/load-degrade.ts +33 -0
  129. package/src/config/pending-teardown.ts +8 -0
  130. package/src/config/process-state.ts +36 -3
  131. package/src/config/provider-relative-send-path.ts +16 -0
  132. package/src/config/proxy-env.ts +31 -7
  133. package/src/config/schema/compaction-triggers.ts +11 -0
  134. package/src/config/schema/config-schema.ts +25 -0
  135. package/src/config/schema/leaf-validators.ts +76 -17
  136. package/src/config.ts +2 -2
  137. package/src/generated/compatibility-version.json +410 -258
  138. package/src/generated/model-metadata.ts +1 -1
  139. package/src/grok/reset-coupons.ts +38 -19
  140. package/src/images/loop.ts +6 -1
  141. package/src/integrations/aside-profile-context.ts +37 -3
  142. package/src/integrations/aside-profile-journal.ts +68 -3
  143. package/src/integrations/aside-profiles.ts +128 -3
  144. package/src/integrations/mutation-plan.ts +815 -0
  145. package/src/integrations/writer.ts +85 -99
  146. package/src/lab/live/transport.ts +4 -0
  147. package/src/lab/live/types.ts +5 -0
  148. package/src/lab/subject/behavior-fingerprint.ts +1 -1
  149. package/src/lib/admin-secrets.ts +9 -1
  150. package/src/lib/bounded-body.ts +4 -2
  151. package/src/lib/debug-log-buffer.ts +6 -1
  152. package/src/lib/debug.ts +23 -0
  153. package/src/lib/destination-policy.ts +48 -6
  154. package/src/lib/errors.ts +82 -15
  155. package/src/lib/http-response-semantics.ts +57 -0
  156. package/src/lib/lab-live-pinned-sender.ts +26 -12
  157. package/src/lib/local-destinations.ts +32 -5
  158. package/src/lib/pinned-http.ts +142 -2
  159. package/src/lib/plain-data.ts +103 -0
  160. package/src/lib/process-control.ts +13 -5
  161. package/src/lib/provider-outbound.ts +53 -5
  162. package/src/lib/proxy-env.ts +70 -3
  163. package/src/lib/request-execution-budget.ts +11 -3
  164. package/src/lib/response-body-inactivity.ts +193 -0
  165. package/src/lib/retry-delay.ts +69 -0
  166. package/src/lib/socks5-fetch.ts +741 -0
  167. package/src/lib/spend-ledger-owner.ts +364 -0
  168. package/src/lib/spend-reservation-ledger.ts +332 -35
  169. package/src/lib/windows-system-proxy.ts +16 -11
  170. package/src/lib/workflow-budget.ts +145 -8
  171. package/src/oauth/account-quota-rank.ts +72 -15
  172. package/src/oauth/callback-server.ts +4 -3
  173. package/src/oauth/generic-account-failover.ts +41 -27
  174. package/src/oauth/health.ts +12 -1
  175. package/src/oauth/index.ts +3 -107
  176. package/src/oauth/login-flow-state.ts +127 -0
  177. package/src/oauth/orcarouter.ts +15 -2
  178. package/src/oauth/store.ts +8 -0
  179. package/src/providers/codex-capacity.ts +9 -0
  180. package/src/providers/derive.ts +34 -17
  181. package/src/providers/devin-cli-authmode-migration.ts +14 -10
  182. package/src/providers/devin-provider-merge-migration.ts +33 -12
  183. package/src/providers/free-directory.ts +20 -2
  184. package/src/providers/key-failover.ts +327 -25
  185. package/src/providers/model-rename-migration.ts +56 -1
  186. package/src/providers/model-rename-startup.ts +7 -5
  187. package/src/providers/openai-sidecar.ts +4 -0
  188. package/src/providers/openai-virtual-models.ts +42 -2
  189. package/src/providers/opencode-go-transport.ts +14 -5
  190. package/src/providers/quota/antigravity.ts +22 -2
  191. package/src/providers/quota/report-cache.ts +3 -0
  192. package/src/providers/quota/vendor-probes-key.ts +1 -1
  193. package/src/providers/registry/entries-core.ts +39 -17
  194. package/src/providers/registry/entries-extended.ts +131 -1
  195. package/src/providers/registry/model-ids.ts +168 -0
  196. package/src/providers/registry/model-seeds.ts +87 -21
  197. package/src/providers/registry/types.ts +2 -0
  198. package/src/providers/resolved-model-policy-merge.ts +167 -0
  199. package/src/providers/resolved-model-policy.ts +406 -0
  200. package/src/providers/stale-vision-classification-migration.ts +137 -0
  201. package/src/responses/apply-patch-envelope.ts +32 -11
  202. package/src/responses/bridge-search-replay-cache.ts +152 -0
  203. package/src/responses/code-mode-helper-compat.ts +26 -16
  204. package/src/responses/custom-tool-compat.ts +1 -1
  205. package/src/responses/freeform-wrapper-scan.ts +279 -0
  206. package/src/responses/hosted-tool-policy.ts +85 -2
  207. package/src/responses/legacy-dotted-tool-name-repair.ts +134 -0
  208. package/src/responses/progressive-freeform-input.ts +130 -0
  209. package/src/responses/reasoning-envelope.ts +30 -0
  210. package/src/responses/schema.ts +9 -2
  211. package/src/responses/state.ts +5 -12
  212. package/src/responses/tool-name-aliases.ts +15 -1
  213. package/src/router.ts +91 -115
  214. package/src/routing/compatibility/behavior.ts +9 -0
  215. package/src/routing/compatibility/subject.ts +16 -1
  216. package/src/server/adapter-resolve.ts +9 -0
  217. package/src/server/auth-cors.ts +29 -0
  218. package/src/server/chat-completions.ts +13 -5
  219. package/src/server/chat-native-sse.ts +26 -9
  220. package/src/server/chat-native.ts +10 -4
  221. package/src/server/claude-messages.ts +30 -5
  222. package/src/server/effort-row.ts +11 -3
  223. package/src/server/grok-responses-control-frame.ts +160 -1
  224. package/src/server/gui-static.ts +36 -2
  225. package/src/server/inbound-body-admission.ts +187 -0
  226. package/src/server/index/serve-options.ts +86 -29
  227. package/src/server/index/spend-ledger-lifecycle.ts +66 -0
  228. package/src/server/index/websocket-handler.ts +6 -1
  229. package/src/server/index.ts +24 -25
  230. package/src/server/management/api-access.ts +3 -4
  231. package/src/server/management/aside-profile-routes.ts +266 -7
  232. package/src/server/management/config-routes.ts +48 -7
  233. package/src/server/management/context.ts +3 -0
  234. package/src/server/management/integration-routes.ts +287 -5
  235. package/src/server/management/metrics-routes.ts +20 -0
  236. package/src/server/management/model-rows.ts +224 -12
  237. package/src/server/management/provider-capability-config.ts +35 -7
  238. package/src/server/management/provider-routes.ts +70 -18
  239. package/src/server/management/route-registry.ts +14 -0
  240. package/src/server/management/shared.ts +10 -3
  241. package/src/server/management/system-restart.ts +7 -2
  242. package/src/server/management/system-routes.ts +2 -0
  243. package/src/server/management/usage-aggregate-cache.ts +4 -0
  244. package/src/server/management-api.ts +2 -0
  245. package/src/server/management-auth.ts +15 -1
  246. package/src/server/proxy-liveness.ts +97 -2
  247. package/src/server/readiness.ts +29 -10
  248. package/src/server/relay-eager.ts +24 -2
  249. package/src/server/relay.ts +136 -34
  250. package/src/server/request-log.ts +80 -3
  251. package/src/server/request-metrics.ts +236 -0
  252. package/src/server/responses/adapter-continuation.ts +74 -30
  253. package/src/server/responses/adapter-delivery.ts +39 -8
  254. package/src/server/responses/adapter-dispatch.ts +63 -30
  255. package/src/server/responses/compact.ts +89 -18
  256. package/src/server/responses/compaction-routing.ts +111 -0
  257. package/src/server/responses/core-codex-account.ts +90 -24
  258. package/src/server/responses/core-combo.ts +7 -7
  259. package/src/server/responses/core-normalize.ts +16 -15
  260. package/src/server/responses/core-opaque-recovery.ts +1 -0
  261. package/src/server/responses/core-options.ts +4 -0
  262. package/src/server/responses/encrypted-payload.ts +20 -2
  263. package/src/server/responses/fetch-helpers.ts +68 -2
  264. package/src/server/responses/passthrough-delivery.ts +41 -15
  265. package/src/server/responses/passthrough-dispatch.ts +145 -53
  266. package/src/server/responses/passthrough-execution.ts +11 -1
  267. package/src/server/responses/policy-fallback.ts +5 -13
  268. package/src/server/responses/request-prepare.ts +96 -17
  269. package/src/server/responses/request-send-budget.ts +89 -8
  270. package/src/server/responses/request-sidecar-auth.ts +17 -9
  271. package/src/server/responses/request-spend.ts +38 -9
  272. package/src/server/responses/request-transport.ts +15 -12
  273. package/src/server/responses/run-turn-execution.ts +45 -8
  274. package/src/server/responses/sidecar-execution.ts +19 -2
  275. package/src/server/responses/ws-upstream.ts +16 -28
  276. package/src/server/responses-custom-tool-repair.ts +29 -56
  277. package/src/server/responses-undeclared-tool-guard.ts +31 -1
  278. package/src/server/sse-frame-buffer.ts +12 -10
  279. package/src/server/sse-payload-rewrite.ts +37 -10
  280. package/src/server/system-env-shell.ts +5 -1
  281. package/src/server/system-env.ts +7 -1
  282. package/src/server/workflow-refusal.ts +56 -2
  283. package/src/service/cli.ts +16 -6
  284. package/src/service/guards.ts +10 -0
  285. package/src/service/health.ts +43 -0
  286. package/src/service/state.ts +7 -2
  287. package/src/tray/windows-tray.ps1 +155 -3
  288. package/src/types/accounts.ts +4 -0
  289. package/src/types/config.ts +111 -6
  290. package/src/types/provider.ts +37 -0
  291. package/src/types/request.ts +9 -1
  292. package/src/types/tools.ts +14 -0
  293. package/src/types/wire.ts +9 -1
  294. package/src/types.ts +1 -0
  295. package/src/usage/expected-prices.ts +28 -0
  296. package/src/usage/log.ts +87 -4
  297. package/src/vision/eligibility.ts +88 -9
  298. package/src/vision/plan.ts +34 -10
  299. package/src/web-search/executor.ts +41 -2
  300. package/src/web-search/loop.ts +6 -1
  301. package/src/web-search/passthrough-bridge.ts +39 -5
  302. package/gui/dist/assets/index-BbrHOIY0.js +0 -128
  303. package/gui/dist/assets/index-C5-RdDmD.css +0 -1
@@ -24,11 +24,10 @@
24
24
  * released. Minting a new root id mints no new budget because the identity and pool scopes
25
25
  * still hold the spend.
26
26
  *
27
- * SUPPORTED TOPOLOGY: this guarantees a single proxy process against its own journal. The
28
- * file is append-friendly, but nothing here serializes two live processes writing it, so a
29
- * second proxy sharing the same OPENCODEX_HOME is explicitly outside the guarantee -- that
30
- * needs a shared store with cross-process atomicity and is declared out of scope rather
31
- * than implied.
27
+ * SUPPORTED TOPOLOGY: one live writer owns one OPENCODEX_HOME journal. Every server and
28
+ * direct shared-ledger caller must hold the state-directory SQLite lease before replay,
29
+ * append or compaction. Independent homes remain independent; multi-host shared storage
30
+ * still needs a distributed transaction boundary and is outside this local lease.
32
31
  *
33
32
  * Five properties this file owes its callers. Each one was absent in the first draft, and a
34
33
  * budget that can be bypassed is worse than no budget because it looks like protection:
@@ -54,15 +53,24 @@
54
53
  * that are re-applied to an EXISTING file rather than trusted from its creation.
55
54
  */
56
55
 
57
- import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
56
+ import { appendFileSync, chmodSync, closeSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
58
57
  import { createHash, randomBytes } from "node:crypto";
59
58
  import { dirname, join } from "node:path";
60
59
  // Definition-site import, not the ../config barrel -- same reasoning as
61
60
  // src/quota/reset-seen-store.ts: the barrel pulls ~154 modules into a hot path.
62
61
  import { getConfigDir } from "../config/paths";
62
+ // Type-only, so it is erased before this module has a runtime import graph at all. The
63
+ // config SHAPE is what this file needs; the config loader is what the note above keeps out.
64
+ import type { OcxSpendConfig, OcxSpendScopeConfig } from "../types/config";
63
65
  import { assertNotRealHomeUnderTest } from "./test-home-guard";
64
66
  // Windows chmod does not remove inherited ACEs; this is the repository's icacls path.
65
67
  import { hardenSecretPath } from "./windows-secret-acl";
68
+ import { assertSpendLedgerOwnerHeld, assertStorageOwned, bindSpendLedgerOwnerHome, mintSpendLedgerStorage, onSpendLedgerOwnerReleased, resetSpendLedgerOwnerBindingForTest, SpendLedgerOwnerError, spendLedgerOwnerSnapshot, spendLedgerStoragePath, type SpendLedgerStorage } from "./spend-ledger-owner";
69
+
70
+ // The singleton belongs to the state directory it was built for. Releasing ownership hands that
71
+ // directory to whoever comes next, so the in-memory copy goes with it and the next construction
72
+ // replays the journal.
73
+ onSpendLedgerOwnerReleased(() => { sharedLedger = undefined; });
66
74
 
67
75
  export const SPEND_LEDGER_JOURNAL_FILENAME = "spend-ledger.jsonl";
68
76
  /**
@@ -147,6 +155,24 @@ export interface SpendReservationRequest {
147
155
  /** Enforceable output ceiling -- max_output_tokens or the model's documented cap. */
148
156
  readonly outputCeilingTokens: number;
149
157
  readonly at?: number;
158
+ /**
159
+ * This send has ALREADY left for upstream and is being recorded rather than admitted.
160
+ *
161
+ * Some transports report their physical sends after the fact -- the passthrough ladder
162
+ * reports through `onSendsConsumed`, and an adapter's inner retries are counted when they
163
+ * finish. For those, a ceiling cannot refuse anything: the tokens are spent. Refusing to
164
+ * BOOK them is the worse answer, and it is not hypothetical -- it is a fixpoint. The send
165
+ * that would cross the ceiling gets dropped from the total, the total stays just under the
166
+ * limit forever, the scope never reads as exhausted, and the ceiling never fires again for
167
+ * any request. So a recorded send skips the limit check and takes the scope over its
168
+ * ceiling, which is what makes the NEXT request refusable.
169
+ *
170
+ * It skips the durability refusal for the same reason: a journal that could not be written
171
+ * is a reason to report degradation, never a reason to forget spend that really happened.
172
+ * Identity, capacity and journal-integrity denials still apply -- those say the ledger
173
+ * cannot account for the send at all, which no flag here can change.
174
+ */
175
+ readonly alreadySent?: boolean;
150
176
  }
151
177
 
152
178
  /**
@@ -370,7 +396,70 @@ function hardenLedgerFile(path: string, options: { readonly force?: boolean } =
370
396
  } catch { /* best-effort: a non-owner cannot chmod */ }
371
397
  }
372
398
 
373
- export function createFileSpendJournal(path: string): SpendJournal {
399
+ /**
400
+ * Fault injection for the journal's own filesystem steps. Internal test contract, not config.
401
+ *
402
+ * The compaction cleanup only runs when a step after the exclusive create fails, and there is no
403
+ * portable way to make a validate, harden or rename fail on demand. Without a seam the cleanup
404
+ * would ship asserted by reading alone, which is how a failure path stays broken.
405
+ */
406
+ export type SpendJournalFaultStep = "stat" | "create" | "write" | "validate" | "harden" | "rename";
407
+
408
+ let journalFaultForTests: ((step: SpendJournalFaultStep, temp: string) => void) | undefined;
409
+
410
+ export function setSpendJournalFaultForTests(
411
+ fault: ((step: SpendJournalFaultStep, temp: string) => void) | undefined,
412
+ ): void {
413
+ journalFaultForTests = fault;
414
+ }
415
+
416
+ /**
417
+ * Does a directory entry exist here, whatever it points at?
418
+ *
419
+ * `existsSync` follows the link, so a symlink whose target is absent reads as "no file" and an
420
+ * append then creates that target somewhere else entirely. The entry itself is what decides
421
+ * whether the safety check runs.
422
+ */
423
+ function ledgerEntryExists(path: string): boolean {
424
+ try {
425
+ journalFaultForTests?.("stat", path);
426
+ lstatSync(path);
427
+ return true;
428
+ } catch (error) {
429
+ // Only a genuinely absent entry is absent. Treating every failure as "no file" meant a
430
+ // permission denial or an I/O error skipped assertSafeLedgerFile entirely and let the
431
+ // append proceed against whatever is actually there, which is the case that check exists
432
+ // for. An entry we cannot inspect is a refusal, not an empty slot.
433
+ //
434
+ // Raised as the module's typed error rather than the raw fs error: the path and errno of a
435
+ // state file are not something a client should be handed.
436
+ if ((error as NodeJS.ErrnoException | null)?.code === "ENOENT") return false;
437
+ throw new SpendLedgerOwnerError(
438
+ "SPEND_LEDGER_OWNER_UNAVAILABLE",
439
+ "Spend-ledger storage could not be inspected safely.",
440
+ { cause: error },
441
+ );
442
+ }
443
+ }
444
+
445
+ function assertSafeLedgerFile(path: string): void {
446
+ const stat = lstatSync(path);
447
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1
448
+ || (process.platform !== "win32" && stat.uid !== process.getuid!())) {
449
+ throw new SpendLedgerOwnerError(
450
+ "SPEND_LEDGER_OWNER_UNAVAILABLE",
451
+ "Spend-ledger storage could not be opened safely.",
452
+ );
453
+ }
454
+ }
455
+
456
+ /**
457
+ * The production journal. Its location comes from the owned state directory and every touch
458
+ * proves that ownership, so there is no entrypoint here that writes a caller-chosen path.
459
+ */
460
+ export function createOwnedFileSpendJournal(storage: SpendLedgerStorage): SpendJournal {
461
+ assertStorageOwned(storage);
462
+ const path = spendLedgerStoragePath(storage);
374
463
  const ensureDir = (): string => {
375
464
  const dir = dirname(path);
376
465
  // The guard runs before any mutation so a rejected write leaves nothing behind.
@@ -380,26 +469,66 @@ export function createFileSpendJournal(path: string): SpendJournal {
380
469
  };
381
470
  return {
382
471
  read(): string[] {
383
- if (!existsSync(path)) return [];
472
+ assertStorageOwned(storage);
473
+ if (!ledgerEntryExists(path)) return [];
474
+ assertSafeLedgerFile(path);
384
475
  // Replay is once per process and is the moment a journal inherited from an older build
385
476
  // or a restored backup first passes through here.
386
477
  hardenLedgerFile(path, { force: true });
387
478
  return readFileSync(path, "utf8").split("\n").filter((line) => line.length > 0);
388
479
  },
389
480
  append(line: string): void {
481
+ assertStorageOwned(storage);
390
482
  ensureDir();
391
- const created = !existsSync(path);
483
+ const created = !ledgerEntryExists(path);
484
+ if (!created) assertSafeLedgerFile(path);
392
485
  appendFileSync(path, line + "\n", { encoding: "utf8", mode: 0o600 });
486
+ assertSafeLedgerFile(path);
393
487
  hardenLedgerFile(path, { force: created });
394
488
  },
395
489
  rewrite(lines: string[]): void {
490
+ assertStorageOwned(storage);
396
491
  ensureDir();
397
492
  // Same directory, so the rename is atomic on the same filesystem: a crash mid-compaction
398
493
  // leaves either the old journal or the new one, never a half-written ledger.
399
- const temp = `${path}.compact-${process.pid}`;
400
- writeFileSync(temp, lines.map((line) => line + "\n").join(""), { encoding: "utf8", mode: 0o600 });
401
- hardenLedgerFile(temp, { force: true });
402
- renameSync(temp, path);
494
+ if (ledgerEntryExists(path)) assertSafeLedgerFile(path);
495
+ const temp = `${path}.compact-${process.pid}-${randomBytes(6).toString("hex")}`;
496
+ // The creation is INSIDE the cleanup, not before it. The name carries random bytes, so a
497
+ // failure anywhere after the entry exists used to leave a uniquely named file and the next
498
+ // attempt made another: repeated failures accumulated instead of overwriting one fixed
499
+ // name. Only an entry this call created is removed, so a name that turned out to belong to
500
+ // something else is left alone, and the original journal and the primary error survive.
501
+ let fd: number | undefined;
502
+ let created = false;
503
+ let renamed = false;
504
+ try {
505
+ // Exclusive create FIRST, so "this entry is ours" is a fact rather than a guess about
506
+ // which error a combined write threw. EEXIST leaves created false and the name is left
507
+ // alone; every failure after this point is cleaned because the entry is provably ours,
508
+ // including a write that stopped partway through.
509
+ journalFaultForTests?.("create", temp);
510
+ fd = openSync(temp, "wx", 0o600);
511
+ created = true;
512
+ journalFaultForTests?.("write", temp);
513
+ writeFileSync(fd, lines.map((line) => line + "\n").join(""), { encoding: "utf8" });
514
+ closeSync(fd);
515
+ fd = undefined;
516
+ journalFaultForTests?.("validate", temp);
517
+ assertSafeLedgerFile(temp);
518
+ journalFaultForTests?.("harden", temp);
519
+ hardenLedgerFile(temp, { force: true });
520
+ journalFaultForTests?.("rename", temp);
521
+ renameSync(temp, path);
522
+ renamed = true;
523
+ } finally {
524
+ if (fd !== undefined) {
525
+ try { closeSync(fd); } catch { /* the compaction failure is the one to report */ }
526
+ }
527
+ if (created && !renamed) {
528
+ try { unlinkSync(temp); } catch { /* same */ }
529
+ }
530
+ }
531
+ assertSafeLedgerFile(path);
403
532
  hardenLedgerFile(path, { force: true });
404
533
  },
405
534
  };
@@ -412,17 +541,26 @@ export function createFileSpendJournal(path: string): SpendJournal {
412
541
  * recorded spend, which would hand every scope a fresh allowance -- so it is a file, not a
413
542
  * per-process value.
414
543
  */
415
- export function loadOrCreateSpendLedgerSalt(path: string): string {
416
- if (existsSync(path)) {
544
+ export function loadOrCreateSpendLedgerSalt(storage: SpendLedgerStorage): string {
545
+ assertStorageOwned(storage);
546
+ const path = spendLedgerStoragePath(storage);
547
+ if (ledgerEntryExists(path)) {
548
+ assertSafeLedgerFile(path);
417
549
  hardenLedgerFile(path, { force: true });
418
550
  const existing = readFileSync(path, "utf8").trim();
419
551
  if (/^[0-9a-f]{32,}$/.test(existing)) return existing;
552
+ throw new SpendLedgerOwnerError(
553
+ "SPEND_LEDGER_OWNER_UNAVAILABLE",
554
+ "Spend-ledger storage could not be opened safely.",
555
+ );
420
556
  }
421
557
  const dir = dirname(path);
558
+ assertStorageOwned(storage);
422
559
  assertNotRealHomeUnderTest(dir);
423
560
  mkdirSync(dir, { recursive: true, mode: 0o700 });
424
561
  const salt = randomBytes(32).toString("hex");
425
- writeFileSync(path, salt + "\n", { encoding: "utf8", mode: 0o600 });
562
+ writeFileSync(path, salt + "\n", { encoding: "utf8", mode: 0o600, flag: "wx" });
563
+ assertSafeLedgerFile(path);
426
564
  hardenLedgerFile(path, { force: true });
427
565
  return salt;
428
566
  }
@@ -468,6 +606,20 @@ export interface SpendReservationLedger {
468
606
  prune(now?: number): void;
469
607
  /** Whether this send id is already known, and therefore refused. */
470
608
  knows(sendId: string): boolean;
609
+ /**
610
+ * Replace the live policy.
611
+ *
612
+ * Every figure already accounted survives: raising, lowering or clearing a ceiling changes
613
+ * what is REFUSED from here on and never what was spent. Rebuilding the ledger instead
614
+ * would replay the journal into a second set of maps while the first still holds this
615
+ * process's open reservations, and the two would then disagree about what is in flight.
616
+ */
617
+ reconfigure(next: SpendReservationPolicy): void;
618
+ /**
619
+ * The policy in force. A live read, not a copy: a caller that formats a refusal has to name
620
+ * the ceiling this ledger would enforce on the NEXT request, not the one it was built with.
621
+ */
622
+ readonly policy: SpendReservationPolicy;
471
623
  /** Journal writes that failed; a nonzero count means durability is degraded. */
472
624
  readonly persistFailures: number;
473
625
  /**
@@ -494,14 +646,25 @@ export function createSpendReservationLedger(options: {
494
646
  * no file anyone could correlate.
495
647
  */
496
648
  readonly salt?: string;
649
+ /**
650
+ * Shared production ledgers prove their exact ownership before reading or changing
651
+ * accounting. Identity, not just the directory name: a handle kept across a release and a
652
+ * reacquire describes a journal another writer may have changed in between.
653
+ */
654
+ readonly assertOwnedAccounting?: () => void;
497
655
  } = {}): SpendReservationLedger {
498
- const policy = options.policy ?? DEFAULT_SPEND_RESERVATION_POLICY;
656
+ // Mutable because the ceilings are operator configuration, and configuration is reloadable.
657
+ // The three bounds below are read through functions for the same reason: a value captured
658
+ // at construction would answer for the policy this ledger was BUILT with, and an operator
659
+ // who raised a bound would keep the old one until the process restarted.
660
+ let policy = options.policy ?? DEFAULT_SPEND_RESERVATION_POLICY;
499
661
  const journal = options.journal;
500
662
  const now = options.now ?? (() => Date.now());
501
663
  const salt = options.salt ?? "";
502
- const maxTrackedScopes = policy.maxTrackedScopes ?? DEFAULT_MAX_TRACKED_SCOPES;
503
- const maxTrackedSends = policy.maxTrackedSends ?? DEFAULT_MAX_TRACKED_SENDS;
504
- const compactAfterRecords = policy.compactAfterRecords ?? DEFAULT_COMPACT_AFTER_RECORDS;
664
+ const assertOwnedAccounting = options.assertOwnedAccounting;
665
+ const maxTrackedScopes = (): number => policy.maxTrackedScopes ?? DEFAULT_MAX_TRACKED_SCOPES;
666
+ const maxTrackedSends = (): number => policy.maxTrackedSends ?? DEFAULT_MAX_TRACKED_SENDS;
667
+ const compactAfterRecords = (): number => policy.compactAfterRecords ?? DEFAULT_COMPACT_AFTER_RECORDS;
505
668
  const scopes = new Map<string, ScopeState>();
506
669
  const reservations = new Map<string, Reservation>();
507
670
  let persistFailures = 0;
@@ -553,7 +716,8 @@ export function createSpendReservationLedger(options: {
553
716
  journal.append(JSON.stringify(record));
554
717
  recordsOnDisk += 1;
555
718
  return true;
556
- } catch {
719
+ } catch (error) {
720
+ if (error instanceof SpendLedgerOwnerError) throw error;
557
721
  // In-memory state still bounds this process; the counter is how a caller learns the
558
722
  // restart guarantee degraded instead of discovering it after the fact.
559
723
  persistFailures += 1;
@@ -753,7 +917,7 @@ export function createSpendReservationLedger(options: {
753
917
  */
754
918
  const compact = (at: number): void => {
755
919
  const rewrite = journal?.rewrite;
756
- if (!journal || !rewrite || recordsOnDisk < compactAfterRecords) return;
920
+ if (!journal || !rewrite || recordsOnDisk < compactAfterRecords()) return;
757
921
  const checkpoint: JournalRecord = {
758
922
  v: 1,
759
923
  kind: "checkpoint",
@@ -780,7 +944,8 @@ export function createSpendReservationLedger(options: {
780
944
  try {
781
945
  rewrite.call(journal, [JSON.stringify(checkpoint)]);
782
946
  recordsOnDisk = 1;
783
- } catch {
947
+ } catch (error) {
948
+ if (error instanceof SpendLedgerOwnerError) throw error;
784
949
  // Compaction is maintenance, not accounting: a failed rewrite leaves the previous
785
950
  // journal intact and every figure in it still replayable.
786
951
  persistFailures += 1;
@@ -791,12 +956,12 @@ export function createSpendReservationLedger(options: {
791
956
  const makeRoom = (refs: readonly ScopeRef[], at: number): SpendDenial | undefined => {
792
957
  evictSends(at, false);
793
958
  evictScopes(at, false);
794
- while (reservations.size >= maxTrackedSends) {
959
+ while (reservations.size >= maxTrackedSends()) {
795
960
  if (evictSends(at, true) === 0) return { reason: "tracking-capacity-exhausted" };
796
961
  }
797
962
  let fresh = 0;
798
963
  for (const ref of refs) if (!scopes.has(scopeKey(ref.scope, ref.alias))) fresh += 1;
799
- while (scopes.size + fresh > maxTrackedScopes) {
964
+ while (scopes.size + fresh > maxTrackedScopes()) {
800
965
  if (evictScopes(at, true) === 0) {
801
966
  return { reason: "tracking-capacity-exhausted", scope: refs[0]?.scope };
802
967
  }
@@ -805,11 +970,15 @@ export function createSpendReservationLedger(options: {
805
970
  };
806
971
 
807
972
  return {
808
- get persistFailures() { return persistFailures; },
809
- get corruptRecords() { return corruptRecords; },
810
- get degraded() { return persistFailures > 0 || corruptRecords > 0; },
973
+ // Every figure this ledger reports describes a journal it must still own. Reporting one
974
+ // after ownership ended is the same error as writing then, with a quieter symptom.
975
+ get persistFailures() { assertOwnedAccounting?.(); return persistFailures; },
976
+ get corruptRecords() { assertOwnedAccounting?.(); return corruptRecords; },
977
+ get degraded() { assertOwnedAccounting?.(); return persistFailures > 0 || corruptRecords > 0; },
978
+ get policy() { assertOwnedAccounting?.(); return policy; },
811
979
 
812
980
  reserve(request: SpendReservationRequest): SpendReservationDecision {
981
+ assertOwnedAccounting?.();
813
982
  const tokens = sanitizeTokens(request.inputTokens) + sanitizeTokens(request.outputCeilingTokens);
814
983
  const at = request.at ?? now();
815
984
  const send = aliasFor("send", request.sendId);
@@ -834,7 +1003,9 @@ export function createSpendReservationLedger(options: {
834
1003
  // reservation booked on the scopes that would have passed. Reading state without
835
1004
  // creating it matters here -- a denied request must not leave a tracked scope behind.
836
1005
  for (const ref of refs) {
837
- const limit = limitFor(ref.scope);
1006
+ // A recorded send has no limit to fail: it already happened, and the point of booking
1007
+ // it is to let the total go OVER the ceiling so the next request can be refused.
1008
+ const limit = request.alreadySent === true ? undefined : limitFor(ref.scope);
838
1009
  if (limit === undefined) continue;
839
1010
  const state = scopes.get(scopeKey(ref.scope, ref.alias));
840
1011
  const projected = (state ? state.settled + state.reserved + state.unresolved : 0) + tokens;
@@ -853,7 +1024,7 @@ export function createSpendReservationLedger(options: {
853
1024
  // limit a failed write refuses the request rather than admitting one that a restart
854
1025
  // would forget -- which is exactly the disk-full and permission case durability is for.
855
1026
  const durable = append({ v: 1, kind: "reserve", send, targets: refs, tokens, at });
856
- if (!durable && enforced) {
1027
+ if (!durable && enforced && request.alreadySent !== true) {
857
1028
  return { reserved: false, denial: { reason: "reserve-not-durable", sendId: request.sendId } };
858
1029
  }
859
1030
  applyReserve(send, refs, tokens, at);
@@ -862,6 +1033,7 @@ export function createSpendReservationLedger(options: {
862
1033
  },
863
1034
 
864
1035
  markDispatched(sendId: string): boolean {
1036
+ assertOwnedAccounting?.();
865
1037
  const send = aliasFor("send", sendId);
866
1038
  const reservation = reservations.get(send);
867
1039
  if (!reservation || reservation.status !== "open") return false;
@@ -872,6 +1044,7 @@ export function createSpendReservationLedger(options: {
872
1044
  },
873
1045
 
874
1046
  abandon(sendId: string): boolean {
1047
+ assertOwnedAccounting?.();
875
1048
  const send = aliasFor("send", sendId);
876
1049
  const reservation = reservations.get(send);
877
1050
  // Only an UNDISPATCHED reservation may be released for free. Once bytes have left for
@@ -884,6 +1057,7 @@ export function createSpendReservationLedger(options: {
884
1057
  },
885
1058
 
886
1059
  settle(sendId: string, usage: SpendUsage): boolean {
1060
+ assertOwnedAccounting?.();
887
1061
  const send = aliasFor("send", sendId);
888
1062
  const reservation = reservations.get(send);
889
1063
  if (!reservation || !isLive(reservation.status)) return false;
@@ -895,6 +1069,7 @@ export function createSpendReservationLedger(options: {
895
1069
  },
896
1070
 
897
1071
  markLost(sendId: string): boolean {
1072
+ assertOwnedAccounting?.();
898
1073
  const send = aliasFor("send", sendId);
899
1074
  const reservation = reservations.get(send);
900
1075
  if (!reservation || !isLive(reservation.status)) return false;
@@ -905,10 +1080,14 @@ export function createSpendReservationLedger(options: {
905
1080
  },
906
1081
 
907
1082
  knows(sendId: string): boolean {
1083
+ assertOwnedAccounting?.();
908
1084
  return reservations.has(aliasFor("send", sendId));
909
1085
  },
910
1086
 
911
1087
  snapshot(scope: SpendScope, scopeId: string): ScopeSpendSnapshot | undefined {
1088
+ // Reading accounting from a handle whose ownership has ended is as wrong as writing it:
1089
+ // the figures describe a journal this process no longer owns.
1090
+ assertOwnedAccounting?.();
912
1091
  const state = scopes.get(scopeKey(scope, aliasFor(scope, scopeId)));
913
1092
  if (!state) return undefined;
914
1093
  return {
@@ -920,39 +1099,157 @@ export function createSpendReservationLedger(options: {
920
1099
  },
921
1100
 
922
1101
  exhausted(scope: SpendScope, scopeId: string): boolean {
1102
+ assertOwnedAccounting?.();
923
1103
  const state = scopes.get(scopeKey(scope, aliasFor(scope, scopeId)));
924
1104
  return state !== undefined && isExhausted(scope, state);
925
1105
  },
926
1106
 
927
1107
  prune(at: number = now()): void {
1108
+ assertOwnedAccounting?.();
928
1109
  // Removal requires BOTH inactive and not exhausted inside the window. An
929
1110
  // exhausted-but-idle scope that was dropped would be recreated fresh under the
930
1111
  // same id -- the exact laundering the ceiling exists to stop.
931
1112
  evictSends(at, false);
932
1113
  evictScopes(at, false);
933
1114
  },
1115
+
1116
+ reconfigure(next: SpendReservationPolicy): void {
1117
+ assertOwnedAccounting?.();
1118
+ policy = next;
1119
+ },
934
1120
  };
935
1121
  }
936
1122
 
937
1123
  let sharedLedger: SpendReservationLedger | undefined;
1124
+ /**
1125
+ * The operator policy in effect. Held beside the ledger rather than inside it because the
1126
+ * ledger is built lazily: a configured ceiling has to be remembered from startup until the
1127
+ * first request that actually reserves, and an install that configures nothing must still
1128
+ * open no journal.
1129
+ */
1130
+ let sharedPolicy: SpendReservationPolicy = DEFAULT_SPEND_RESERVATION_POLICY;
1131
+
1132
+ /** Whether any scope carries a ceiling -- that is, whether anything at all can be refused. */
1133
+ export function spendCeilingsConfigured(policy: SpendReservationPolicy = sharedPolicy): boolean {
1134
+ return policy.root.maxTokens !== undefined
1135
+ || policy.identity.maxTokens !== undefined
1136
+ || policy.pool.maxTokens !== undefined;
1137
+ }
1138
+
1139
+ /** The policy the process-wide ledger enforces right now. */
1140
+ export function sharedSpendPolicy(): SpendReservationPolicy {
1141
+ return sharedPolicy;
1142
+ }
1143
+
1144
+ const spendScopeLimitFromConfig = (scope: OcxSpendScopeConfig | undefined): SpendScopeLimit =>
1145
+ scope?.maxTokens !== undefined && Number.isFinite(scope.maxTokens) && scope.maxTokens > 0
1146
+ ? { maxTokens: Math.trunc(scope.maxTokens) }
1147
+ : {};
1148
+
1149
+ /**
1150
+ * The ledger policy an operator's `spend` section asks for.
1151
+ *
1152
+ * An absent section, an empty one, and one whose every ceiling is absent all produce the
1153
+ * unconfigured default: observe-only accounting that refuses nothing. That equivalence is the
1154
+ * load-bearing part. This ledger is on and journaling by default, so shipping a default
1155
+ * ceiling would start refusing real traffic on the first upgrade that ran this code, against
1156
+ * a number nobody chose. There is deliberately no default figure here at all.
1157
+ */
1158
+ export function spendPolicyFromConfig(spend: OcxSpendConfig | undefined): SpendReservationPolicy {
1159
+ const retentionDays = spend?.retentionDays;
1160
+ return {
1161
+ root: spendScopeLimitFromConfig(spend?.root),
1162
+ identity: spendScopeLimitFromConfig(spend?.identity),
1163
+ pool: spendScopeLimitFromConfig(spend?.pool),
1164
+ retentionMs: retentionDays !== undefined && Number.isFinite(retentionDays) && retentionDays > 0
1165
+ ? Math.trunc(retentionDays) * 24 * 60 * 60_000
1166
+ : DEFAULT_SPEND_RESERVATION_POLICY.retentionMs,
1167
+ };
1168
+ }
1169
+
1170
+ /**
1171
+ * Apply an operator policy to the process-wide ledger.
1172
+ *
1173
+ * Startup calls this with the loaded config, and a reload may call it again: the ledger keeps
1174
+ * every figure it has already accounted, so changing a ceiling changes what is refused from
1175
+ * here on and never what was spent. It does not CREATE the ledger -- an install that
1176
+ * configures no ceiling must not open a journal merely because the server started.
1177
+ */
1178
+ export function configureSharedSpendLedger(policy: SpendReservationPolicy): void {
1179
+ // Recording a policy value touches no journal, so it needs no ownership. Changing a ledger
1180
+ // that already exists does, because that ledger is a live view of an owned directory.
1181
+ if (sharedLedger) {
1182
+ assertSpendLedgerOwnerHeld();
1183
+ bindSpendLedgerOwnerHome();
1184
+ }
1185
+ sharedPolicy = policy;
1186
+ sharedLedger?.reconfigure(policy);
1187
+ }
938
1188
 
939
1189
  /**
940
1190
  * Process-wide ledger backed by the journal under OPENCODEX_HOME. Created lazily so
941
1191
  * importing the module -- or running a request path that never reserves -- touches no
942
- * disk.
1192
+ * disk. One directory at a time, not one directory for the life of the process: the singleton
1193
+ * is discarded when its ownership ends, so a later directory replays its own journal rather
1194
+ * than inheriting figures from the previous one.
943
1195
  */
944
1196
  export function sharedSpendLedger(): SpendReservationLedger {
1197
+ assertSpendLedgerOwnerHeld();
1198
+ bindSpendLedgerOwnerHome();
945
1199
  if (!sharedLedger) {
946
- const home = getConfigDir();
1200
+ // Minted by the owner module from the directory it actually owns, and carrying the exact
1201
+ // ownership they were minted under. Nothing here chooses a path or supplies its own guard.
1202
+ const journalStorage = mintSpendLedgerStorage(SPEND_LEDGER_JOURNAL_FILENAME);
1203
+ const saltStorage = mintSpendLedgerStorage(SPEND_LEDGER_SALT_FILENAME);
1204
+ const journalPath = spendLedgerStoragePath(journalStorage);
1205
+ const saltPath = spendLedgerStoragePath(saltStorage);
1206
+ const assertOwnedAccounting = (): void => {
1207
+ assertStorageOwned(journalStorage);
1208
+ if (ledgerEntryExists(journalPath)) assertSafeLedgerFile(journalPath);
1209
+ if (ledgerEntryExists(saltPath)) assertSafeLedgerFile(saltPath);
1210
+ };
947
1211
  sharedLedger = createSpendReservationLedger({
948
- journal: createFileSpendJournal(join(home, SPEND_LEDGER_JOURNAL_FILENAME)),
949
- salt: loadOrCreateSpendLedgerSalt(join(home, SPEND_LEDGER_SALT_FILENAME)),
1212
+ journal: createOwnedFileSpendJournal(journalStorage),
1213
+ salt: loadOrCreateSpendLedgerSalt(saltStorage),
1214
+ policy: sharedPolicy,
1215
+ assertOwnedAccounting,
950
1216
  });
951
1217
  }
952
1218
  return sharedLedger;
953
1219
  }
954
1220
 
955
- /** Test seam. Production never discards the ledger: that would reset a spent budget. */
1221
+ const MAX_DIAGNOSTIC_ERROR_COUNT = 1_000_000;
1222
+
1223
+ /** Scalar-only and side-effect-free: reading diagnostics never constructs or replays. */
1224
+ export function spendLedgerDiagnosticsSnapshot(): {
1225
+ readonly ownership: "held" | "unheld";
1226
+ readonly initialized: boolean;
1227
+ readonly configured: boolean;
1228
+ readonly degraded: boolean;
1229
+ readonly persistFailures: number;
1230
+ readonly corruptRecords: number;
1231
+ } {
1232
+ const ledger = sharedLedger;
1233
+ const bounded = (value: number): number => Math.min(MAX_DIAGNOSTIC_ERROR_COUNT, Math.max(0, value));
1234
+ return {
1235
+ ...spendLedgerOwnerSnapshot(),
1236
+ initialized: ledger !== undefined,
1237
+ configured: spendCeilingsConfigured(sharedPolicy),
1238
+ degraded: ledger?.degraded ?? false,
1239
+ persistFailures: bounded(ledger?.persistFailures ?? 0),
1240
+ corruptRecords: bounded(ledger?.corruptRecords ?? 0),
1241
+ };
1242
+ }
1243
+
1244
+ /**
1245
+ * Test seam for discarding the singleton outright.
1246
+ *
1247
+ * Production discards it too, but only with the ownership it belongs to, which is what
1248
+ * `onSpendLedgerOwnerReleased` does. Neither path resets a spent budget: the journal is the
1249
+ * durable record and the next construction replays it.
1250
+ */
956
1251
  export function resetSharedSpendLedgerForTest(): void {
957
1252
  sharedLedger = undefined;
1253
+ sharedPolicy = DEFAULT_SPEND_RESERVATION_POLICY;
1254
+ resetSpendLedgerOwnerBindingForTest();
958
1255
  }
@@ -16,7 +16,7 @@ import { decodeWindowsTextBytes } from "./windows-text";
16
16
  const INTERNET_SETTINGS_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings";
17
17
 
18
18
  export type WindowsSystemProxyResult =
19
- | { kind: "proxy"; url: string }
19
+ | { kind: "proxy"; httpUrl?: string; httpsUrl?: string }
20
20
  | { kind: "disabled" }
21
21
  | { kind: "socks-only" }
22
22
  | { kind: "unsupported" }
@@ -63,35 +63,40 @@ export function readWindowsProxyRegistry(): WindowsProxyRegistryValues | null {
63
63
 
64
64
  /**
65
65
  * `ProxyServer` is either a bare `host:port` (applies to every scheme) or a semicolon list of
66
- * `scheme=host:port` entries. Prefer the https entry, then http; a SOCKS-only value cannot be
67
- * mirrored into HTTP_PROXY/HTTPS_PROXY.
66
+ * `scheme=host:port` entries. Bare values apply to both HTTP and HTTPS destinations; per-scheme
67
+ * values retain their WinINET scope. A SOCKS-only value cannot be mirrored into HTTP(S)_PROXY.
68
68
  */
69
- export function parseWindowsProxyServer(value: string): { kind: "proxy"; url: string } | { kind: "socks-only" } | { kind: "disabled" } {
69
+ export function parseWindowsProxyServer(value: string): Extract<WindowsSystemProxyResult, { kind: "proxy" | "socks-only" | "disabled" }> {
70
70
  const trimmed = value.trim();
71
71
  if (!trimmed) return { kind: "disabled" };
72
- if (!trimmed.includes("=")) return normalize(trimmed);
72
+ if (!trimmed.includes("=")) {
73
+ const url = normalize(trimmed);
74
+ return url ? { kind: "proxy", httpUrl: url, httpsUrl: url } : { kind: "disabled" };
75
+ }
73
76
  const entries = new Map<string, string>();
74
77
  for (const part of trimmed.split(";")) {
75
78
  const eq = part.indexOf("=");
76
79
  if (eq <= 0) continue;
77
80
  entries.set(part.slice(0, eq).trim().toLowerCase(), part.slice(eq + 1).trim());
78
81
  }
79
- const candidate = entries.get("https") || entries.get("http");
80
- if (candidate) return normalize(candidate);
82
+ const httpUrl = normalize(entries.get("http") ?? "");
83
+ const httpsUrl = normalize(entries.get("https") ?? "");
84
+ if (httpUrl || httpsUrl) return { kind: "proxy", ...(httpUrl && { httpUrl }), ...(httpsUrl && { httpsUrl }) };
81
85
  if (entries.has("socks")) return { kind: "socks-only" };
82
86
  return { kind: "disabled" };
83
87
  }
84
88
 
85
- function normalize(hostPort: string): { kind: "proxy"; url: string } | { kind: "disabled" } {
89
+ function normalize(hostPort: string): string | undefined {
90
+ if (!hostPort) return undefined;
86
91
  const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(hostPort) ? hostPort : `http://${hostPort}`;
87
92
  try {
88
93
  const url = new URL(withScheme);
89
- if (!url.hostname || (url.protocol !== "http:" && url.protocol !== "https:")) return { kind: "disabled" };
94
+ if (!url.hostname || (url.protocol !== "http:" && url.protocol !== "https:")) return undefined;
90
95
  // Keep userinfo: a credentialed proxy is valid in HTTP_PROXY. Only the log strips it.
91
96
  const auth = url.username ? `${url.username}${url.password ? `:${url.password}` : ""}@` : "";
92
- return { kind: "proxy", url: `${url.protocol}//${auth}${url.host}` };
97
+ return `${url.protocol}//${auth}${url.host}`;
93
98
  } catch {
94
- return { kind: "disabled" };
99
+ return undefined;
95
100
  }
96
101
  }
97
102