@bitkyc08/opencodex 2.56.0 → 2.57.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 (145) hide show
  1. package/bin/ocx.mjs +10 -0
  2. package/gui/dist/assets/{index-BBOZWGB6.css → index-C5-RdDmD.css} +1 -1
  3. package/gui/dist/assets/{index-D4zuyIxQ.js → index-Cz7CLdif.js} +21 -21
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +3 -3
  6. package/src/adapters/codebuddy/adapter.ts +2 -1
  7. package/src/adapters/codebuddy/scaffold-guard.ts +248 -0
  8. package/src/adapters/command-code.ts +1 -1
  9. package/src/adapters/cursor/envelope-echo.ts +8 -2
  10. package/src/adapters/google.ts +7 -7
  11. package/src/adapters/kiro/payload.ts +17 -3
  12. package/src/adapters/kiro/reasoning.ts +70 -7
  13. package/src/adapters/kiro/stream.ts +8 -2
  14. package/src/adapters/kiro/wire.ts +2 -1
  15. package/src/adapters/kiro-events.ts +21 -13
  16. package/src/adapters/openai-chat/tool-name-registry.ts +166 -0
  17. package/src/adapters/openai-chat/tool-schema.ts +25 -7
  18. package/src/adapters/openai-chat.ts +8 -8
  19. package/src/adapters/openai-responses/passthrough.ts +32 -1
  20. package/src/bridge/errors.ts +26 -2
  21. package/src/bridge/response-json.ts +7 -1
  22. package/src/bridge/sse.ts +19 -1
  23. package/src/claude/desktop-profile.ts +66 -9
  24. package/src/claude/outbound.ts +18 -0
  25. package/src/cli/account-main.ts +1 -1
  26. package/src/cli/capabilities.ts +2 -2
  27. package/src/cli/combo.ts +10 -1
  28. package/src/cli/index.ts +48 -5
  29. package/src/cli/registry.ts +2 -1
  30. package/src/cli/system-command.ts +4 -4
  31. package/src/clients/config-export.ts +7 -3
  32. package/src/codex/account-label.ts +14 -3
  33. package/src/codex/account-store.ts +113 -26
  34. package/src/codex/account-usability.ts +21 -0
  35. package/src/codex/auth-api/login-flow.ts +14 -2
  36. package/src/codex/auth-api/reset-credit-service.ts +11 -2
  37. package/src/codex/auth-context.ts +157 -7
  38. package/src/codex/catalog/aggregation.ts +80 -1
  39. package/src/codex/catalog/model-visibility.ts +1 -0
  40. package/src/codex/catalog/remote.ts +30 -0
  41. package/src/codex/catalog/retained-sync.ts +9 -1
  42. package/src/codex/catalog/routed-gather.ts +38 -1
  43. package/src/codex/cli-install-provenance.ts +7 -1
  44. package/src/codex/convergence.ts +7 -2
  45. package/src/codex/desktop-app/types.ts +11 -2
  46. package/src/codex/desktop-app/windows.ts +5 -5
  47. package/src/codex/inject/restore.ts +29 -2
  48. package/src/codex/inject.ts +9 -9
  49. package/src/codex/model-entitlements.ts +152 -15
  50. package/src/codex/pool-refresh-backoff.ts +12 -3
  51. package/src/codex/quota-rejection.ts +104 -15
  52. package/src/codex/routing/cache-affinity.ts +70 -0
  53. package/src/codex/routing/cooldown-math.ts +10 -0
  54. package/src/codex/routing/selection.ts +79 -2
  55. package/src/codex/routing/thread-affinity.ts +50 -2
  56. package/src/codex/routing/transient-hold-dispatch.ts +141 -0
  57. package/src/codex/routing.ts +29 -49
  58. package/src/codex/warmup.ts +1 -1
  59. package/src/combos/failover.ts +85 -0
  60. package/src/combos/request.ts +17 -10
  61. package/src/combos/types.ts +23 -2
  62. package/src/config/pending-teardown.ts +31 -0
  63. package/src/generated/compatibility-version.json +163 -135
  64. package/src/images/loop.ts +1 -1
  65. package/src/lib/errors.ts +17 -0
  66. package/src/lib/request-execution-budget.ts +147 -21
  67. package/src/lib/spend-reservation-ledger.ts +18 -0
  68. package/src/lib/state-store-registrations.ts +6 -2
  69. package/src/lib/test-home-guard.ts +85 -1
  70. package/src/lib/upstream-retry.ts +77 -10
  71. package/src/lib/windows-elevation.ts +76 -14
  72. package/src/oauth/index.ts +2 -2
  73. package/src/oauth/key-providers.ts +2 -2
  74. package/src/providers/kiro-models.ts +4 -3
  75. package/src/providers/label.ts +19 -1
  76. package/src/providers/model-discovery.ts +16 -0
  77. package/src/providers/registry/entries-core.ts +7 -0
  78. package/src/providers/registry/entries-extended.ts +9 -0
  79. package/src/providers/registry/model-seeds.ts +4 -0
  80. package/src/responses/reasoning-envelope.ts +6 -3
  81. package/src/routing/identity-domains.ts +21 -14
  82. package/src/routing/probe-lease.ts +103 -1
  83. package/src/server/chat-completions.ts +3 -1
  84. package/src/server/chat-native.ts +37 -9
  85. package/src/server/index/live-sideband.ts +37 -1
  86. package/src/server/index/websocket-handler.ts +6 -2
  87. package/src/server/index.ts +5 -5
  88. package/src/server/inspection-tee.ts +107 -0
  89. package/src/server/live.ts +46 -1
  90. package/src/server/management/combo-routes.ts +10 -1
  91. package/src/server/relay-eager.ts +2 -0
  92. package/src/server/relay.ts +14 -19
  93. package/src/server/request-log.ts +127 -3
  94. package/src/server/response-log-body.ts +153 -0
  95. package/src/server/responses/account-change-state.ts +74 -0
  96. package/src/server/responses/adapter-continuation.ts +33 -7
  97. package/src/server/responses/adapter-delivery.ts +5 -11
  98. package/src/server/responses/adapter-dispatch.ts +84 -13
  99. package/src/server/responses/codex-ws-wire.ts +5 -0
  100. package/src/server/responses/collaboration.ts +74 -4
  101. package/src/server/responses/combo-session-recall.ts +68 -8
  102. package/src/server/responses/compact.ts +54 -13
  103. package/src/server/responses/core-auth.ts +2 -0
  104. package/src/server/responses/core-codex-account.ts +51 -3
  105. package/src/server/responses/core-combo.ts +103 -23
  106. package/src/server/responses/core-errors.ts +18 -0
  107. package/src/server/responses/core-replay.ts +105 -32
  108. package/src/server/responses/core.ts +3 -3
  109. package/src/server/responses/encrypted-payload.ts +0 -1
  110. package/src/server/responses/input-admission.ts +126 -6
  111. package/src/server/responses/passthrough-delivery.ts +19 -6
  112. package/src/server/responses/passthrough-dispatch.ts +28 -10
  113. package/src/server/responses/passthrough-error.ts +38 -2
  114. package/src/server/responses/request-prepare.ts +132 -22
  115. package/src/server/responses/request-send-budget.ts +97 -2
  116. package/src/server/responses/request-spend.ts +147 -0
  117. package/src/server/responses/request-transport.ts +62 -3
  118. package/src/server/responses/run-turn-execution.ts +59 -31
  119. package/src/server/responses/sidecar-execution.ts +7 -13
  120. package/src/server/responses/terminal-guard.ts +65 -4
  121. package/src/server/responses-undeclared-tool-guard.ts +9 -5
  122. package/src/service/windows-ops.ts +210 -16
  123. package/src/service/windows-scheduler.ts +28 -21
  124. package/src/service.ts +1 -1
  125. package/src/types/config.ts +4 -1
  126. package/src/types/request.ts +8 -5
  127. package/src/types/tools.ts +24 -0
  128. package/src/types.ts +2 -0
  129. package/src/update/index.ts +10 -0
  130. package/src/update/stop-contract.d.mts +1 -0
  131. package/src/update/stop-contract.mjs +19 -0
  132. package/src/update/stop-decision.d.mts +1 -1
  133. package/src/update/stop-decision.mjs +12 -3
  134. package/src/usage/log.ts +1 -1
  135. package/src/vision/anthropic-describe.ts +1 -1
  136. package/src/vision/describe.ts +5 -5
  137. package/src/web-search/anthropic-executor.ts +1 -1
  138. package/src/web-search/exa-executor.ts +1 -1
  139. package/src/web-search/executor.ts +1 -1
  140. package/src/web-search/gemini-executor.ts +1 -1
  141. package/src/web-search/loop.ts +1 -1
  142. package/src/web-search/ollama-executor.ts +1 -1
  143. package/src/web-search/parse.ts +67 -14
  144. package/src/web-search/passthrough-bridge.ts +64 -31
  145. 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 {
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
+ }
@@ -2,10 +2,10 @@
2
2
  * Retry guard for upstream fetches that die on stale pooled keep-alive sockets.
