@bitkyc08/opencodex 2.59.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 (194) hide show
  1. package/gui/dist/assets/index-BTuCbqQd.css +1 -0
  2. package/gui/dist/assets/index-DoBVdPHP.js +134 -0
  3. package/gui/dist/index.html +2 -2
  4. package/package.json +4 -1
  5. package/src/adapters/anthropic-image-codec.ts +16 -2
  6. package/src/adapters/anthropic-image-normalize.ts +49 -2
  7. package/src/adapters/anthropic.ts +4 -1
  8. package/src/adapters/base.ts +23 -0
  9. package/src/adapters/coding-agent/turn.ts +22 -2
  10. package/src/adapters/command-code.ts +50 -3
  11. package/src/adapters/cursor/checkpoint-store.ts +3 -0
  12. package/src/adapters/cursor/discovery.ts +11 -8
  13. package/src/adapters/cursor/live-transport.ts +26 -9
  14. package/src/adapters/cursor/request-builder.ts +7 -1
  15. package/src/adapters/cursor/transport.ts +19 -0
  16. package/src/adapters/cursor.ts +3 -1
  17. package/src/adapters/devin/cloud-direct/chat.ts +3 -1
  18. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +42 -5
  19. package/src/adapters/devin.ts +50 -13
  20. package/src/adapters/google-antigravity-replay.ts +1 -1
  21. package/src/adapters/google-antigravity-wire.ts +29 -5
  22. package/src/adapters/google-http.ts +49 -10
  23. package/src/adapters/google-tool-schema.ts +595 -31
  24. package/src/adapters/google-wire-compiler.ts +93 -10
  25. package/src/adapters/google-wire-shape.ts +461 -0
  26. package/src/adapters/google.ts +37 -6
  27. package/src/adapters/openai-chat-images.ts +3 -1
  28. package/src/adapters/openai-chat.ts +5 -1
  29. package/src/adapters/openai-responses/image-gen.ts +8 -6
  30. package/src/adapters/openai-responses/passthrough.ts +15 -3
  31. package/src/adapters/openai-responses/reasoning.ts +7 -0
  32. package/src/adapters/opencode-go-additional-tools.ts +12 -2
  33. package/src/bridge/sse.ts +3 -151
  34. package/src/cli/account-extended.ts +4 -4
  35. package/src/cli/dispatch.ts +3 -3
  36. package/src/cli/doctor.ts +28 -9
  37. package/src/cli/hub.ts +3 -2
  38. package/src/cli/index.ts +7 -2
  39. package/src/cli/opencode.ts +2 -2
  40. package/src/cli/provider.ts +13 -1
  41. package/src/client/machine-api.ts +2 -2
  42. package/src/client/machine-listener.ts +2 -2
  43. package/src/client/runtime.ts +26 -2
  44. package/src/codex/account-store.ts +65 -0
  45. package/src/codex/auth-api/account-list.ts +19 -11
  46. package/src/codex/auth-api/pool-quota-probe.ts +30 -7
  47. package/src/codex/catalog/gather-capture.ts +21 -2
  48. package/src/codex/catalog/model-hints.ts +29 -28
  49. package/src/codex/catalog/parsing.ts +7 -0
  50. package/src/codex/catalog/provider-models.ts +19 -2
  51. package/src/codex/catalog/retained-sync.ts +22 -26
  52. package/src/codex/catalog/routed-gather.ts +19 -0
  53. package/src/codex/context-compat.ts +5 -2
  54. package/src/codex/desired-state.ts +4 -1
  55. package/src/codex/history-job.ts +6 -6
  56. package/src/codex/history-provider.ts +20 -166
  57. package/src/codex/history-rollout-read.ts +174 -0
  58. package/src/codex/internal/catalog-writer.ts +33 -1
  59. package/src/codex/model-cache.ts +47 -0
  60. package/src/codex/model-entitlements.ts +29 -10
  61. package/src/codex/observed-model-denials.ts +101 -8
  62. package/src/codex/prompt-text-probe.ts +9 -6
  63. package/src/codex/routing.ts +7 -1
  64. package/src/codex/shim.ts +1 -1
  65. package/src/codex/subagent-model-fallback.ts +22 -4
  66. package/src/combos/failover.ts +3 -0
  67. package/src/config/admitted-identity.ts +222 -0
  68. package/src/config/diagnostics.ts +22 -1
  69. package/src/config/feature-flags.ts +5 -0
  70. package/src/config/load-degrade.ts +18 -0
  71. package/src/config/proxy-env.ts +8 -2
  72. package/src/config/schema/compaction-triggers.ts +11 -0
  73. package/src/config/schema/config-schema.ts +4 -0
  74. package/src/config/schema/leaf-validators.ts +12 -0
  75. package/src/config.ts +2 -2
  76. package/src/generated/compatibility-version.json +249 -169
  77. package/src/grok/reset-coupons.ts +38 -19
  78. package/src/images/loop.ts +6 -1
  79. package/src/integrations/aside-profile-context.ts +37 -3
  80. package/src/integrations/aside-profile-journal.ts +68 -3
  81. package/src/integrations/aside-profiles.ts +128 -3
  82. package/src/integrations/mutation-plan.ts +815 -0
  83. package/src/integrations/writer.ts +85 -99
  84. package/src/lab/live/transport.ts +4 -0
  85. package/src/lab/live/types.ts +5 -0
  86. package/src/lab/subject/behavior-fingerprint.ts +1 -1
  87. package/src/lib/admin-secrets.ts +9 -1
  88. package/src/lib/debug-log-buffer.ts +6 -1
  89. package/src/lib/debug.ts +23 -0
  90. package/src/lib/errors.ts +79 -0
  91. package/src/lib/http-response-semantics.ts +57 -0
  92. package/src/lib/lab-live-pinned-sender.ts +26 -12
  93. package/src/lib/pinned-http.ts +142 -2
  94. package/src/lib/plain-data.ts +103 -0
  95. package/src/lib/process-control.ts +13 -5
  96. package/src/lib/provider-outbound.ts +50 -2
  97. package/src/lib/socks5-fetch.ts +136 -26
  98. package/src/lib/spend-ledger-owner.ts +364 -0
  99. package/src/lib/spend-reservation-ledger.ts +218 -27
  100. package/src/lib/windows-system-proxy.ts +16 -11
  101. package/src/oauth/callback-server.ts +4 -3
  102. package/src/oauth/generic-account-failover.ts +1 -0
  103. package/src/oauth/health.ts +12 -1
  104. package/src/oauth/index.ts +3 -107
  105. package/src/oauth/login-flow-state.ts +127 -0
  106. package/src/providers/derive.ts +34 -17
  107. package/src/providers/devin-cli-authmode-migration.ts +14 -10
  108. package/src/providers/key-failover.ts +66 -18
  109. package/src/providers/model-rename-migration.ts +55 -1
  110. package/src/providers/model-rename-startup.ts +7 -5
  111. package/src/providers/openai-virtual-models.ts +42 -2
  112. package/src/providers/quota/antigravity.ts +22 -2
  113. package/src/providers/quota/vendor-probes-key.ts +1 -1
  114. package/src/providers/registry/entries-core.ts +39 -17
  115. package/src/providers/registry/entries-extended.ts +35 -1
  116. package/src/providers/registry/model-ids.ts +168 -0
  117. package/src/providers/registry/model-seeds.ts +9 -0
  118. package/src/providers/registry/types.ts +2 -0
  119. package/src/providers/resolved-model-policy-merge.ts +167 -0
  120. package/src/providers/resolved-model-policy.ts +406 -0
  121. package/src/providers/stale-vision-classification-migration.ts +137 -0
  122. package/src/responses/apply-patch-envelope.ts +0 -12
  123. package/src/responses/freeform-wrapper-scan.ts +279 -0
  124. package/src/responses/legacy-dotted-tool-name-repair.ts +134 -0
  125. package/src/responses/progressive-freeform-input.ts +130 -0
  126. package/src/responses/reasoning-envelope.ts +30 -0
  127. package/src/responses/state.ts +5 -12
  128. package/src/responses/tool-name-aliases.ts +15 -1
  129. package/src/router.ts +91 -115
  130. package/src/routing/compatibility/behavior.ts +9 -0
  131. package/src/routing/compatibility/subject.ts +16 -1
  132. package/src/server/adapter-resolve.ts +9 -0
  133. package/src/server/auth-cors.ts +3 -0
  134. package/src/server/chat-completions.ts +5 -2
  135. package/src/server/claude-messages.ts +6 -3
  136. package/src/server/effort-row.ts +11 -3
  137. package/src/server/grok-responses-control-frame.ts +160 -1
  138. package/src/server/index/serve-options.ts +86 -29
  139. package/src/server/index/spend-ledger-lifecycle.ts +66 -0
  140. package/src/server/index/websocket-handler.ts +6 -1
  141. package/src/server/index.ts +16 -13
  142. package/src/server/management/aside-profile-routes.ts +266 -7
  143. package/src/server/management/config-routes.ts +18 -2
  144. package/src/server/management/context.ts +3 -0
  145. package/src/server/management/integration-routes.ts +287 -5
  146. package/src/server/management/metrics-routes.ts +20 -0
  147. package/src/server/management/model-rows.ts +224 -12
  148. package/src/server/management/route-registry.ts +14 -0
  149. package/src/server/management/shared.ts +10 -3
  150. package/src/server/management/system-restart.ts +7 -2
  151. package/src/server/management/system-routes.ts +2 -0
  152. package/src/server/management/usage-aggregate-cache.ts +4 -0
  153. package/src/server/management-api.ts +2 -0
  154. package/src/server/management-auth.ts +15 -1
  155. package/src/server/readiness.ts +29 -10
  156. package/src/server/relay-eager.ts +24 -2
  157. package/src/server/relay.ts +119 -10
  158. package/src/server/request-log.ts +55 -2
  159. package/src/server/request-metrics.ts +236 -0
  160. package/src/server/responses/adapter-continuation.ts +3 -3
  161. package/src/server/responses/adapter-dispatch.ts +11 -6
  162. package/src/server/responses/compact.ts +32 -10
  163. package/src/server/responses/compaction-routing.ts +111 -0
  164. package/src/server/responses/core-codex-account.ts +8 -3
  165. package/src/server/responses/core-combo.ts +7 -7
  166. package/src/server/responses/core-normalize.ts +6 -12
  167. package/src/server/responses/core-opaque-recovery.ts +1 -0
  168. package/src/server/responses/core-options.ts +4 -0
  169. package/src/server/responses/encrypted-payload.ts +20 -2
  170. package/src/server/responses/passthrough-delivery.ts +31 -14
  171. package/src/server/responses/passthrough-dispatch.ts +36 -9
  172. package/src/server/responses/policy-fallback.ts +5 -13
  173. package/src/server/responses/request-prepare.ts +67 -17
  174. package/src/server/responses/request-send-budget.ts +5 -1
  175. package/src/server/responses/request-sidecar-auth.ts +1 -1
  176. package/src/server/responses/request-transport.ts +2 -2
  177. package/src/server/responses/run-turn-execution.ts +25 -3
  178. package/src/server/responses/sidecar-execution.ts +17 -2
  179. package/src/server/responses/ws-upstream.ts +14 -27
  180. package/src/server/responses-custom-tool-repair.ts +27 -54
  181. package/src/server/responses-undeclared-tool-guard.ts +31 -1
  182. package/src/server/sse-payload-rewrite.ts +1 -1
  183. package/src/tray/windows-tray.ps1 +155 -3
  184. package/src/types/config.ts +11 -3
  185. package/src/types/provider.ts +18 -0
  186. package/src/types/request.ts +2 -0
  187. package/src/types/tools.ts +14 -0
  188. package/src/types.ts +1 -0
  189. package/src/vision/eligibility.ts +88 -9
  190. package/src/vision/plan.ts +34 -10
  191. package/src/web-search/executor.ts +41 -2
  192. package/src/web-search/loop.ts +6 -1
  193. package/gui/dist/assets/index-C5IebErG.js +0 -136
  194. package/gui/dist/assets/index-OESInAjC.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,7 +53,7 @@
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
@@ -66,6 +65,12 @@ import type { OcxSpendConfig, OcxSpendScopeConfig } from "../types/config";
66
65
  import { assertNotRealHomeUnderTest } from "./test-home-guard";
