@bitkyc08/opencodex 2.56.0 → 2.58.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 (206) hide show
  1. package/bin/ocx.mjs +10 -0
  2. package/gui/dist/assets/{index-D4zuyIxQ.js → index-BbrHOIY0.js} +21 -21
  3. package/gui/dist/assets/{index-BBOZWGB6.css → index-C5-RdDmD.css} +1 -1
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +4 -4
  6. package/src/adapters/codebuddy/adapter.ts +2 -1
  7. package/src/adapters/codebuddy/scaffold-guard.ts +249 -0
  8. package/src/adapters/command-code.ts +12 -3
  9. package/src/adapters/cursor/cursor-errors.ts +15 -0
  10. package/src/adapters/cursor/discovery.ts +65 -1
  11. package/src/adapters/cursor/envelope-echo.ts +8 -2
  12. package/src/adapters/cursor/live-transport.ts +5 -1
  13. package/src/adapters/cursor/protobuf-events.ts +110 -11
  14. package/src/adapters/cursor/protobuf-request.ts +19 -1
  15. package/src/adapters/cursor/text-toolcall.ts +230 -0
  16. package/src/adapters/cursor/thread-continuity.ts +67 -0
  17. package/src/adapters/cursor/types.ts +5 -0
  18. package/src/adapters/cursor.ts +55 -5
  19. package/src/adapters/google-http.ts +38 -13
  20. package/src/adapters/google.ts +7 -7
  21. package/src/adapters/kiro/payload.ts +17 -3
  22. package/src/adapters/kiro/reasoning.ts +70 -7
  23. package/src/adapters/kiro/stream.ts +8 -2
  24. package/src/adapters/kiro/wire.ts +2 -1
  25. package/src/adapters/kiro-events.ts +21 -13
  26. package/src/adapters/mimo-free.ts +32 -17
  27. package/src/adapters/ollama-native.ts +42 -8
  28. package/src/adapters/openai-chat/tool-name-registry.ts +166 -0
  29. package/src/adapters/openai-chat/tool-schema.ts +25 -7
  30. package/src/adapters/openai-chat.ts +8 -8
  31. package/src/adapters/openai-responses/passthrough.ts +62 -5
  32. package/src/adapters/openai-responses/request-strips.ts +43 -0
  33. package/src/adapters/physical-send.ts +50 -0
  34. package/src/bridge/errors.ts +26 -2
  35. package/src/bridge/response-json.ts +8 -2
  36. package/src/bridge/sse.ts +20 -2
  37. package/src/claude/desktop-profile.ts +66 -9
  38. package/src/claude/outbound.ts +32 -4
  39. package/src/cli/account-main.ts +1 -1
  40. package/src/cli/capabilities.ts +2 -2
  41. package/src/cli/combo.ts +10 -1
  42. package/src/cli/config-command.ts +35 -18
  43. package/src/cli/dispatch.ts +17 -4
  44. package/src/cli/index.ts +92 -7
  45. package/src/cli/registry.ts +2 -1
  46. package/src/cli/system-command.ts +74 -5
  47. package/src/cli/uninstall-client-state.ts +12 -0
  48. package/src/clients/config-export.ts +7 -3
  49. package/src/codex/account-label.ts +14 -3
  50. package/src/codex/account-store.ts +113 -26
  51. package/src/codex/account-usability.ts +21 -0
  52. package/src/codex/auth-api/login-flow.ts +14 -2
  53. package/src/codex/auth-api/reset-credit-service.ts +11 -2
  54. package/src/codex/auth-context.ts +199 -15
  55. package/src/codex/catalog/aggregation.ts +80 -1
  56. package/src/codex/catalog/model-visibility.ts +1 -0
  57. package/src/codex/catalog/remote.ts +30 -0
  58. package/src/codex/catalog/retained-sync.ts +9 -1
  59. package/src/codex/catalog/routed-gather.ts +38 -1
  60. package/src/codex/cli-install-provenance.ts +7 -1
  61. package/src/codex/convergence.ts +7 -2
  62. package/src/codex/desktop-app/types.ts +11 -2
  63. package/src/codex/desktop-app/windows.ts +5 -5
  64. package/src/codex/desktop-switches.ts +145 -0
  65. package/src/codex/history-job.ts +5 -1
  66. package/src/codex/history-provider.ts +33 -4
  67. package/src/codex/history-worker.ts +14 -1
  68. package/src/codex/inject/remove.ts +145 -7
  69. package/src/codex/inject/restore.ts +231 -32
  70. package/src/codex/inject.ts +12 -16
  71. package/src/codex/loopback-target.ts +9 -0
  72. package/src/codex/model-entitlements.ts +152 -15
  73. package/src/codex/native-profile-startup.ts +64 -20
  74. package/src/codex/pool-refresh-backoff.ts +12 -3
  75. package/src/codex/quota-rejection.ts +104 -15
  76. package/src/codex/routing/cache-affinity.ts +70 -0
  77. package/src/codex/routing/cooldown-math.ts +10 -0
  78. package/src/codex/routing/selection.ts +79 -2
  79. package/src/codex/routing/thread-affinity.ts +50 -2
  80. package/src/codex/routing/transient-hold-dispatch.ts +141 -0
  81. package/src/codex/routing.ts +29 -49
  82. package/src/codex/warmup.ts +1 -1
  83. package/src/combos/failover.ts +85 -0
  84. package/src/combos/request.ts +17 -10
  85. package/src/combos/types.ts +23 -2
  86. package/src/config/atomic-write.ts +83 -8
  87. package/src/config/pending-teardown.ts +31 -0
  88. package/src/config/schema/config-schema.ts +2 -0
  89. package/src/config/schema/leaf-validators.ts +1 -0
  90. package/src/generated/compatibility-version.json +272 -180
  91. package/src/images/loop.ts +1 -1
  92. package/src/lib/bounded-subprocess.ts +62 -10
  93. package/src/lib/errors.ts +17 -0
  94. package/src/lib/request-execution-budget.ts +147 -21
  95. package/src/lib/spend-reservation-ledger.ts +18 -0
  96. package/src/lib/state-store-registrations.ts +6 -2
  97. package/src/lib/test-home-guard.ts +85 -1
  98. package/src/lib/upstream-retry.ts +77 -10
  99. package/src/lib/windows-elevation.ts +76 -14
  100. package/src/lib/windows-secret-acl.ts +151 -15
  101. package/src/lib/windows-user-principal.ts +5 -1
  102. package/src/oauth/index.ts +2 -2
  103. package/src/oauth/key-providers.ts +2 -2
  104. package/src/providers/derive.ts +6 -0
  105. package/src/providers/kiro-models.ts +4 -3
  106. package/src/providers/label.ts +19 -1
  107. package/src/providers/model-discovery.ts +35 -7
  108. package/src/providers/registry/entries-core.ts +18 -0
  109. package/src/providers/registry/entries-extended.ts +59 -28
  110. package/src/providers/registry/model-seeds.ts +71 -17
  111. package/src/providers/registry/types.ts +9 -0
  112. package/src/responses/reasoning-envelope.ts +6 -3
  113. package/src/responses/spill-store.ts +17 -0
  114. package/src/responses/state/body-policy.ts +25 -0
  115. package/src/responses/state/spill-queue.ts +8 -6
  116. package/src/responses/state.ts +3 -22
  117. package/src/router.ts +4 -0
  118. package/src/routing/identity-domains.ts +21 -14
  119. package/src/routing/probe-lease.ts +103 -1
  120. package/src/server/auth-cors.ts +1 -0
  121. package/src/server/chat-completions.ts +3 -1
  122. package/src/server/chat-native.ts +37 -9
  123. package/src/server/index/live-sideband.ts +37 -1
  124. package/src/server/index/websocket-handler.ts +54 -3
  125. package/src/server/index.ts +5 -5
  126. package/src/server/inspection-tee.ts +107 -0
  127. package/src/server/live.ts +46 -1
  128. package/src/server/management/combo-routes.ts +10 -1
  129. package/src/server/management/config-routes.ts +27 -5
  130. package/src/server/models-capabilities.ts +24 -3
  131. package/src/server/relay-eager.ts +2 -0
  132. package/src/server/relay.ts +14 -19
  133. package/src/server/request-log.ts +127 -3
  134. package/src/server/response-log-body.ts +153 -0
  135. package/src/server/responses/account-change-state.ts +74 -0
  136. package/src/server/responses/adapter-continuation.ts +33 -7
  137. package/src/server/responses/adapter-delivery.ts +5 -11
  138. package/src/server/responses/adapter-dispatch.ts +84 -13
  139. package/src/server/responses/codex-ws-exchange.ts +65 -4
  140. package/src/server/responses/codex-ws-wire.ts +5 -0
  141. package/src/server/responses/collaboration.ts +74 -4
  142. package/src/server/responses/combo-session-recall.ts +68 -8
  143. package/src/server/responses/combo-stream-preflight.ts +68 -5
  144. package/src/server/responses/compact.ts +54 -13
  145. package/src/server/responses/core-auth.ts +2 -0
  146. package/src/server/responses/core-codex-account.ts +51 -3
  147. package/src/server/responses/core-combo.ts +129 -23
  148. package/src/server/responses/core-errors.ts +18 -0
  149. package/src/server/responses/core-options.ts +3 -0
  150. package/src/server/responses/core-replay.ts +105 -32
  151. package/src/server/responses/core.ts +3 -3
  152. package/src/server/responses/encrypted-payload.ts +0 -1
  153. package/src/server/responses/fetch-helpers.ts +4 -1
  154. package/src/server/responses/input-admission.ts +126 -6
  155. package/src/server/responses/native-injection-protocol.ts +42 -0
  156. package/src/server/responses/native-injection-replay.ts +105 -0
  157. package/src/server/responses/native-injection.ts +242 -0
  158. package/src/server/responses/native-response-control.ts +56 -0
  159. package/src/server/responses/native-response-json.ts +14 -0
  160. package/src/server/responses/native-response-output.ts +37 -0
  161. package/src/server/responses/native-steering-log.ts +44 -0
  162. package/src/server/responses/native-steering-policy.ts +49 -0
  163. package/src/server/responses/native-steering-replay.ts +126 -0
  164. package/src/server/responses/native-steering-settings.ts +76 -0
  165. package/src/server/responses/native-steering.ts +400 -0
  166. package/src/server/responses/native-tool-results.ts +130 -0
  167. package/src/server/responses/passthrough-delivery.ts +30 -6
  168. package/src/server/responses/passthrough-dispatch.ts +61 -11
  169. package/src/server/responses/passthrough-error.ts +38 -2
  170. package/src/server/responses/request-prepare.ts +173 -22
  171. package/src/server/responses/request-send-budget.ts +97 -2
  172. package/src/server/responses/request-spend.ts +147 -0
  173. package/src/server/responses/request-transport.ts +62 -3
  174. package/src/server/responses/run-turn-execution.ts +59 -31
  175. package/src/server/responses/sidecar-execution.ts +7 -13
  176. package/src/server/responses/terminal-guard.ts +65 -4
  177. package/src/server/responses/ws-upstream.ts +21 -1
  178. package/src/server/responses-undeclared-tool-guard.ts +9 -5
  179. package/src/server/stop-teardown.ts +8 -1
  180. package/src/server/ws-bridge.ts +16 -1
  181. package/src/service/cli.ts +13 -1
  182. package/src/service/windows-ops.ts +210 -16
  183. package/src/service/windows-scheduler.ts +28 -21
  184. package/src/service.ts +1 -1
  185. package/src/types/config.ts +8 -1
  186. package/src/types/provider.ts +13 -0
  187. package/src/types/request.ts +8 -5
  188. package/src/types/tools.ts +24 -0
  189. package/src/types.ts +2 -0
  190. package/src/update/index.ts +10 -0
  191. package/src/update/stop-contract.d.mts +1 -0
  192. package/src/update/stop-contract.mjs +19 -0
  193. package/src/update/stop-decision.d.mts +1 -1
  194. package/src/update/stop-decision.mjs +12 -3
  195. package/src/usage/log.ts +1 -1
  196. package/src/vision/anthropic-describe.ts +1 -1
  197. package/src/vision/describe.ts +5 -5
  198. package/src/web-search/anthropic-executor.ts +1 -1
  199. package/src/web-search/exa-executor.ts +1 -1
  200. package/src/web-search/executor.ts +1 -1
  201. package/src/web-search/gemini-executor.ts +1 -1
  202. package/src/web-search/loop.ts +1 -1
  203. package/src/web-search/ollama-executor.ts +1 -1
  204. package/src/web-search/parse.ts +67 -14
  205. package/src/web-search/passthrough-bridge.ts +64 -31
  206. package/src/web-search/xai-executor.ts +1 -1