3
3
  *
4
4
  * chatgpt.com (Cloudflare) closes idle keep-alive connections server-side; Bun's fetch pool
5
- * reuses the half-closed socket and the request write fails with ECONNRESET before any
6
- * response bytes arrive. Retrying on a fresh connection is safe for our replayable
7
- * (string-body) upstream requests, because fetch() rejects only before response headers —
8
- * a caught error here means no response was ever received.
5
+ * reuses the half-closed socket and a request can fail before response headers arrive.
6
+ * A pre-header rejection does not prove that the origin did not process the request.
7
+ * Mechanically reusable bytes do not make a model POST idempotent: an ambiguous reset
8
+ * becomes a terminal, non-replayable response unless the operation is explicitly safe.
9
9
  *
10
10
  * Deliberately narrow: timeouts, aborts, ECONNREFUSED/DNS/TLS failures, and HTTP error
11
11
  * statuses (returned as Response, never thrown) are NOT retried. Mid-stream SSE resets are
@@ -36,19 +36,66 @@ export function isNonReplayableResponse(response: Response): boolean {
36
36
  return nonReplayableResponses.has(response);
37
37
  }
38
38
 
39
+ /**
40
+ * The narrower marker: responses this proxy synthesized as a replay refusal.
41
+ *
42
+ * {@link isNonReplayableResponse} answers "must not be sent again", which the WebSocket
43
+ * post-send verdicts share. This one answers "the upstream never said this", and that is the
44
+ * question a quota recorder or a `Retry-After` synthesizer has to ask. Both were written for
45
+ * a status that only ever arrived from a provider, so a synthetic 429 reads to them as a
46
+ * credential that rate-limited us and as a wait worth honouring -- one writes a cooldown
47
+ * against a credential that refused nothing, the other instructs the client to send the turn
48
+ * again. A marker rather than a body check, because it has to be answerable before the body
49
+ * is read and cannot be spoofed by an upstream that happens to echo the code.
50
+ */
51
+ const replayRefusalResponses = new WeakSet<Response>();
52
+
53
+ export function markReplayRefusalResponse(response: Response): void {
54
+ replayRefusalResponses.add(response);
55
+ }
56
+
57
+ export function isReplayRefusalResponse(response: Response): boolean {
58
+ return replayRefusalResponses.has(response);
59
+ }
60
+
39
61
  /** Origin never produced a response event; the turn may still be executing. */