67
66
  // Windows chmod does not remove inherited ACEs; this is the repository's icacls path.
68
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; });
69
74
 
70
75
  export const SPEND_LEDGER_JOURNAL_FILENAME = "spend-ledger.jsonl";
71
76
  /**
@@ -391,7 +396,70 @@ function hardenLedgerFile(path: string, options: { readonly force?: boolean } =
391
396
  } catch { /* best-effort: a non-owner cannot chmod */ }
392
397
  }
393
398
 
394
- 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);
395
463
  const ensureDir = (): string => {
396
464
  const dir = dirname(path);
397
465
  // The guard runs before any mutation so a rejected write leaves nothing behind.
@@ -401,26 +469,66 @@ export function createFileSpendJournal(path: string): SpendJournal {
401
469
  };
402
470
  return {
403
471
  read(): string[] {
404
- if (!existsSync(path)) return [];
472
+ assertStorageOwned(storage);
473
+ if (!ledgerEntryExists(path)) return [];
474
+ assertSafeLedgerFile(path);
405
475
  // Replay is once per process and is the moment a journal inherited from an older build
406
476
  // or a restored backup first passes through here.
407
477
  hardenLedgerFile(path, { force: true });
408
478
  return readFileSync(path, "utf8").split("\n").filter((line) => line.length > 0);
409
479
  },
410
480
  append(line: string): void {
481
+ assertStorageOwned(storage);
411
482
  ensureDir();
412
- const created = !existsSync(path);
483
+ const created = !ledgerEntryExists(path);
484
+ if (!created) assertSafeLedgerFile(path);
413
485
  appendFileSync(path, line + "\n", { encoding: "utf8", mode: 0o600 });
486
+ assertSafeLedgerFile(path);
414
487
  hardenLedgerFile(path, { force: created });
415
488
  },
416
489
  rewrite(lines: string[]): void {
490
+ assertStorageOwned(storage);
417
491
  ensureDir();
418
492
  // Same directory, so the rename is atomic on the same filesystem: a crash mid-compaction
419
493
  // leaves either the old journal or the new one, never a half-written ledger.
420
- const temp = `${path}.compact-${process.pid}`;
421
- writeFileSync(temp, lines.map((line) => line + "\n").join(""), { encoding: "utf8", mode: 0o600 });
422
- hardenLedgerFile(temp, { force: true });
423
- 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);
424
532
  hardenLedgerFile(path, { force: true });
425
533
  },
426
534
  };
