@bitkyc08/opencodex 2.57.0 → 2.59.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 (241) hide show
  1. package/README.md +28 -10
  2. package/gui/dist/assets/index-C5IebErG.js +136 -0
  3. package/gui/dist/assets/{index-C5-RdDmD.css → index-OESInAjC.css} +1 -1
  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 +2 -2
  8. package/src/adapters/base.ts +11 -1
  9. package/src/adapters/codebuddy/scaffold-guard.ts +5 -4
  10. package/src/adapters/command-code.ts +13 -4
  11. package/src/adapters/cursor/catalog.ts +11 -0
  12. package/src/adapters/cursor/cursor-errors.ts +15 -0
  13. package/src/adapters/cursor/discovery.ts +65 -1
  14. package/src/adapters/cursor/effort-map.ts +16 -2
  15. package/src/adapters/cursor/envelope-echo.ts +55 -2
  16. package/src/adapters/cursor/live-transport.ts +5 -1
  17. package/src/adapters/cursor/message-mapper.ts +3 -2
  18. package/src/adapters/cursor/protobuf-events.ts +110 -11
  19. package/src/adapters/cursor/protobuf-request.ts +27 -6
  20. package/src/adapters/cursor/request-builder.ts +14 -3
  21. package/src/adapters/cursor/text-toolcall.ts +230 -0
  22. package/src/adapters/cursor/thread-continuity.ts +141 -0
  23. package/src/adapters/cursor/tool-guidance.ts +5 -4
  24. package/src/adapters/cursor/types.ts +5 -0
  25. package/src/adapters/cursor.ts +97 -6
  26. package/src/adapters/devin/cloud-direct/chat.ts +11 -2
  27. package/src/adapters/devin/cloud-direct/index.ts +7 -0
  28. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +103 -0
  29. package/src/adapters/devin.ts +75 -13
  30. package/src/adapters/google-antigravity-wire.ts +29 -2
  31. package/src/adapters/google-http.ts +45 -13
  32. package/src/adapters/google.ts +23 -4
  33. package/src/adapters/mimo-free.ts +32 -17
  34. package/src/adapters/ollama-native.ts +42 -8
  35. package/src/adapters/openai-chat/response-events.ts +61 -0
  36. package/src/adapters/openai-chat.ts +5 -10
  37. package/src/adapters/openai-responses/passthrough.ts +40 -5
  38. package/src/adapters/openai-responses/request-strips.ts +43 -0
  39. package/src/adapters/openai-responses/tool-output-recovery.ts +75 -0
  40. package/src/adapters/openai-responses/tool-schema.ts +19 -7
  41. package/src/adapters/physical-send.ts +50 -0
  42. package/src/adapters/responses-tool-schema.ts +76 -46
  43. package/src/adapters/run-turn-queue.ts +17 -4
  44. package/src/bridge/response-json.ts +2 -2
  45. package/src/bridge/sse.ts +166 -25
  46. package/src/claude/context-windows.ts +22 -0
  47. package/src/claude/outbound.ts +46 -5
  48. package/src/cli/account-api.ts +4 -3
  49. package/src/cli/account-extended.ts +22 -2
  50. package/src/cli/account-orca-import.ts +63 -0
  51. package/src/cli/account.ts +32 -4
  52. package/src/cli/capabilities.ts +40 -0
  53. package/src/cli/claude.ts +29 -1
  54. package/src/cli/codex-cli-update.ts +97 -2
  55. package/src/cli/config-command.ts +35 -18
  56. package/src/cli/dispatch.ts +71 -4
  57. package/src/cli/doctor.ts +197 -2
  58. package/src/cli/help.ts +4 -1
  59. package/src/cli/index.ts +132 -22
  60. package/src/cli/models-runtime.ts +33 -4
  61. package/src/cli/registry.ts +11 -1
  62. package/src/cli/runtime-api.ts +44 -0
  63. package/src/cli/start-args.ts +94 -0
  64. package/src/cli/system-command.ts +72 -1
  65. package/src/cli/uninstall-client-state.ts +12 -0
  66. package/src/client/machine-api.ts +4 -3
  67. package/src/client/machine-listener.ts +14 -1
  68. package/src/clients/config-export/constants.ts +2 -3
  69. package/src/clients/config-export.ts +5 -5
  70. package/src/codex/account-store.ts +81 -5
  71. package/src/codex/auth-api/pool-quota-probe.ts +14 -3
  72. package/src/codex/auth-api/routes.ts +17 -2
  73. package/src/codex/auth-context.ts +58 -20
  74. package/src/codex/catalog/build-entries.ts +25 -4
  75. package/src/codex/catalog/derive-entry.ts +8 -1
  76. package/src/codex/catalog/effort.ts +10 -6
  77. package/src/codex/catalog/gather-capture.ts +1 -0
  78. package/src/codex/catalog/model-hints.ts +37 -5
  79. package/src/codex/catalog/parsing.ts +83 -5
  80. package/src/codex/catalog/reserve-warn.ts +96 -0
  81. package/src/codex/catalog/retained-sync.ts +19 -0
  82. package/src/codex/catalog/routed-gather.ts +42 -3
  83. package/src/codex/cli-installation-identity.ts +210 -0
  84. package/src/codex/cli-installation-targets.ts +158 -0
  85. package/src/codex/convergence.ts +5 -0
  86. package/src/codex/desktop-switches.ts +145 -0
  87. package/src/codex/history-job.ts +5 -1
  88. package/src/codex/history-provider.ts +37 -5
  89. package/src/codex/history-state-open.ts +105 -0
  90. package/src/codex/history-worker.ts +14 -1
  91. package/src/codex/inject/config-toml.ts +44 -2
  92. package/src/codex/inject/remove.ts +145 -7
  93. package/src/codex/inject/restore.ts +204 -32
  94. package/src/codex/inject.ts +6 -9
  95. package/src/codex/lineage.ts +83 -32
  96. package/src/codex/loopback-target.ts +40 -0
  97. package/src/codex/main-account-hard-lock.ts +2 -1
  98. package/src/codex/main-account.ts +10 -3
  99. package/src/codex/main-device-reauth.ts +17 -9
  100. package/src/codex/model-entitlements.ts +60 -1
  101. package/src/codex/native-profile-startup.ts +64 -20
  102. package/src/codex/observed-model-denials.ts +137 -0
  103. package/src/codex/orca-auth-source.ts +94 -0
  104. package/src/codex/orca-import.ts +219 -0
  105. package/src/codex/prompt-text-probe.ts +282 -12
  106. package/src/codex/quota-401-recovery.ts +12 -0
  107. package/src/codex/quota-types.ts +65 -0
  108. package/src/codex/quota.ts +24 -19
  109. package/src/codex/routing/cooldown-math.ts +8 -47
  110. package/src/codex/routing/pin-drain.ts +57 -0
  111. package/src/codex/routing.ts +13 -15
  112. package/src/codex/subagent-model-fallback.ts +94 -0
  113. package/src/codex/windows-installation-files.ts +224 -0
  114. package/src/combos/failover.ts +122 -5
  115. package/src/config/atomic-write.ts +83 -8
  116. package/src/config/diagnostics.ts +21 -0
  117. package/src/config/load-degrade.ts +15 -0
  118. package/src/config/pending-teardown.ts +8 -0
  119. package/src/config/process-state.ts +36 -3
  120. package/src/config/provider-relative-send-path.ts +16 -0
  121. package/src/config/proxy-env.ts +23 -5
  122. package/src/config/schema/config-schema.ts +23 -0
  123. package/src/config/schema/leaf-validators.ts +65 -17
  124. package/src/generated/compatibility-version.json +337 -201
  125. package/src/generated/model-metadata.ts +1 -1
  126. package/src/lib/bounded-body.ts +4 -2
  127. package/src/lib/bounded-subprocess.ts +62 -10
  128. package/src/lib/destination-policy.ts +48 -6
  129. package/src/lib/errors.ts +3 -15
  130. package/src/lib/local-destinations.ts +32 -5
  131. package/src/lib/provider-outbound.ts +3 -3
  132. package/src/lib/proxy-env.ts +70 -3
  133. package/src/lib/request-execution-budget.ts +11 -3
  134. package/src/lib/response-body-inactivity.ts +193 -0
  135. package/src/lib/retry-delay.ts +69 -0
  136. package/src/lib/socks5-fetch.ts +631 -0
  137. package/src/lib/spend-reservation-ledger.ts +115 -9
  138. package/src/lib/windows-secret-acl.ts +151 -15
  139. package/src/lib/windows-user-principal.ts +5 -1
  140. package/src/lib/workflow-budget.ts +145 -8
  141. package/src/oauth/account-quota-rank.ts +72 -15
  142. package/src/oauth/generic-account-failover.ts +40 -27
  143. package/src/oauth/orcarouter.ts +15 -2
  144. package/src/oauth/store.ts +8 -0
  145. package/src/providers/codex-capacity.ts +9 -0
  146. package/src/providers/derive.ts +6 -0
  147. package/src/providers/devin-provider-merge-migration.ts +33 -12
  148. package/src/providers/free-directory.ts +20 -2
  149. package/src/providers/key-failover.ts +261 -7
  150. package/src/providers/model-discovery.ts +19 -7
  151. package/src/providers/model-rename-migration.ts +1 -0
  152. package/src/providers/openai-sidecar.ts +4 -0
  153. package/src/providers/opencode-go-transport.ts +14 -5
  154. package/src/providers/quota/report-cache.ts +3 -0
  155. package/src/providers/registry/entries-core.ts +11 -0
  156. package/src/providers/registry/entries-extended.ts +146 -28
  157. package/src/providers/registry/model-seeds.ts +136 -29
  158. package/src/providers/registry/types.ts +9 -0
  159. package/src/responses/apply-patch-envelope.ts +44 -11
  160. package/src/responses/bridge-search-replay-cache.ts +152 -0
  161. package/src/responses/code-mode-helper-compat.ts +26 -16
  162. package/src/responses/custom-tool-compat.ts +1 -1
  163. package/src/responses/hosted-tool-policy.ts +85 -2
  164. package/src/responses/schema.ts +9 -2
  165. package/src/responses/spill-store.ts +17 -0
  166. package/src/responses/state/body-policy.ts +25 -0
  167. package/src/responses/state/spill-queue.ts +8 -6
  168. package/src/responses/state.ts +3 -22
  169. package/src/router.ts +4 -0
  170. package/src/server/auth-cors.ts +27 -0
  171. package/src/server/chat-completions.ts +9 -4
  172. package/src/server/chat-native-sse.ts +26 -9
  173. package/src/server/chat-native.ts +10 -4
  174. package/src/server/claude-messages.ts +24 -2
  175. package/src/server/gui-static.ts +36 -2
  176. package/src/server/inbound-body-admission.ts +187 -0
  177. package/src/server/index/websocket-handler.ts +48 -1
  178. package/src/server/index.ts +15 -19
  179. package/src/server/management/api-access.ts +3 -4
  180. package/src/server/management/config-routes.ts +57 -10
  181. package/src/server/management/provider-capability-config.ts +35 -7
  182. package/src/server/management/provider-routes.ts +70 -18
  183. package/src/server/models-capabilities.ts +24 -3
  184. package/src/server/proxy-liveness.ts +97 -2
  185. package/src/server/relay.ts +17 -24
  186. package/src/server/request-log.ts +25 -1
  187. package/src/server/responses/adapter-continuation.ts +71 -27
  188. package/src/server/responses/adapter-delivery.ts +39 -8
  189. package/src/server/responses/adapter-dispatch.ts +52 -24
  190. package/src/server/responses/codex-ws-exchange.ts +65 -4
  191. package/src/server/responses/combo-stream-preflight.ts +68 -5
  192. package/src/server/responses/compact.ts +60 -11
  193. package/src/server/responses/core-codex-account.ts +83 -22
  194. package/src/server/responses/core-combo.ts +26 -0
  195. package/src/server/responses/core-normalize.ts +12 -5
  196. package/src/server/responses/core-options.ts +3 -0
  197. package/src/server/responses/fetch-helpers.ts +72 -3
  198. package/src/server/responses/native-injection-protocol.ts +42 -0
  199. package/src/server/responses/native-injection-replay.ts +105 -0
  200. package/src/server/responses/native-injection.ts +242 -0
  201. package/src/server/responses/native-response-control.ts +56 -0
  202. package/src/server/responses/native-response-json.ts +14 -0
  203. package/src/server/responses/native-response-output.ts +37 -0
  204. package/src/server/responses/native-steering-log.ts +44 -0
  205. package/src/server/responses/native-steering-policy.ts +49 -0
  206. package/src/server/responses/native-steering-replay.ts +126 -0
  207. package/src/server/responses/native-steering-settings.ts +76 -0
  208. package/src/server/responses/native-steering.ts +400 -0
  209. package/src/server/responses/native-tool-results.ts +130 -0
  210. package/src/server/responses/passthrough-delivery.ts +21 -1
  211. package/src/server/responses/passthrough-dispatch.ts +146 -49
  212. package/src/server/responses/passthrough-execution.ts +11 -1
  213. package/src/server/responses/request-prepare.ts +70 -0
  214. package/src/server/responses/request-send-budget.ts +84 -7
  215. package/src/server/responses/request-sidecar-auth.ts +16 -8
  216. package/src/server/responses/request-spend.ts +38 -9
  217. package/src/server/responses/request-transport.ts +13 -10
  218. package/src/server/responses/run-turn-execution.ts +20 -5
  219. package/src/server/responses/sidecar-execution.ts +2 -0
  220. package/src/server/responses/ws-upstream.ts +23 -2
  221. package/src/server/responses-custom-tool-repair.ts +2 -2
  222. package/src/server/sse-frame-buffer.ts +12 -10
  223. package/src/server/sse-payload-rewrite.ts +36 -9
  224. package/src/server/stop-teardown.ts +8 -1
  225. package/src/server/system-env-shell.ts +5 -1
  226. package/src/server/system-env.ts +7 -1
  227. package/src/server/workflow-refusal.ts +56 -2
  228. package/src/server/ws-bridge.ts +16 -1
  229. package/src/service/cli.ts +29 -7
  230. package/src/service/guards.ts +10 -0
  231. package/src/service/health.ts +43 -0
  232. package/src/service/state.ts +7 -2
  233. package/src/types/accounts.ts +4 -0
  234. package/src/types/config.ts +104 -3
  235. package/src/types/provider.ts +32 -0
  236. package/src/types/request.ts +7 -1
  237. package/src/types/wire.ts +9 -1
  238. package/src/usage/expected-prices.ts +28 -0
  239. package/src/usage/log.ts +87 -4
  240. package/src/web-search/passthrough-bridge.ts +39 -5
  241. package/gui/dist/assets/index-Cz7CLdif.js +0 -128