40
62
  export const UPSTREAM_NO_RESPONSE_CODE = "upstream_no_response";
41
63
  /** Transport closed after the send, before any response event. */
42
64
  export const UPSTREAM_CLOSED_BEFORE_RESPONSE_CODE = "upstream_closed_before_response";
65
+ /**
66
+ * This proxy refused to replay a pre-header fetch rejection.
67
+ *
68
+ * Distinct from {@link UPSTREAM_CLOSED_BEFORE_RESPONSE_CODE}, which the Codex WebSocket
69
+ * transport settles as a 502 after the create frame was already sent. Both are ambiguous,
70
+ * but only this one is a refusal this process made before any response existed, so it
71
+ * follows the send-budget precedent and answers 429: the Codex client is configured with
72
+ * `retry_429: false` and `retry_5xx: true` over four attempts, so a 5xx here multiplies
73
+ * the duplicate send the refusal exists to prevent. See
74
+ * structure/transports/responses.md#ambiguous-connection-reset-replay-boundary.
75
+ */
76
+ export const UPSTREAM_RESET_REPLAY_REFUSED_CODE = "upstream_reset_replay_refused";
43
77
  const NON_REPLAYABLE_UPSTREAM_CODES: ReadonlySet<string> = new Set([
44
78
  UPSTREAM_NO_RESPONSE_CODE,
45
79
  UPSTREAM_CLOSED_BEFORE_RESPONSE_CODE,
80
+ UPSTREAM_RESET_REPLAY_REFUSED_CODE,
46
81
  ]);
47
82
 
48
83
  export function isNonReplayableUpstreamCode(code: unknown): boolean {
49
84
  return typeof code === "string" && NON_REPLAYABLE_UPSTREAM_CODES.has(code);
50
85
  }