@@ -433,17 +541,26 @@ export function createFileSpendJournal(path: string): SpendJournal {
433
541
  * recorded spend, which would hand every scope a fresh allowance -- so it is a file, not a
434
542
  * per-process value.
435
543
  */
436
- export function loadOrCreateSpendLedgerSalt(path: string): string {
437
- 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);
438
549
  hardenLedgerFile(path, { force: true });
439
550
  const existing = readFileSync(path, "utf8").trim();
440
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
+ );
441
556
  }
442
557
  const dir = dirname(path);
558
+ assertStorageOwned(storage);
443
559
  assertNotRealHomeUnderTest(dir);
444
560
  mkdirSync(dir, { recursive: true, mode: 0o700 });
445
561
  const salt = randomBytes(32).toString("hex");
446
- writeFileSync(path, salt + "\n", { encoding: "utf8", mode: 0o600 });
562
+ writeFileSync(path, salt + "\n", { encoding: "utf8", mode: 0o600, flag: "wx" });
563
+ assertSafeLedgerFile(path);
447
564
  hardenLedgerFile(path, { force: true });
448
565
  return salt;
449
566
  }
@@ -529,6 +646,12 @@ export function createSpendReservationLedger(options: {
529
646
  * no file anyone could correlate.
530
647
  */
531
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;
532
655
  } = {}): SpendReservationLedger {
533
656
  // Mutable because the ceilings are operator configuration, and configuration is reloadable.
534
657
  // The three bounds below are read through functions for the same reason: a value captured
@@ -538,6 +661,7 @@ export function createSpendReservationLedger(options: {
538
661
  const journal = options.journal;
539
662
  const now = options.now ?? (() => Date.now());
540
663
  const salt = options.salt ?? "";
664
+ const assertOwnedAccounting = options.assertOwnedAccounting;
541
665
  const maxTrackedScopes = (): number => policy.maxTrackedScopes ?? DEFAULT_MAX_TRACKED_SCOPES;
542
666
  const maxTrackedSends = (): number => policy.maxTrackedSends ?? DEFAULT_MAX_TRACKED_SENDS;
543
667
  const compactAfterRecords = (): number => policy.compactAfterRecords ?? DEFAULT_COMPACT_AFTER_RECORDS;
@@ -592,7 +716,8 @@ export function createSpendReservationLedger(options: {
592
716
  journal.append(JSON.stringify(record));
593
717
  recordsOnDisk += 1;
594
718
  return true;
595
- } catch {
719
+ } catch (error) {
720
+ if (error instanceof SpendLedgerOwnerError) throw error;
596
721
  // In-memory state still bounds this process; the counter is how a caller learns the
597
722
  // restart guarantee degraded instead of discovering it after the fact.
598
723
  persistFailures += 1;
@@ -819,7 +944,8 @@ export function createSpendReservationLedger(options: {
819
944
  try {
820
945
  rewrite.call(journal, [JSON.stringify(checkpoint)]);
821
946
  recordsOnDisk = 1;
822
- } catch {
947
+ } catch (error) {
948
+ if (error instanceof SpendLedgerOwnerError) throw error;
823
949
  // Compaction is maintenance, not accounting: a failed rewrite leaves the previous
824
950
  // journal intact and every figure in it still replayable.
825
951
  persistFailures += 1;
@@ -844,12 +970,15 @@ export function createSpendReservationLedger(options: {
844
970
  };
845
971
 
846
972
  return {
847
- get persistFailures() { return persistFailures; },
848
- get corruptRecords() { return corruptRecords; },
849
- get degraded() { return persistFailures > 0 || corruptRecords > 0; },
850
- get policy() { return policy; },
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; },
851
979
 
852
980
  reserve(request: SpendReservationRequest): SpendReservationDecision {
981
+ assertOwnedAccounting?.();
853
982
  const tokens = sanitizeTokens(request.inputTokens) + sanitizeTokens(request.outputCeilingTokens);
854
983
  const at = request.at ?? now();
855
984
  const send = aliasFor("send", request.sendId);
@@ -904,6 +1033,7 @@ export function createSpendReservationLedger(options: {
904
1033
  },
905
1034
 
906
1035
  markDispatched(sendId: string): boolean {
1036
+ assertOwnedAccounting?.();
907
1037
  const send = aliasFor("send", sendId);
908
1038
  const reservation = reservations.get(send);
909
1039
  if (!reservation || reservation.status !== "open") return false;
@@ -914,6 +1044,7 @@ export function createSpendReservationLedger(options: {
914
1044
  },
915
1045
 
916
1046
  abandon(sendId: string): boolean {
1047
+ assertOwnedAccounting?.();
917
1048
  const send = aliasFor("send", sendId);
918
1049
  const reservation = reservations.get(send);
919
1050
  // Only an UNDISPATCHED reservation may be released for free. Once bytes have left for
@@ -926,6 +1057,7 @@ export function createSpendReservationLedger(options: {
926
1057
  },
927
1058
 
928
1059
  settle(sendId: string, usage: SpendUsage): boolean {
1060
+ assertOwnedAccounting?.();
929
1061
  const send = aliasFor("send", sendId);
930
1062
  const reservation = reservations.get(send);
931
1063
  if (!reservation || !isLive(reservation.status)) return false;
@@ -937,6 +1069,7 @@ export function createSpendReservationLedger(options: {
937
1069
  },
938
1070
 
939
1071
  markLost(sendId: string): boolean {
1072
+ assertOwnedAccounting?.();
940
1073
  const send = aliasFor("send", sendId);
941
1074
  const reservation = reservations.get(send);
942
1075
  if (!reservation || !isLive(reservation.status)) return false;
@@ -947,10 +1080,14 @@ export function createSpendReservationLedger(options: {
947
1080
  },
948
1081
 
949
1082
  knows(sendId: string): boolean {
1083
+ assertOwnedAccounting?.();
950
1084
  return reservations.has(aliasFor("send", sendId));
951
1085
  },
952
1086
 
953
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?.();
954
1091
  const state = scopes.get(scopeKey(scope, aliasFor(scope, scopeId)));
955
1092
  if (!state) return undefined;
956
1093
  return {
@@ -962,11 +1099,13 @@ export function createSpendReservationLedger(options: {
962
1099
  },
963
1100
 
964
1101
  exhausted(scope: SpendScope, scopeId: string): boolean {
1102
+ assertOwnedAccounting?.();
965
1103
  const state = scopes.get(scopeKey(scope, aliasFor(scope, scopeId)));
966
1104
  return state !== undefined && isExhausted(scope, state);
967
1105
  },
968
1106
 
969
1107
  prune(at: number = now()): void {
1108
+ assertOwnedAccounting?.();
970
1109
  // Removal requires BOTH inactive and not exhausted inside the window. An
971
1110
  // exhausted-but-idle scope that was dropped would be recreated fresh under the
972
1111
  // same id -- the exact laundering the ceiling exists to stop.
@@ -975,6 +1114,7 @@ export function createSpendReservationLedger(options: {
975
1114
  },
976
1115
 
977
1116
  reconfigure(next: SpendReservationPolicy): void {
1117
+ assertOwnedAccounting?.();
978
1118
  policy = next;
979
1119
  },
980
1120
  };
@@ -1036,6 +1176,12 @@ export function spendPolicyFromConfig(spend: OcxSpendConfig | undefined): SpendR
1036
1176
  * configures no ceiling must not open a journal merely because the server started.
1037
1177
  */
1038
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
+ }
1039
1185
  sharedPolicy = policy;
1040
1186
  sharedLedger?.reconfigure(policy);
1041
1187
  }
@@ -1043,22 +1189,67 @@ export function configureSharedSpendLedger(policy: SpendReservationPolicy): void
1043
1189
  /**
1044
1190
  * Process-wide ledger backed by the journal under OPENCODEX_HOME. Created lazily so
1045
1191
  * importing the module -- or running a request path that never reserves -- touches no
1046
- * 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.
1047
1195
  */
1048
1196
  export function sharedSpendLedger(): SpendReservationLedger {
1197
+ assertSpendLedgerOwnerHeld();
1198
+ bindSpendLedgerOwnerHome();
1049
1199
  if (!sharedLedger) {
1050
- 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
+ };
1051
1211
  sharedLedger = createSpendReservationLedger({
1052
- journal: createFileSpendJournal(join(home, SPEND_LEDGER_JOURNAL_FILENAME)),
1053
- salt: loadOrCreateSpendLedgerSalt(join(home, SPEND_LEDGER_SALT_FILENAME)),
1212
+ journal: createOwnedFileSpendJournal(journalStorage),
1213
+ salt: loadOrCreateSpendLedgerSalt(saltStorage),
1054
1214
  policy: sharedPolicy,
1215
+ assertOwnedAccounting,
1055
1216
  });
1056
1217
  }
1057
1218
  return sharedLedger;
1058
1219
  }
1059
1220
 
1060
- /** 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
+ */
1061
1251
  export function resetSharedSpendLedgerForTest(): void {
1062
1252
  sharedLedger = undefined;
1063
1253
  sharedPolicy = DEFAULT_SPEND_RESERVATION_POLICY;
1254
+ resetSpendLedgerOwnerBindingForTest();
1064
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
 
@@ -252,9 +252,10 @@ export abstract class OAuthCallbackFlow {
252
252
  if (!parsed.code) return null;
253
253
  // Kind-aware state enforcement: url/query-shaped input is an authorization
254
254
  // RESPONSE and must carry a matching state — missing state is rejected, not
255
- // downgraded to raw. Only a syntactically raw code (same PKCE session) is
256
- // exempt, so the CLI/GUI paste fallback still works.
257
- if (parsed.kind !== "raw" && expectedState && parsed.state !== expectedState) return null;
255
+ // downgraded to raw. A raw paste with an explicit code#state suffix is
256
+ // state-bearing too. Only a syntactically raw code WITHOUT a state suffix
257
+ // (same PKCE session) is exempt, so the CLI/GUI paste fallback still works.
258
+ if ((parsed.kind !== "raw" || parsed.state !== undefined) && expectedState && parsed.state !== expectedState) return null;
258
259
  return { code: parsed.code, state: parsed.state ?? expectedState };
259
260
  })
260
261
  .catch((): CallbackResult | null => null),
@@ -234,6 +234,7 @@ function stableGenericRoster(providerName: string): string[] {
234
234
  * quota-less provider off its active account on the very first request.
235
235
  */
236
236
  function isOverAutoSwitchThreshold(providerName: string, accountId: string, threshold: number, requestedModelId?: string | null): boolean {
237
+ if (threshold <= 0) return false;
237
238
  const headroom = accountHeadroomPercent(providerName, accountId, requestedModelId);
238
239
  if (headroom === null) return false;
239
240
  return 100 - headroom >= threshold;
@@ -202,6 +202,7 @@ export function projectStoredOAuthAccountHealth(
202
202
  export function projectCodexAccountHealth(input: {
203
203
  accountId: string;
204
204
  needsReauth: boolean;
205
+ reauthReason?: "unauthorized" | "forbidden" | "refresh_failed";
205
206
  now?: number;
206
207
  }): OAuthAccountHealth {
207
208
  // One read serves every verdict below. Each lookup re-reads and re-hardens the whole store
@@ -230,6 +231,16 @@ export function projectCodexAccountHealth(input: {
230
231
 
231
232
  const needsReauth = input.needsReauth || validationAuthFailed || terminalGrantFailure;
232
233
 
234
+ // A stored verification failure carries its own cause. When the caller did not name a
235
+ // reason, surface the persisted one instead of flattening every verdict into
236
+ // `refresh_failed`.
237
+ const storedVerificationFailed = validationAuthFailed || terminalGrantFailure;
238
+ const storedFailureReason: "unauthorized" | "forbidden" | undefined = !storedVerificationFailed
239
+ ? undefined
240
+ : record?.lastCodexValidationError === "http_status:403" ? "forbidden"
241
+ : record?.lastCodexValidationError === "http_status:401" ? "unauthorized"
242
+ : undefined;
243
+
233
244
  // Deferred validation is only worth reporting while the credential itself is still viable. A
234
245
  // revoked grant needs a re-login, not a "Refresh quotas" click, so reauth is resolved first.
235
246
  if (!needsReauth && record?.codexValidationPending) {
@@ -239,7 +250,7 @@ export function projectCodexAccountHealth(input: {
239
250
  const snap = getCodexAccountHealthSnapshot(input.accountId, now);
240
251
  return projectOAuthAccountHealth({
241
252
  needsReauth,
242
- reauthReason: needsReauth ? "refresh_failed" : undefined,
253
+ reauthReason: needsReauth ? (input.reauthReason ?? storedFailureReason ?? "refresh_failed") : undefined,
243
254
  cooldownUntilMs: snap?.cooldownUntil,
244
255
  cooldownReason: cooldownReasonFromSource(snap?.cooldownSource),
245
256
  now,