@@ -60,6 +60,9 @@ import { dirname, join } from "node:path";
60
60
  // Definition-site import, not the ../config barrel -- same reasoning as
61
61
  // src/quota/reset-seen-store.ts: the barrel pulls ~154 modules into a hot path.
62
62
  import { getConfigDir } from "../config/paths";
63
+ // Type-only, so it is erased before this module has a runtime import graph at all. The
64
+ // config SHAPE is what this file needs; the config loader is what the note above keeps out.
65
+ import type { OcxSpendConfig, OcxSpendScopeConfig } from "../types/config";
63
66
  import { assertNotRealHomeUnderTest } from "./test-home-guard";
64
67
  // Windows chmod does not remove inherited ACEs; this is the repository's icacls path.
65
68
  import { hardenSecretPath } from "./windows-secret-acl";
@@ -147,6 +150,24 @@ export interface SpendReservationRequest {
147
150
  /** Enforceable output ceiling -- max_output_tokens or the model's documented cap. */
148
151
  readonly outputCeilingTokens: number;
149
152
  readonly at?: number;
153
+ /**
154
+ * This send has ALREADY left for upstream and is being recorded rather than admitted.
155
+ *
156
+ * Some transports report their physical sends after the fact -- the passthrough ladder
157
+ * reports through `onSendsConsumed`, and an adapter's inner retries are counted when they
158
+ * finish. For those, a ceiling cannot refuse anything: the tokens are spent. Refusing to
159
+ * BOOK them is the worse answer, and it is not hypothetical -- it is a fixpoint. The send
160
+ * that would cross the ceiling gets dropped from the total, the total stays just under the
161
+ * limit forever, the scope never reads as exhausted, and the ceiling never fires again for
162
+ * any request. So a recorded send skips the limit check and takes the scope over its
163
+ * ceiling, which is what makes the NEXT request refusable.
164
+ *
165
+ * It skips the durability refusal for the same reason: a journal that could not be written
166
+ * is a reason to report degradation, never a reason to forget spend that really happened.
167
+ * Identity, capacity and journal-integrity denials still apply -- those say the ledger
168
+ * cannot account for the send at all, which no flag here can change.
169
+ */
170
+ readonly alreadySent?: boolean;
150
171
  }