@@ -615,7 +615,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
615
615
  signal: headerDeadline.signal,
616
616
  }, retryRecovery));
617
617
  },
618
- { abortSignal: headerDeadline.signal, label: "image-bridge-loop" },
618
+ { replaySafe: true, abortSignal: headerDeadline.signal, label: "image-bridge-loop" },
619
619
  );
620
620
  }
621
621
  } finally {
@@ -9,28 +9,80 @@ export interface BoundedSubprocessExit {
9
9
  timedOut: boolean;
10
10
  }
11
11
 
12
- /** Kill at the deadline and abandon immediately; late exit/rejection remains observed. */
12
+ export type SubprocessDeadlineScheduler = (
13
+ callback: () => void,
14
+ milliseconds: number,
15
+ ) => () => void;
16
+
17
+ const scheduleDeadline: SubprocessDeadlineScheduler = (callback, milliseconds) => {
18
+ const timer = setTimeout(callback, milliseconds);
19
+ return () => clearTimeout(timer);
20
+ };
21
+
22
+ /**
23
+ * Compatibility allowance used by the ACL runner's outer watchdog.
24
+ *
25
+ * `kill()` only REQUESTS termination. It returns before the kernel has torn the process down, and
26
+ * every handle that process holds stays held until it does. On Windows that is not a detail: file
27
+ * locking is mandatory, so a directory an abandoned `icacls.exe` still has open cannot be removed
28
+ * by anyone, and the removal fails with EPERM rather than waiting.
29
+ */
30
+ export const SUBPROCESS_KILL_GRACE_MS = 2_000;
31
+
32
+ /**
33
+ * Wait for a child until the deadline. At the deadline, kill it AND wait for it to actually die.
34
+ *
35
+ * This used to kill, `unref`, and resolve in the same tick, which made every caller's "I waited
36
+ * for my child" guarantee false precisely when it mattered. The ACL runner now waits here until
37
+ * actual exit; if its separate caller-facing belt fires first, that layer registers the target so
38
+ * removal can wait for the reap without making ordinary startup or shutdown unbounded.
39
+ *
40
+ * That cost three failed fixes. #4789 blamed the removal retry budget and asked for more than
41
+ * 2.5s; #4796 gave it a 15s exponential schedule; a later change awaited the hardening flight from
42
+ * the test hook. Windows shard 1/6 failed identically through all three, because none of them
43
+ * addressed a live process holding the handle -- run 35108652486 burned the full 15s budget and
44
+ * still threw `EPERM ... rm ocx-management-auth-fDchUb`, with two
45
+ * `ACL hardening timed out (ETIMEDOUT) - transient icacls stall` lines logged beside it.
46
+ *
47
+ * The old grace still abandoned a live child after two seconds. That recreated the same false
48
+ * ownership contract on a slower clock: the ACL flight settled, cleanup removed the directory,
49
+ * and Windows returned EPERM because the child still held it. A handle-bearing caller therefore
50
+ * has no second deadline after kill. The child's actual exit is the only release signal.
51
+ *
52
+ * Pass `0` to opt out for a child that holds no path anyone will remove. The numeric form is kept
53
+ * for compatibility with the existing callers; any positive value means that reaping is required.
54
+ * The injected scheduler is a test seam so deadline and exit ordering can be proved without sleep.
55
+ */
13
56
  export function waitForSubprocessExit(
14
57
  proc: KillableSubprocess,
15
58
  timeoutMs: number,
59
+ reapAfterKill: number = SUBPROCESS_KILL_GRACE_MS,
60
+ schedule: SubprocessDeadlineScheduler = scheduleDeadline,
16
61
  ): Promise<BoundedSubprocessExit> {
17
62
  return new Promise(resolve => {
18
63
  let settled = false;
19
- let timer: ReturnType<typeof setTimeout> | undefined;
64
+ let deadlineFired = false;
65
+ let cancelDeadline: (() => void) | undefined;
20
66
  const finish = (result: BoundedSubprocessExit): void => {
21
67
  if (settled) return;
22
68
  settled = true;
23
- if (timer !== undefined) clearTimeout(timer);
69
+ cancelDeadline?.();
24
70
  resolve(result);
25
71
  };
26
- timer = setTimeout(() => {
72
+ const reaped = proc.exited.then(
73
+ exitCode => finish(deadlineFired
74
+ ? { exitCode: null, timedOut: true }
75
+ : { exitCode, timedOut: false }),
76
+ () => finish({ exitCode: null, timedOut: deadlineFired }),
77
+ );
78
+ cancelDeadline = schedule(() => {
79
+ deadlineFired = true;
27
80
  try { proc.kill(); } catch { /* already exited */ }
28
- try { proc.unref?.(); } catch { /* abandonment is still authoritative */ }
29
- finish({ exitCode: null, timedOut: true });
81
+ if (reapAfterKill <= 0) {
82
+ try { proc.unref?.(); } catch { /* abandonment is still authoritative */ }
83
+ finish({ exitCode: null, timedOut: true });
84
+ return;
85
+ }
30
86
  }, Math.max(1, timeoutMs));
31
- void proc.exited.then(
32
- exitCode => finish({ exitCode, timedOut: false }),
33
- () => finish({ exitCode: null, timedOut: false }),
34
- );
35
87
  });
36
88
  }
package/src/lib/errors.ts CHANGED
@@ -7,6 +7,15 @@ export interface OcxErrorPayload {
7
7
  export const ENCRYPTED_FUNCTION_OUTPUT_REJECTION =
8
8
  "Encrypted function output content could not be decrypted or decoded.";
9
9
 
10
+ /**
11
+ * The error identity for a send this proxy declined to make (#4708).
12
+ *
13
+ * Declared here rather than only on the error class because the classifier is what decides
14
+ * whether the identity survives serialization, and every dispatch path has to name the same
15
+ * string for a client to be able to tell this apart from a provider rate limit.
16
+ */
17
+ export const SEND_BUDGET_EXHAUSTED_CODE = "request_send_budget_exhausted";
18
+
10
19
  /** Canonical human-readable message paths used by Responses upstream failures. */
11
20
  export function upstreamErrorMessageFromPayload(payload: unknown): string | undefined {
12
21
  if (!payload || typeof payload !== "object" || Array.isArray(payload)) return undefined;
@@ -253,6 +262,14 @@ export function classifyError(status: number, type: string, message: string): Oc
253
262
  ) {
254
263
  return { message, type: "insufficient_quota", code: "insufficient_quota" };
255
264
  }
265
+ // A refusal this proxy made itself, kept apart from the provider rate limits below. The HTTP
266
+ // semantics are identical -- 429, do not send this again now -- but the code is the only thing
267
+ // that tells an operator reading a log whether the provider throttled the request or whether
268
+ // this process declined to send it. Folding it into the generic rate-limit code sent them to
269
+ // the provider's dashboard to explain a decision that was never made there.
270
+ if (type === SEND_BUDGET_EXHAUSTED_CODE) {
271
+ return { message, type: "rate_limit_error", code: SEND_BUDGET_EXHAUSTED_CODE };
272
+ }
256
273
  if (
257
274
  status === 429 ||
258
275
  text.includes("rate limit") ||
@@ -56,6 +56,7 @@ export type BudgetDenial =
56
56
  | "final-recovery-spent"
57
57
  | "alternate-target-exhausted"
58
58
  | "target-transition-exhausted"
59
+ | "spend-exhausted"
59
60
  | "not-replay-safe";
60
61
 
61
62
  export interface DispatchIntent {
@@ -94,12 +95,45 @@ export interface SingleUseDispatchPermit {
94
95
  * once an external send reporter already settled it.
95
96
  */
96
97
  release(): void;
98
+ /**
99
+ * Take over an externally counted booking, because the layer holding this permit is the one
100
+ * that physically sends.
101
+ *
102
+ * `countedExternally` promises that a retry helper will name this send through
103
+ * `onSendsConsumed`. An adapter that owns its own dispatch ladder -- Kiro's reset loop,
104
+ * Cursor's transport loop -- reserves per physical send instead, so no reporter ever arrives
105
+ * and the pending booking would sit there until it silently swallowed an unrelated later
106
+ * report. Confirming through this method settles the permit AND closes the booking, so the
107
+ * send stays charged exactly once (#4709). Returns false once the permit is settled, which is
108
+ * what keeps one permit from admitting two sends.
109
+ */
110
+ assumeCharge(): boolean;
97
111
  }
98
112
 
99
113
  export type DispatchDecision =
100
114
  | { allowed: true; permit: SingleUseDispatchPermit }
101
115
  | { allowed: false; reason: BudgetDenial };
102
116
 
117
+ /**
118
+ * Notified when this request's physical-send count moves.
119
+ *
120
+ * `spent` is the only number here that counts SENDS rather than intentions: a reservation
121
+ * increments it, a refund decrements it, and an externally reported send settles against a
122
+ * booking that was already counted. Anything that books one entry per increment therefore
123
+ * books exactly one entry per physical send -- which is what lets the durable spend ledger
124
+ * have a production caller without every dispatch site in the tree remembering to call it.
125
+ *
126
+ * `charge` may refuse, and a refusal denies the dispatch. That is deliberate: the ledger is
127
+ * the only bound here that survives a restart, so a limit it enforces has to be able to stop a
128
+ * send rather than merely describe one.
129
+ */
130
+ export interface RequestSendObserver {
131
+ /** Book one physical send. False refuses the dispatch before the budget charges it. */
132
+ charge(): boolean;
133
+ /** Give back a booking whose send never happened. */
134
+ refund(): void;
135
+ }
136
+
103
137
  /**
104
138
  * Carried on HandleResponsesOptions so a combo child, a rebuild and an alternate-account leg
105
139
  * all decrement the same holder. `used` is the existing #4605 counter and still counts every
@@ -134,33 +168,57 @@ const RESERVE_FUNDED_CLASSES: ReadonlySet<SendClass> = new Set<SendClass>([
134
168
 
135
169
  let logicalRequestSeq = 0;
136
170
 
137
- export function createRequestExecutionBudget(
138
- policy: RequestExecutionBudgetPolicy = CODEX_TEXT_GUARDED_BUDGET_POLICY,
139
- logicalRequestId?: string,
171
+ /**
172
+ * One request's physical-send ledger, held apart from the budget object so a derived policy
173
+ * scope can share the exact same one.
174
+ *
175
+ * `spent` and `pendingExternalSends` belong together: a pending booking is a send that is
176
+ * already counted in `spent` and awaiting its reporter, so a scope that shared one without the
177
+ * other would either charge that send twice or never charge it at all.
178
+ *
179
+ * The durable-spend observer belongs here for the same reason. It books one entry per physical
180
+ * send by watching this counter move, so a derived scope that spent the counter without
181
+ * carrying the observer would move it without booking, and a combo child's sends would go
182
+ * missing from the ledger (#4707).
183
+ */
184
+ interface SharedSendLedger {
185
+ spent: number;
186
+ pendingExternalSends: number;
187
+ readonly observer?: RequestSendObserver;
188
+ }
189
+
190
+ const sharedSendLedgers = new WeakMap<RequestExecutionBudget, SharedSendLedger>();
191
+
192
+ function createRequestExecutionBudgetWithLedger(
193
+ policy: RequestExecutionBudgetPolicy,
194
+ logicalRequestId: string | undefined,
195
+ counter: SharedSendLedger,
140
196
  ): RequestExecutionBudget {
141
- let spent = 0;
142
- // Reservations whose physical send is reported by a retry helper rather than by the permit.
143
- // They are already charged; the reporter's first send settles one instead of charging again.
144
- let pendingExternalSends = 0;
197
+ const observer = counter.observer;
145
198
  let reserveSpent = false;
146
199
  let alternateTargetSends = 0;
147
200
  let targetTransitions = 0;
148
201
  let lastTargetKey: string | undefined;
149
202
 
150
203
  const budget: RequestExecutionBudget = {
151
- get used(): number { return spent; },
204
+ get used(): number { return counter.spent; },
152
205
  set used(next: number) {
153
206
  // The retry helpers report their real send count by assigning through this field. A
154
207
  // reservation taken with `countedExternally` has already booked one of those sends, so
155
208
  // the report settles the pending booking first and only the surplus is charged.
156
- const delta = next - spent;
209
+ const delta = next - counter.spent;
157
210
  if (delta <= 0) {
158
- spent = Math.max(0, next);
211
+ counter.spent = Math.max(0, next);
159
212
  return;
160
213
  }
161
- const settled = Math.min(delta, pendingExternalSends);
162
- pendingExternalSends -= settled;
163
- spent += delta - settled;
214
+ const settled = Math.min(delta, counter.pendingExternalSends);
215
+ counter.pendingExternalSends -= settled;
216
+ const charged = delta - settled;
217
+ counter.spent += charged;
218
+ // These sends have already left. The ledger records them even past a ceiling it would
219
+ // have refused, because refusing after the fact only hides spend that was really
220
+ // incurred -- the refusal has to happen at the reservation below, or not at all.
221
+ for (let index = 0; index < charged; index += 1) observer?.charge();
164
222
  },
165
223
  logicalRequestId: logicalRequestId ?? `lr-${Date.now().toString(36)}-${(logicalRequestSeq += 1).toString(36)}`,
166
224
  policyVersion: REQUEST_BUDGET_POLICY_VERSION,
@@ -171,11 +229,11 @@ export function createRequestExecutionBudget(
171
229
  get lastTargetKey() { return lastTargetKey; },
172
230
  remainingBaseSends(cap: number): number {
173
231
  const capped = Number.isFinite(cap) ? Math.trunc(cap) : 0;
174
- return Math.max(0, Math.min(capped, policy.baseSendAllowance - spent));
232
+ return Math.max(0, Math.min(capped, policy.baseSendAllowance - counter.spent));
175
233
  },
176
234
  reserveDispatch(intent: DispatchIntent): DispatchDecision {
177
235
  if (intent.replaySafe === false) return { allowed: false, reason: "not-replay-safe" };
178
- if (spent >= policy.maxTotalModelSends) return { allowed: false, reason: "total-exhausted" };
236
+ if (counter.spent >= policy.maxTotalModelSends) return { allowed: false, reason: "total-exhausted" };
179
237
 
180
238
  const changesTarget = lastTargetKey !== undefined && lastTargetKey !== intent.targetKey;
181
239
  const isAlternateTarget = changesTarget || intent.sendClass === "account-failover"
@@ -190,7 +248,7 @@ export function createRequestExecutionBudget(
190
248
  // The base allowance is spent first. Only once it is gone does a recovery class reach
191
249
  // for the single shared reserve -- an account move and a validated rebuild cannot each
192
250
  // take one.
193
- const drawsReserve = policy.baseSendAllowance - spent <= 0;
251
+ const drawsReserve = policy.baseSendAllowance - counter.spent <= 0;
194
252
  if (drawsReserve) {
195
253
  if (!RESERVE_FUNDED_CLASSES.has(intent.sendClass)) {
196
254
  return { allowed: false, reason: "base-allowance-exhausted" };
@@ -200,13 +258,18 @@ export function createRequestExecutionBudget(
200
258
  }
201
259
  }
202
260
 
261
+ // Consulted last, because it is the only bound here that WRITES. A ledger entry booked
262
+ // for a dispatch a cheaper check above would have refused is spend this request never
263
+ // makes, and it would hold those tokens against the scope until retention expired.
264
+ if (observer && !observer.charge()) return { allowed: false, reason: "spend-exhausted" };
265
+
203
266
  // THE RESERVATION IS THE CHARGE. Deciding here and charging in `use()` left a window in
204
267
  // which two legs read the same remainder, both received a permit, and both dispatched:
205
268
  // one remaining send admitted two physical sends, which is the per-request multiplication
206
269
  // this budget exists to stop. Everything is booked now; `release()` is the way back.
207
270
  const previousTargetKey = lastTargetKey;
208
- spent += 1;
209
- if (intent.countedExternally === true) pendingExternalSends += 1;
271
+ counter.spent += 1;
272
+ if (intent.countedExternally === true) counter.pendingExternalSends += 1;
210
273
  if (drawsReserve) reserveSpent = true;
211
274
  if (isAlternateTarget) alternateTargetSends += 1;
212
275
  if (changesTarget) targetTransitions += 1;
@@ -222,16 +285,28 @@ export function createRequestExecutionBudget(
222
285
  settled = "used";
223
286
  return true;
224
287
  },
288
+ assumeCharge(): boolean {
289
+ if (settled !== "open") return false;
290
+ settled = "used";
291
+ // The booking this reservation made for an external reporter is now owned by the
292
+ // caller. Leaving it pending is not harmless: the next `used` report of this request
293
+ // would settle against it and one real send would go uncharged.
294
+ if (intent.countedExternally === true && counter.pendingExternalSends > 0) {
295
+ counter.pendingExternalSends -= 1;
296
+ }
297
+ return true;
298
+ },
225
299
  release(): void {
226
300
  if (settled !== "open") return;
227
301
  settled = "released";
228
302
  // An externally counted reservation the reporter already settled paid for a send
229
303
  // that physically happened. Refunding it would hand the request a free send back.
230
304
  if (intent.countedExternally === true) {
231
- if (pendingExternalSends === 0) return;
232
- pendingExternalSends -= 1;
305
+ if (counter.pendingExternalSends === 0) return;
306
+ counter.pendingExternalSends -= 1;
233
307
  }
234
- spent -= 1;
308
+ counter.spent -= 1;
309
+ observer?.refund();
235
310
  if (drawsReserve) reserveSpent = false;
236
311
  if (isAlternateTarget) alternateTargetSends -= 1;
237
312
  if (changesTarget) targetTransitions -= 1;
@@ -241,9 +316,60 @@ export function createRequestExecutionBudget(
241
316
  };
242
317
  },
243
318
  };
319
+ sharedSendLedgers.set(budget, counter);
244
320
  return budget;
245
321
  }
246
322
 
323
+ export function createRequestExecutionBudget(
324
+ policy: RequestExecutionBudgetPolicy = CODEX_TEXT_GUARDED_BUDGET_POLICY,
325
+ logicalRequestId?: string,
326
+ observer?: RequestSendObserver,
327
+ ): RequestExecutionBudget {
328
+ return createRequestExecutionBudgetWithLedger(policy, logicalRequestId, {
329
+ spent: 0,
330
+ pendingExternalSends: 0,
331
+ ...(observer ? { observer } : {}),
332
+ });
333
+ }
334
+
335
+ /**
336
+ * A budget that applies its own policy and keeps its own recovery ledgers while spending the
337
+ * parent's exact physical-send ledger.
338
+ *
339
+ * Aliasing the public `used` property was not enough, and that is the whole defect. The factory
340
+ * reads its own private counter back in `remainingBaseSends`, in the total check, and in the
341
+ * reserve test, so an aliased scope answered every admission question from a counter that only
342
+ * ever saw its own reservations. A combo's per-target holdback is computed from
343
+ * `maxTotalModelSends` and is therefore unenforceable unless the scope actually observes what
344
+ * the request has already spent.
345
+ */
346
+ export function deriveRequestExecutionBudget(
347
+ parent: RequestExecutionBudget,
348
+ policy: RequestExecutionBudgetPolicy,
349
+ ): RequestExecutionBudget {
350
+ return createRequestExecutionBudgetWithLedger(policy, parent.logicalRequestId, ledgerFor(parent));
351
+ }
352
+
353
+ /**
354
+ * A budget that did not come from this factory still honors the public `used` contract, so
355
+ * bridge onto it rather than failing the request. `isRequestExecutionBudget` is a shape test,
356
+ * so a stub can reach here; turning that into a thrown error would convert a routing request
357
+ * into a 500 to report a condition production never produces. Only a factory-backed parent can
358
+ * share pending external bookings and a durable-spend observer, which are private by
359
+ * construction; a bridged scope keeps the parent's spend accurate and books nothing of its own.
360
+ */
361
+ function ledgerFor(parent: RequestExecutionBudget): SharedSendLedger {
362
+ const existing = sharedSendLedgers.get(parent);
363
+ if (existing) return existing;
364
+ let pendingExternalSends = 0;
365
+ return {
366
+ get spent(): number { return parent.used; },
367
+ set spent(next: number) { parent.used = next; },
368
+ get pendingExternalSends(): number { return pendingExternalSends; },
369
+ set pendingExternalSends(next: number) { pendingExternalSends = next; },
370
+ };
371
+ }
372
+
247
373
  export function isRequestExecutionBudget(
248
374
  value: TransientSendBudget | undefined,
249
375
  ): value is RequestExecutionBudget {
@@ -669,6 +669,24 @@ export function createSpendReservationLedger(options: {
669
669
  case "checkpoint": applyCheckpoint(record); break;
670
670
  }
671
671
  }
672
+ // A reservation that survived replay has no owner left. The process that made it is gone,
673
+ // so nothing in this one can ever settle it, and leaving it live means the send stays
674
+ // pending forever against a scope that can never resolve it. Deleting the entry is not the
675
+ // alternative either: that would hand the same send id a second reservation.
676
+ //
677
+ // Both live states resolve to UNRESOLVED, including an undispatched one. The tempting
678
+ // distinction -- open never reached the wire, so give its tokens back -- assumes the
679
+ // journal is complete up to the crash, and the torn-tail handling above says it is not: a
680
+ // send can dispatch and die before its dispatch record lands. Abandoning that reservation
681
+ // returns tokens for a send that may have been billed, and worse, it RESETS a ceiling that
682
+ // had already fired. An exhausted scope staying exhausted across a restart is the whole
683
+ // reason this store is on disk.
684
+ const reconciledAt = now();
685
+ for (const [send, reservation] of reservations) {
686
+ if (!isLive(reservation.status)) continue;
687
+ applyResolve(send, "lost", 0, reconciledAt);
688
+ append({ v: 1, kind: "lost", send, at: reconciledAt });
689
+ }
672
690
  }
673
691
 
674
692
  /**
@@ -21,7 +21,7 @@ import {
21
21
  } from "../combos/failover";
22
22
  import { reconcileComboWarningMemos } from "../combos/request";
23
23
  import { reconcileComboRotationState } from "../combos/resolve";
24
- import { reconcileComboRecall } from "../server/responses/combo-session-recall";
24
+ import { reconcileComboRecall, sweepExpiredComboRecall } from "../server/responses/combo-session-recall";
25
25
  import { listLiveComboTargetKeys } from "../combos/types";
26
26
  import {
27
27
  listLiveConfigOwnershipRoots,
@@ -112,7 +112,11 @@ export const STATE_STORE_REGISTRATIONS = [
112
112
  { name: "model-cache-history", reconcileGeneration: reconcileModelCacheGeneration },
113
113
  { name: "pool-rotation", reconcileGeneration: reconcilePoolRotationState },
114
114
  { name: "combo-rotation", reconcileGeneration: reconcileComboRotationState },
115
- { name: "combo-session-recall", reconcileGeneration: reconcileComboRecall },
115
+ {
116
+ name: "combo-session-recall",
117
+ sweepExpired: sweepExpiredComboRecall,
118
+ reconcileGeneration: reconcileComboRecall,
119
+ },
116
120
  { name: "guardian-backoff", reconcileGeneration: reconcileGuardianBackoff },
117
121
  { name: "codex-reauth", reconcileGeneration: reconcileCodexReauthState },
118
122
  { name: "oauth-reauth", reconcileGeneration: reconcileOAuthReauthState },
@@ -20,7 +20,7 @@
20
20
  * how this incident happened.
21
21
  */
22
22
  import { homedir } from "node:os";
23
- import { dirname, join, relative, resolve } from "node:path";
23
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
24
24
  import { realpathSync } from "node:fs";
25
25
 
26
26
  const GUARD_ENV = "OCX_TEST_HOME_GUARD";
@@ -152,3 +152,87 @@ export function assertNotRealCodexHomeUnderTest(dir: string): void {
152
152
  + "Point CODEX_HOME at a temp directory for this test before writing native auth.json.",
153
153
  );
154
154
  }
155
+
156
+ /**
157
+ * The trees a removal must never reach, and the reason each one is named.
158
+ *
159
+ * The writer guard above cannot help here. `rmSync` is plain `node:fs`: it calls no writer of
160
+ * ours, so no assertion of ours runs, and by the time anything could observe the damage the
161
+ * directory is already gone. On 2026-09-15 that is exactly what happened — a test resolved the
162
+ * process-global config directory and removed it, taking every OAuth login, the Codex account
163
+ * store, the service tokens and a 372MB usage ledger with it.
164
+ */
165
+ const PROTECTED_TREES: ReadonlyArray<{ path: string; lexical: string; label: string }> = [
166
+ { path: PROTECTED_HOME, lexical: resolve(join(REAL_HOME, ".opencodex")), label: "the real OpenCodex home" },
167
+ { path: PROTECTED_CODEX_HOME, lexical: resolve(join(REAL_HOME, ".codex")), label: "the real Codex home" },
168
+ {
169
+ path: PROTECTED_LAUNCH_AGENTS,
170
+ lexical: resolve(join(REAL_HOME, "Library", "LaunchAgents")),
171
+ label: "the real LaunchAgents directory",
172
+ },
173
+ ];
174
+ const PROTECTED_REAL_HOME = canonicalize(REAL_HOME);
175
+ const LEXICAL_REAL_HOME = resolve(REAL_HOME);
176
+
177
+ /** Canonical paths whose removal is refused. Exported so the guard's tests cannot drift off them. */
178
+ export function protectedRemovalTreesForTests(): readonly string[] {
179
+ return [PROTECTED_REAL_HOME, ...PROTECTED_TREES.map(tree => tree.path)];
180
+ }
181
+
182
+ /** Whether `child` sits strictly below `parent`, both already canonicalized. */
183
+ function isInside(parent: string, child: string): boolean {
184
+ const rel = relative(parent, child);
185
+ return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
186
+ }
187
+
188
+ /**
189
+ * Why removing `target` is refused, or `null` when it is not a protected location.
190
+ *
191
+ * Three relations are refused, not one. Equality alone would still permit
192
+ * `rmSync(getConfigPath())` against a live `config.json`, and it would permit
193
+ * `rmSync(homedir())`, which takes the protected tree with it. So a target is refused when it
194
+ * IS a protected tree, when it sits INSIDE one, or when it is an ANCESTOR of one.
195
+ *
196
+ * Canonicalization is what makes a symlink useless as a bypass: a temp path that merely points
197
+ * at the real home resolves to the real home before any comparison happens.
198
+ */
199
+ export function protectedRemovalReason(target: string): string | null {
200
+ // Both spellings are judged, not just the canonical one. Canonicalization is what defeats a
201
+ // symlink alias, but it also resolves the target away: if `~/.opencodex` is itself a link,
202
+ // the literal path a caller passed is the thing that gets unlinked, and only the lexical
203
+ // form still names it. Upstream Codex makes the same distinction in its writable-root
204
+ // handling, keeping logical and resolved forms side by side rather than collapsing to one.
205
+ for (const candidate of [canonicalize(target), resolve(target)]) {
206
+ if (candidate === PROTECTED_REAL_HOME || candidate === LEXICAL_REAL_HOME) {
207
+ return `the real home directory (${PROTECTED_REAL_HOME})`;
208
+ }
209
+ for (const tree of PROTECTED_TREES) {
210
+ for (const protectedPath of [tree.path, tree.lexical]) {
211
+ if (candidate === protectedPath) return `${tree.label} (${protectedPath})`;
212
+ if (isInside(protectedPath, candidate)) return `a path inside ${tree.label} (${protectedPath})`;
213
+ if (isInside(candidate, protectedPath)) return `an ancestor of ${tree.label} (${protectedPath})`;
214
+ }
215
+ }
216
+ }
217
+ return null;
218
+ }
219
+
220
+ /**
221
+ * Throw before a removal that would reach a protected tree.
222
+ *
223
+ * Deliberately NOT gated on {@link isTestHomeGuardArmed}. Arming happens in `tests/preload.ts`,
224
+ * which Bun loads from the `bunfig.toml` it finds in the CURRENT WORKING DIRECTORY — so a run
225
+ * started outside the repository arms nothing, leaves OPENCODEX_HOME unset, and resolves the
226
+ * developer's real home. That unarmed run is precisely the one that caused the incident, so the
227
+ * refusal has to hold without it. Nothing in production calls this; the callers are test
228
+ * helpers, where the only cost of an unconditional check is a path comparison.
229
+ */
230
+ export function assertRemovalOutsideProtectedTrees(target: string): void {
231
+ const reason = protectedRemovalReason(target);
232
+ if (reason === null) return;
233
+ throw new Error(
234
+ `refusing to remove ${reason} from a test process: "${target}" resolves there. `
235
+ + "Create the directory this test owns with createTempHome() from tests/helpers/temp-home "
236
+ + "and remove that handle instead (see devlog 260730_codex_rs_upstream_v2_live_handoff/070).",
237
+ );
238
+ }