51
86
 
87
+ /**
88
+ * True for the one non-replayable code this proxy owns end to end. The status it carries is
89
+ * a local decision, so a re-wrapping formatter must restate it rather than inherit the
90
+ * caller's upstream-shaped status.
91
+ */
92
+ export function isReplayRefusalCode(code: unknown): boolean {
93
+ return code === UPSTREAM_RESET_REPLAY_REFUSED_CODE;
94
+ }
95
+
96
+ /** Client-facing status for {@link UPSTREAM_RESET_REPLAY_REFUSED_CODE}. */
97
+ export const REPLAY_REFUSED_STATUS = 429;
98
+
52
99
  // 1 initial + 2 retries: the pool may hold more than one stale socket.
53
100
  const RESET_RETRY_MAX_ATTEMPTS = 3;
54
101
  const RESET_RETRY_BASE_DELAY_MS = 150;
@@ -352,6 +399,12 @@ export async function fetchWithAttemptDeadline(
352
399
  }
353
400
 
354
401
  export interface ResetRetryOptions {
402
+ /**
403
+ * Opt in only when repeating this operation cannot duplicate upstream effects.
404
+ * This permits reset retries, not extra sends: attempts and onSendsConsumed still
405
+ * bound and count every physical send. A string body is not replay-safety proof.
406
+ */
407
+ replaySafe?: boolean;
355
408
  abortSignal?: AbortSignal;
356
409
  /** Short host/path label for the retry warn log (no secrets/query strings). */
357
410
  label?: string;
@@ -442,9 +495,9 @@ export function applyUpstreamRecoveryInit<T extends RequestInit>(
442
495
  }
443
496
 
444
497
  /**
445
- * Run `doFetch`, retrying only connection-reset-shaped rejections (see
446
- * isConnectionResetError) with jittered backoff. The caller's thunk must be replay-safe
447
- * (string body); every retry is logged so persistent resets stay visible.
498
+ * Run `doFetch` within one send budget. Connection-reset-shaped rejections are
499
+ * terminal by default; only an explicitly replay-safe operation receives reset retries
500
+ * with jittered backoff. HTTP responses retain the caller's existing retry policy.
448
501
  */
449
502
  export async function fetchWithResetRetry(
450
503
  doFetch: ReplayableFetch,
@@ -475,6 +528,20 @@ export async function fetchWithResetRetry(
475
528
  if (sawReset) throw new UpstreamRetryEvidenceError([], err, true);
476
529
  throw err;
477
530
  }
531
+ if (opts.replaySafe !== true) {
532
+ // Return evidence instead of throwing a generic transport error: outer catches
533
+ // otherwise turn it into a replayable 502 and a combo/account recovery resends it.
534
+ // The WeakSet protects in-process recovery; the code survives JSON re-wrapping.
535
+ // Never expose the raw exception, which can contain credentials or request data.
536
+ const response = new Response(JSON.stringify({ error: {
537
+ type: "upstream_error",
538
+ code: UPSTREAM_RESET_REPLAY_REFUSED_CODE,
539
+ message: "The upstream connection closed before a response was received. The request may already have been processed; automatic replay was stopped.",
540
+ } }), { status: REPLAY_REFUSED_STATUS, headers: { "content-type": "application/json" } });
541
+ markResponseNonReplayable(response);
542
+ markReplayRefusalResponse(response);
543
+ return response;
544
+ }
478
545
  if (attempt === attempts - 1) throw err;
479
546
  sawReset = true;
480
547
  lastError = err;
@@ -491,9 +558,9 @@ export async function fetchWithResetRetry(
491
558
  }
492
559
 
493
560
  /**
494
- * fetchWithResetRetry plus a transient-5xx status retry layer, PRE-STREAM only: a
495
- * returned Response has by definition not been relayed to the client yet, so replaying
496
- * the (string-body) request is safe. The failed attempt's body is cancelled before the
561
+ * fetchWithResetRetry plus the caller-selected transient-5xx policy, PRE-STREAM only.
562
+ * A received HTTP error follows that policy; an ambiguous reset's non-replayable
563
+ * verdict always stops it. The failed attempt's body is cancelled before the
497
564
  * retry; every returned response (ok, non-transient, aborted, slow, exhausted) keeps
498
565
  * its body intact. Honors Retry-After via retryBackoffDelayMs.
499
566
  *