151
172
 
152
173
  /**
@@ -468,6 +489,20 @@ export interface SpendReservationLedger {
468
489
  prune(now?: number): void;
469
490
  /** Whether this send id is already known, and therefore refused. */
470
491
  knows(sendId: string): boolean;
492
+ /**
493
+ * Replace the live policy.
494
+ *
495
+ * Every figure already accounted survives: raising, lowering or clearing a ceiling changes
496
+ * what is REFUSED from here on and never what was spent. Rebuilding the ledger instead
497
+ * would replay the journal into a second set of maps while the first still holds this
498
+ * process's open reservations, and the two would then disagree about what is in flight.
499
+ */
500
+ reconfigure(next: SpendReservationPolicy): void;
501
+ /**
502
+ * The policy in force. A live read, not a copy: a caller that formats a refusal has to name
503
+ * the ceiling this ledger would enforce on the NEXT request, not the one it was built with.
504
+ */
505
+ readonly policy: SpendReservationPolicy;
471
506
  /** Journal writes that failed; a nonzero count means durability is degraded. */
472
507
  readonly persistFailures: number;
473
508
  /**
@@ -495,13 +530,17 @@ export function createSpendReservationLedger(options: {
495
530
  */
496
531
  readonly salt?: string;
497
532
  } = {}): SpendReservationLedger {
498
- const policy = options.policy ?? DEFAULT_SPEND_RESERVATION_POLICY;
533
+ // Mutable because the ceilings are operator configuration, and configuration is reloadable.
534
+ // The three bounds below are read through functions for the same reason: a value captured
535
+ // at construction would answer for the policy this ledger was BUILT with, and an operator
536
+ // who raised a bound would keep the old one until the process restarted.
537
+ let policy = options.policy ?? DEFAULT_SPEND_RESERVATION_POLICY;
499
538
  const journal = options.journal;
500
539
  const now = options.now ?? (() => Date.now());
501
540
  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;
541
+ const maxTrackedScopes = (): number => policy.maxTrackedScopes ?? DEFAULT_MAX_TRACKED_SCOPES;
542
+ const maxTrackedSends = (): number => policy.maxTrackedSends ?? DEFAULT_MAX_TRACKED_SENDS;
543
+ const compactAfterRecords = (): number => policy.compactAfterRecords ?? DEFAULT_COMPACT_AFTER_RECORDS;
505
544
  const scopes = new Map<string, ScopeState>();
506
545
  const reservations = new Map<string, Reservation>();
507
546
  let persistFailures = 0;
@@ -753,7 +792,7 @@ export function createSpendReservationLedger(options: {
753
792
  */
754
793
  const compact = (at: number): void => {
755
794
  const rewrite = journal?.rewrite;
756
- if (!journal || !rewrite || recordsOnDisk < compactAfterRecords) return;
795
+ if (!journal || !rewrite || recordsOnDisk < compactAfterRecords()) return;
757
796
  const checkpoint: JournalRecord = {
758
797
  v: 1,
759
798
  kind: "checkpoint",
@@ -791,12 +830,12 @@ export function createSpendReservationLedger(options: {
791
830
  const makeRoom = (refs: readonly ScopeRef[], at: number): SpendDenial | undefined => {
792
831
  evictSends(at, false);
793
832
  evictScopes(at, false);
794
- while (reservations.size >= maxTrackedSends) {
833
+ while (reservations.size >= maxTrackedSends()) {
795
834
  if (evictSends(at, true) === 0) return { reason: "tracking-capacity-exhausted" };
796
835
  }
797
836
  let fresh = 0;
798
837
  for (const ref of refs) if (!scopes.has(scopeKey(ref.scope, ref.alias))) fresh += 1;
799
- while (scopes.size + fresh > maxTrackedScopes) {
838
+ while (scopes.size + fresh > maxTrackedScopes()) {
800
839
  if (evictScopes(at, true) === 0) {
801
840
  return { reason: "tracking-capacity-exhausted", scope: refs[0]?.scope };
802
841
  }
@@ -808,6 +847,7 @@ export function createSpendReservationLedger(options: {
808
847
  get persistFailures() { return persistFailures; },
809
848
  get corruptRecords() { return corruptRecords; },
810
849
  get degraded() { return persistFailures > 0 || corruptRecords > 0; },
850
+ get policy() { return policy; },
811
851
 
812
852
  reserve(request: SpendReservationRequest): SpendReservationDecision {
813
853
  const tokens = sanitizeTokens(request.inputTokens) + sanitizeTokens(request.outputCeilingTokens);
@@ -834,7 +874,9 @@ export function createSpendReservationLedger(options: {
834
874
  // reservation booked on the scopes that would have passed. Reading state without
835
875
  // creating it matters here -- a denied request must not leave a tracked scope behind.
836
876
  for (const ref of refs) {
837
- const limit = limitFor(ref.scope);
877
+ // A recorded send has no limit to fail: it already happened, and the point of booking
878
+ // it is to let the total go OVER the ceiling so the next request can be refused.
879
+ const limit = request.alreadySent === true ? undefined : limitFor(ref.scope);
838
880
  if (limit === undefined) continue;
839
881
  const state = scopes.get(scopeKey(ref.scope, ref.alias));
840
882
  const projected = (state ? state.settled + state.reserved + state.unresolved : 0) + tokens;
@@ -853,7 +895,7 @@ export function createSpendReservationLedger(options: {
853
895
  // limit a failed write refuses the request rather than admitting one that a restart
854
896
  // would forget -- which is exactly the disk-full and permission case durability is for.
855
897
  const durable = append({ v: 1, kind: "reserve", send, targets: refs, tokens, at });
856
- if (!durable && enforced) {
898
+ if (!durable && enforced && request.alreadySent !== true) {
857
899
  return { reserved: false, denial: { reason: "reserve-not-durable", sendId: request.sendId } };
858
900
  }
859
901
  applyReserve(send, refs, tokens, at);
@@ -931,10 +973,72 @@ export function createSpendReservationLedger(options: {
931
973
  evictSends(at, false);
932
974
  evictScopes(at, false);
933
975
  },
976
+
977
+ reconfigure(next: SpendReservationPolicy): void {
978
+ policy = next;
979
+ },
934
980
  };
935
981
  }
936
982
 
937
983
  let sharedLedger: SpendReservationLedger | undefined;
984
+ /**
985
+ * The operator policy in effect. Held beside the ledger rather than inside it because the
986
+ * ledger is built lazily: a configured ceiling has to be remembered from startup until the
987
+ * first request that actually reserves, and an install that configures nothing must still
988
+ * open no journal.
989
+ */
990
+ let sharedPolicy: SpendReservationPolicy = DEFAULT_SPEND_RESERVATION_POLICY;
991
+
992
+ /** Whether any scope carries a ceiling -- that is, whether anything at all can be refused. */
993
+ export function spendCeilingsConfigured(policy: SpendReservationPolicy = sharedPolicy): boolean {
994
+ return policy.root.maxTokens !== undefined
995
+ || policy.identity.maxTokens !== undefined
996
+ || policy.pool.maxTokens !== undefined;
997
+ }
998
+
999
+ /** The policy the process-wide ledger enforces right now. */
1000
+ export function sharedSpendPolicy(): SpendReservationPolicy {
1001
+ return sharedPolicy;
1002
+ }
1003
+
1004
+ const spendScopeLimitFromConfig = (scope: OcxSpendScopeConfig | undefined): SpendScopeLimit =>
1005
+ scope?.maxTokens !== undefined && Number.isFinite(scope.maxTokens) && scope.maxTokens > 0
1006
+ ? { maxTokens: Math.trunc(scope.maxTokens) }
1007
+ : {};
1008
+
1009
+ /**
1010
+ * The ledger policy an operator's `spend` section asks for.
1011
+ *
1012
+ * An absent section, an empty one, and one whose every ceiling is absent all produce the
1013
+ * unconfigured default: observe-only accounting that refuses nothing. That equivalence is the
1014
+ * load-bearing part. This ledger is on and journaling by default, so shipping a default
1015
+ * ceiling would start refusing real traffic on the first upgrade that ran this code, against
1016
+ * a number nobody chose. There is deliberately no default figure here at all.
1017
+ */
1018
+ export function spendPolicyFromConfig(spend: OcxSpendConfig | undefined): SpendReservationPolicy {
1019
+ const retentionDays = spend?.retentionDays;
1020
+ return {
1021
+ root: spendScopeLimitFromConfig(spend?.root),
1022
+ identity: spendScopeLimitFromConfig(spend?.identity),
1023
+ pool: spendScopeLimitFromConfig(spend?.pool),
1024
+ retentionMs: retentionDays !== undefined && Number.isFinite(retentionDays) && retentionDays > 0
1025
+ ? Math.trunc(retentionDays) * 24 * 60 * 60_000
1026
+ : DEFAULT_SPEND_RESERVATION_POLICY.retentionMs,
1027
+ };
1028
+ }
1029
+
1030
+ /**
1031
+ * Apply an operator policy to the process-wide ledger.
1032
+ *
1033
+ * Startup calls this with the loaded config, and a reload may call it again: the ledger keeps
1034
+ * every figure it has already accounted, so changing a ceiling changes what is refused from
1035
+ * here on and never what was spent. It does not CREATE the ledger -- an install that
1036
+ * configures no ceiling must not open a journal merely because the server started.
1037
+ */
1038
+ export function configureSharedSpendLedger(policy: SpendReservationPolicy): void {
1039
+ sharedPolicy = policy;
1040
+ sharedLedger?.reconfigure(policy);
1041
+ }
938
1042
 
939
1043
  /**
940
1044
  * Process-wide ledger backed by the journal under OPENCODEX_HOME. Created lazily so
@@ -947,6 +1051,7 @@ export function sharedSpendLedger(): SpendReservationLedger {
947
1051
  sharedLedger = createSpendReservationLedger({
948
1052
  journal: createFileSpendJournal(join(home, SPEND_LEDGER_JOURNAL_FILENAME)),
949
1053
  salt: loadOrCreateSpendLedgerSalt(join(home, SPEND_LEDGER_SALT_FILENAME)),
1054
+ policy: sharedPolicy,
950
1055
  });
951
1056
  }
952
1057
  return sharedLedger;
@@ -955,4 +1060,5 @@ export function sharedSpendLedger(): SpendReservationLedger {
955
1060
  /** Test seam. Production never discards the ledger: that would reset a spent budget. */
956
1061
  export function resetSharedSpendLedgerForTest(): void {
957
1062
  sharedLedger = undefined;
1063
+ sharedPolicy = DEFAULT_SPEND_RESERVATION_POLICY;
958
1064
  }
@@ -30,8 +30,13 @@
30
30
  */
31
31
 
32
32
  import { existsSync, statSync } from "node:fs";
33
+ import { isAbsolute, relative, resolve } from "node:path";
33
34
  import { env, platform } from "node:process";
34
- import { waitForSubprocessExit } from "./bounded-subprocess";
35
+ import {
36
+ SUBPROCESS_KILL_GRACE_MS,
37
+ waitForSubprocessExit,
38
+ type SubprocessDeadlineScheduler,
39
+ } from "./bounded-subprocess";
35
40
  import { resolveTrustedWindowsIcaclsExe } from "./windows-elevation";
36
41
  import {
37
42
  cachedCurrentWindowsIdentity,
@@ -48,17 +53,32 @@ const hardenedPaths = new Map<string, HardenedIdentity>();
48
53
  * that attempt was consumed. Ordinary callers never consume it.
49
54
  */
50
55
  const timedOutPaths = new Map<string, boolean>();
56
+ /** Compatibility slack before the outer belt releases a caller whose killed child has not reaped. */
57
+ const ASYNC_ICACLS_BELT_MARGIN_MS = 250;
58
+ const pendingAsyncIcaclsReaps = new Map<string, Set<Promise<void>>>();
59
+
60
+ const scheduleAsyncIcaclsBelt: SubprocessDeadlineScheduler = (callback, milliseconds) => {
61
+ const timer = setTimeout(callback, milliseconds);
62
+ return () => clearTimeout(timer);
63
+ };
64
+ let asyncIcaclsBeltScheduler: SubprocessDeadlineScheduler = scheduleAsyncIcaclsBelt;
51
65
 
52
66
  /**
53
- * The memo value: `object:freshness` for a file a harden was actually attributed
54
- * to.
67
+ * The memo value: the `object` plus `freshness` of a file a harden was actually
68
+ * attributed to.
55
69
  *
56
70
  * There is deliberately no null member. An observation that cannot be read is
57
71
  * not stored at all — the entry is deleted — because a "recorded as unverifiable"
58
72
  * value was dead code the moment attribution became a before/after comparison,
59
73
  * and a branch nothing can reach is a branch no test can defend.
74
+ *
75
+ * It is the observation itself rather than a joined string so that the two
76
+ * questions stay separately askable after storage. `reattributeHardenedSecretPath`
77
+ * has to compare the object while deliberately ignoring the freshness, and
78
+ * recovering one half out of `dev:ino:ctimeNs` by counting colons would make that
79
+ * comparison depend on a format nothing declares.
60
80
  */
61
- type HardenedIdentity = string;
81
+ type HardenedIdentity = PathObservation;
62
82
 
63
83
  /**
64
84
  * What a stat can tell us about WHICH OBJECT is at a path.
@@ -128,8 +148,8 @@ function observe(targetPath: string): PathObservation | null {
128
148
  }
129
149
  }
130
150
 
131
- function memoValue(seen: PathObservation): HardenedIdentity {
132
- return `${seen.object}:${seen.freshness}`;
151
+ function sameObservation(a: PathObservation, b: PathObservation): boolean {
152
+ return a.object === b.object && a.freshness === b.freshness;
133
153
  }
134
154
 
135
155
  /**
@@ -155,7 +175,7 @@ function memoSatisfied(cache: Map<string, HardenedIdentity>, targetPath: string)
155
175
  // without any ACL work. That needs exact-identity ABA to bite — outside the
156
176
  // proof bound this unit claims — but "the consequence is out of scope" is not a
157
177
  // reason to keep an entry we have just proven does not describe what is there.
158
- if (current === null || memoValue(current) !== remembered) {
178
+ if (current === null || !sameObservation(current, remembered)) {
159
179
  cache.delete(targetPath);
160
180
  return false;
161
181
  }
@@ -203,7 +223,7 @@ function recordHarden(
203
223
  cache.delete(targetPath);
204
224
  return false;
205
225
  }
206
- cache.set(targetPath, memoValue(after));
226
+ cache.set(targetPath, after);
207
227
  return true;
208
228
  }
209
229
 
@@ -339,7 +359,7 @@ function defaultIcaclsRunner(args: string[], timeoutMs: number): IcaclsResult {
339
359
  /**
340
360
  * Async icacls runner (#612): yields the event loop while waiting for the child.
341
361
  * Async Subprocess has no exitedDueToTimeout, so the shared settlement helper
342
- * classifies the deadline and abandons a child that does not settle after kill.
362
+ * classifies the deadline and keeps waiting for a killed child to actually exit.
343
363
  */
344
364
  async function defaultAsyncIcaclsRunner(args: string[], timeoutMs: number): Promise<IcaclsResult> {
345
365
  const proc = trySpawnIcacls(args);
@@ -359,21 +379,78 @@ async function defaultAsyncIcaclsRunner(args: string[], timeoutMs: number): Prom
359
379
  function awaitAsyncIcaclsRunner(args: string[], timeoutMs: number): Promise<IcaclsResult> {
360
380
  return new Promise(resolve => {
361
381
  let settled = false;
362
- let timer: ReturnType<typeof setTimeout> | undefined;
382
+ let cancelBelt: (() => void) | undefined;
363
383
  const finish = (result: IcaclsResult): void => {
364
384
  if (settled) return;
365
385
  settled = true;
366
- if (timer !== undefined) clearTimeout(timer);
386
+ cancelBelt?.();
367
387
  resolve(result);
368
388
  };
369
- timer = setTimeout(
370
- () => finish({ success: false, exitCode: null, timedOut: true, stdout: "" }),
371
- Math.max(1, timeoutMs),
389
+ const runner = asyncIcaclsRunner(args, timeoutMs).then(
390
+ result => { finish(result); },
391
+ () => { finish(spawnFailedResult()); },
392
+ );
393
+ // The belt has to outlast the runner it is guarding, or it is not a belt -- it is the
394
+ // deadline. The runner may now legitimately outlive it while a killed child is reaped. The
395
+ // caller is still released, but the target is registered so removal can wait for the distinct
396
+ // handle-release question instead of treating flight settlement as proof that the child died.
397
+ cancelBelt = asyncIcaclsBeltScheduler(
398
+ () => {
399
+ const targetPath = args[0];
400
+ if (targetPath) registerPendingAsyncIcaclsReap(targetPath, runner);
401
+ finish({ success: false, exitCode: null, timedOut: true, stdout: "" });
402
+ },
403
+ Math.max(1, timeoutMs) + SUBPROCESS_KILL_GRACE_MS + ASYNC_ICACLS_BELT_MARGIN_MS,
372
404
  );
373
- void asyncIcaclsRunner(args, timeoutMs).then(finish, () => finish(spawnFailedResult()));
374
405
  });
375
406
  }
376
407
 
408
+ function registerPendingAsyncIcaclsReap(targetPath: string, reap: Promise<void>): void {
409
+ let pending = pendingAsyncIcaclsReaps.get(targetPath);
410
+ if (!pending) {
411
+ pending = new Set();
412
+ pendingAsyncIcaclsReaps.set(targetPath, pending);
413
+ }
414
+ pending.add(reap);
415
+ void reap.finally(() => {
416
+ pending!.delete(reap);
417
+ if (pending!.size === 0) pendingAsyncIcaclsReaps.delete(targetPath);
418
+ });
419
+ }
420
+
421
+ function pathIsAtOrBelow(targetPath: string, rootPath: string): boolean {
422
+ const relativePath = relative(resolve(rootPath), resolve(targetPath));
423
+ return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
424
+ }
425
+
426
+ /** True while an async icacls runner still owns this exact path after its caller's belt fired. */
427
+ export function windowsSecretAclReapPendingForPath(targetPath: string): boolean {
428
+ return (pendingAsyncIcaclsReaps.get(targetPath)?.size ?? 0) > 0;
429
+ }
430
+
431
+ /** Non-blocking removal guard for callers that must refuse rather than wait for a stuck child. */
432
+ export function windowsSecretAclReapPendingAtOrBelow(rootPath: string): boolean {
433
+ return [...pendingAsyncIcaclsReaps.keys()]
434
+ .some(targetPath => pathIsAtOrBelow(targetPath, rootPath));
435
+ }
436
+
437
+ /**
438
+ * Removal barrier for a file or tree that may still be held by a timed-out icacls child.
439
+ *
440
+ * This wait is deliberately separate from ordinary startup and shutdown: a genuinely stuck child
441
+ * must not defeat the caller-facing belt. Code that chooses to remove the target has the stricter
442
+ * contract and must not proceed until every registered runner at or below it has actually reaped.
443
+ */
444
+ export async function flushWindowsSecretAclReapsBeforeRemoval(rootPath: string): Promise<void> {
445
+ while (true) {
446
+ const pending = [...pendingAsyncIcaclsReaps]
447
+ .filter(([targetPath]) => pathIsAtOrBelow(targetPath, rootPath))
448
+ .flatMap(([, reaps]) => [...reaps]);
449
+ if (pending.length === 0) return;
450
+ await Promise.all(pending);
451
+ }
452
+ }
453
+
377
454
  let icaclsRunner: IcaclsRunner = defaultIcaclsRunner;
378
455
  let asyncIcaclsRunner: AsyncIcaclsRunner = defaultAsyncIcaclsRunner;
379
456
  let platformOverride: string | null = null;
@@ -389,6 +466,13 @@ export function setAsyncIcaclsRunnerForTests(runner: AsyncIcaclsRunner | null):
389
466
  asyncIcaclsRunner = runner ?? defaultAsyncIcaclsRunner;
390
467
  }
391
468
 
469
+ /** Test seam: fire the outer caller-facing belt without sleeping. */
470
+ export function setAsyncIcaclsBeltSchedulerForTests(
471
+ scheduler: SubprocessDeadlineScheduler | null,
472
+ ): void {
473
+ asyncIcaclsBeltScheduler = scheduler ?? scheduleAsyncIcaclsBelt;
474
+ }
475
+
392
476
  /**
393
477
  * Test seam: force the platform gate (e.g. "win32") so CI on POSIX reaches the runner.
394
478
  *
@@ -423,6 +507,58 @@ export function forgetHardenedSecretPath(targetPath: string): void {
423
507
  hardenedPaths.delete(targetPath);
424
508
  }
425
509
 
510
+ /**
511
+ * Re-attribute an existing file memo to the SAME object after the caller wrote
512
+ * content to it through a descriptor whose identity it verified.
513
+ *
514
+ * This exists because `freshness` is `ctimeNs`, and on Windows libuv reports
515
+ * `st_ctim` from the NTFS ChangeTime, which moves when file DATA is written. An
516
+ * atomic writer therefore invalidated its own memo between the harden that
517
+ * protects the empty temp and the harden before the rename, and paid a second
518
+ * full `/grant:r` + `/inheritance:r` + `/remove:g` sequence to reapply the ACL
519
+ * that was already on the file. Every secret write on Windows paid it twice.
520
+ *
521
+ * Only the freshness moves, and only for an unchanged object: a different object
522
+ * retires the entry instead. A caller must have proven, immediately beforehand,
523
+ * that `targetPath` resolves to the object its own descriptor refers to.
524
+ *
525
+ * The cost of this is worth stating exactly, because `PathObservation` documents
526
+ * that freshness also moves when PERMISSIONS change, and this call cannot tell
527
+ * the two apart. So a DACL change landing between the harden and this call is
528
+ * absorbed instead of forcing a re-harden. That window is the caller's own
529
+ * content write; every permission change after this call still moves ctime
530
+ * again and still misses the memo, so the detection this memo provides is
531
+ * relocated, not removed.
532
+ *
533
+ * What makes the absorbed window acceptable is who can be in it. Once the harden
534
+ * has run, the DACL is an explicit owner-only ACE with inheritance removed, so
535
+ * no other principal can open the file for `WRITE_DAC` at all. The one principal
536
+ * who can still rewrite that DACL is one holding a handle opened BEFORE the
537
+ * harden, and Windows keeps the access granted to an open handle: that principal
538
+ * can equally rewrite the DACL after any later harden, and after the rename, on
539
+ * the same object. A second mutation pass never bounded that capability — it
540
+ * stripped an ACE the holder could immediately re-add — so declining to repeat
541
+ * it removes no guarantee anyone had.
542
+ *
543
+ * Refusal is cheap and safe in either direction: an unmoved memo simply means the
544
+ * caller's next harden runs in full.
545
+ *
546
+ * Returns whether the memo now describes what is at the path.
547
+ */
548
+ export function reattributeHardenedSecretPath(targetPath: string): boolean {
549
+ const remembered = hardenedPaths.get(targetPath);
550
+ if (remembered === undefined) return false;
551
+ const current = observe(targetPath);
552
+ // Unreadable, or a different object: this is exactly the case the memo must
553
+ // not cover. Retire it so the next harden is a real one.
554
+ if (current === null || current.object !== remembered.object) {
555
+ hardenedPaths.delete(targetPath);
556
+ return false;
557
+ }
558
+ hardenedPaths.set(targetPath, current);
559
+ return true;
560
+ }
561
+
426
562
  /**
427
563
  * Ephemeral-path lifecycle release: clears the success memo AND any timeout
428
564
  * memo keyed by THIS TEMP path in both namespaces. Call only after the temp is
@@ -160,7 +160,11 @@ async function defaultAsyncWindowsPrincipalRunner(
160
160
  stderr: "ignore",
161
161
  windowsHide: true,
162
162
  });
163
- const { exitCode, timedOut } = await waitForSubprocessExit(proc, timeoutMs);
163
+ // No kill grace. The grace exists so a dying child releases a path someone is about to
164
+ // remove; this lookup holds no such path, and it runs during `ocx start`, where the composed
165
+ // acceptance cases already measure real startups at up to 38.8s against a bounded watchdog.
166
+ // Paying two extra seconds per timed-out resolution there buys nothing and costs margin.
167
+ const { exitCode, timedOut } = await waitForSubprocessExit(proc, timeoutMs, 0);
164
168
  // `.bytes()` rather than `.text()`, for the same reason as the sync runner above.
165
169
  const stdout: string | Uint8Array = !timedOut && proc.stdout
166
170
  ? await new Response(proc.stdout).bytes().catch(() => new Uint8Array())