@bitkyc08/opencodex 2.55.0 → 2.56.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 (167) hide show
  1. package/gui/dist/assets/{index-VuoiWj9J.js → index-D4zuyIxQ.js} +1 -1
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +2 -1
  4. package/src/adapters/base.ts +21 -0
  5. package/src/adapters/cursor/transport-retry.ts +46 -1
  6. package/src/adapters/cursor.ts +4 -0
  7. package/src/adapters/kiro/adapter.ts +42 -1
  8. package/src/adapters/kiro-retry.ts +23 -4
  9. package/src/adapters/openai-chat/errors.ts +116 -0
  10. package/src/adapters/openai-chat/messages.ts +346 -0
  11. package/src/adapters/openai-chat/passthrough.ts +146 -0
  12. package/src/adapters/openai-chat/response-events.ts +117 -0
  13. package/src/adapters/openai-chat/tool-call-validation.ts +200 -0
  14. package/src/adapters/openai-chat/tool-schema.ts +477 -0
  15. package/src/adapters/openai-chat/wire.ts +50 -0
  16. package/src/adapters/openai-chat.ts +33 -1445
  17. package/src/adapters/openai-responses/canonical-forward.ts +202 -0
  18. package/src/adapters/openai-responses/image-gen.ts +406 -0
  19. package/src/adapters/openai-responses/internal.ts +3 -0
  20. package/src/adapters/openai-responses/passthrough.ts +611 -0
  21. package/src/adapters/openai-responses/prompt-cache.ts +83 -0
  22. package/src/adapters/openai-responses/reasoning.ts +220 -0
  23. package/src/adapters/openai-responses/request-strips.ts +185 -0
  24. package/src/adapters/openai-responses/tool-output-recovery.ts +509 -0
  25. package/src/adapters/openai-responses/tool-schema.ts +293 -0
  26. package/src/adapters/openai-responses/web-search.ts +156 -0
  27. package/src/adapters/openai-responses.ts +4 -2625
  28. package/src/bridge/errors.ts +34 -0
  29. package/src/bridge/internal.ts +174 -0
  30. package/src/bridge/response-json.ts +624 -0
  31. package/src/bridge/sse.ts +1444 -0
  32. package/src/bridge.ts +5 -2204
  33. package/src/chat/inbound.ts +12 -1
  34. package/src/codex/account-lifecycle.ts +3 -0
  35. package/src/codex/account-store.ts +71 -9
  36. package/src/codex/auth-api/account-list.ts +507 -0
  37. package/src/codex/auth-api/http.ts +32 -0
  38. package/src/codex/auth-api/login-flow.ts +554 -0
  39. package/src/codex/auth-api/login-state.ts +64 -0
  40. package/src/codex/auth-api/main-account-probe.ts +331 -0
  41. package/src/codex/auth-api/pool-mode-gate.ts +274 -0
  42. package/src/codex/auth-api/pool-quota-probe.ts +512 -0
  43. package/src/codex/auth-api/reset-credit-service.ts +422 -0
  44. package/src/codex/auth-api/routes.ts +425 -0
  45. package/src/codex/auth-api/runtime-config.ts +48 -0
  46. package/src/codex/auth-api.ts +27 -3118
  47. package/src/codex/auth-context.ts +95 -28
  48. package/src/codex/catalog/auto-review.ts +507 -0
  49. package/src/codex/catalog/build-entries.ts +981 -0
  50. package/src/codex/catalog/combo-member.ts +375 -0
  51. package/src/codex/catalog/derive-entry.ts +229 -0
  52. package/src/codex/catalog/effort.ts +0 -1
  53. package/src/codex/catalog/gated-native-warn.ts +63 -0
  54. package/src/codex/catalog/gather-capture.ts +533 -0
  55. package/src/codex/catalog/model-hints.ts +691 -0
  56. package/src/codex/catalog/model-visibility.ts +304 -0
  57. package/src/codex/catalog/provider-fetch.ts +52 -2942
  58. package/src/codex/catalog/provider-models.ts +685 -0
  59. package/src/codex/catalog/restore.ts +132 -0
  60. package/src/codex/catalog/retained-sync.ts +706 -0
  61. package/src/codex/catalog/routed-gather.ts +858 -0
  62. package/src/codex/catalog/subagent-roster.ts +176 -0
  63. package/src/codex/catalog/sync.ts +52 -2698
  64. package/src/codex/inject/config-toml.ts +563 -0
  65. package/src/codex/inject/remove.ts +192 -0
  66. package/src/codex/inject/restore.ts +540 -0
  67. package/src/codex/inject/routing-classify.ts +109 -0
  68. package/src/codex/inject/routing-target.ts +125 -0
  69. package/src/codex/inject.ts +81 -1436
  70. package/src/codex/lineage.ts +458 -0
  71. package/src/codex/pool-refresh-backoff.ts +152 -0
  72. package/src/codex/routing/active-account.ts +194 -0
  73. package/src/codex/routing/cooldown-math.ts +275 -0
  74. package/src/codex/routing/health-store.ts +402 -0
  75. package/src/codex/routing/probe-lease.ts +358 -0
  76. package/src/codex/routing/selection.ts +703 -0
  77. package/src/codex/routing/thread-affinity.ts +538 -0
  78. package/src/codex/routing.ts +353 -2234
  79. package/src/codex/shim-fingerprint.ts +223 -0
  80. package/src/codex/shim-inspect.ts +175 -0
  81. package/src/codex/shim-probe.ts +367 -0
  82. package/src/codex/shim-restore-lock.ts +169 -0
  83. package/src/codex/shim-state-file.ts +151 -0
  84. package/src/codex/shim-templates.ts +265 -0
  85. package/src/codex/shim.ts +48 -1268
  86. package/src/config/diagnostics.ts +705 -0
  87. package/src/config/feature-flags.ts +55 -0
  88. package/src/config/live-reconcile.ts +403 -0
  89. package/src/config/load-degrade.ts +880 -0
  90. package/src/config/mutation-lock.ts +244 -0
  91. package/src/config/openai-tier-backup.ts +268 -0
  92. package/src/config/persist-unlocked.ts +92 -0
  93. package/src/config/proxy-env.ts +188 -0
  94. package/src/config/salvage.ts +244 -0
  95. package/src/config/schema/config-schema.ts +640 -0
  96. package/src/config/schema/leaf-validators.ts +855 -0
  97. package/src/config/warn-memo.ts +28 -0
  98. package/src/config.ts +234 -4481
  99. package/src/generated/compatibility-version.json +539 -39
  100. package/src/lib/request-execution-budget.ts +69 -20
  101. package/src/lib/spend-reservation-ledger.ts +940 -0
  102. package/src/lib/upstream-retry.ts +55 -11
  103. package/src/lib/workflow-budget.ts +553 -30
  104. package/src/providers/quota/account-cache.ts +441 -0
  105. package/src/providers/quota/antigravity.ts +295 -0
  106. package/src/providers/quota/report-cache.ts +320 -0
  107. package/src/providers/quota/vendor-probes-key.ts +1243 -0
  108. package/src/providers/quota/vendor-probes-oauth.ts +590 -0
  109. package/src/providers/quota.ts +324 -3079
  110. package/src/providers/registry/entries-core.ts +1221 -0
  111. package/src/providers/registry/entries-extended.ts +1204 -0
  112. package/src/providers/registry/model-seeds.ts +908 -0
  113. package/src/providers/registry/types.ts +352 -0
  114. package/src/providers/registry.ts +24 -3536
  115. package/src/responses/continuation-ownership.ts +29 -0
  116. package/src/responses/state/replay-fingerprint.ts +80 -0
  117. package/src/responses/state/snapshot-codec.ts +104 -0
  118. package/src/responses/state/spill-failure.ts +118 -0
  119. package/src/responses/state/spill-queue.ts +665 -0
  120. package/src/responses/state/temp-recovery.ts +257 -0
  121. package/src/responses/state.ts +82 -1143
  122. package/src/routing/identity-domains.ts +449 -0
  123. package/src/routing/probe-lease.ts +511 -0
  124. package/src/server/index/bounded-request.ts +88 -0
  125. package/src/server/index/live-sideband.ts +565 -0
  126. package/src/server/index/serve-options.ts +1766 -0
  127. package/src/server/index/startup-warnings.ts +213 -0
  128. package/src/server/index/websocket-handler.ts +335 -0
  129. package/src/server/index.ts +40 -2547
  130. package/src/server/management/route-registry.ts +26 -23
  131. package/src/server/management/shared.ts +8 -5
  132. package/src/server/management/workflow-budget-routes.ts +133 -0
  133. package/src/server/management-api.ts +12 -0
  134. package/src/server/request-log-conversation.ts +9 -7
  135. package/src/server/request-log.ts +245 -1
  136. package/src/server/responses/account-change-state.ts +233 -0
  137. package/src/server/responses/adapter-continuation.ts +514 -0
  138. package/src/server/responses/adapter-delivery.ts +214 -0
  139. package/src/server/responses/adapter-dispatch.ts +971 -0
  140. package/src/server/responses/compact.ts +59 -4
  141. package/src/server/responses/completion-policy.ts +33 -0
  142. package/src/server/responses/core-auth.ts +527 -0
  143. package/src/server/responses/core-codex-account.ts +859 -0
  144. package/src/server/responses/core-combo-failure.ts +210 -0
  145. package/src/server/responses/core-combo.ts +707 -0
  146. package/src/server/responses/core-errors.ts +152 -0
  147. package/src/server/responses/core-lifetime.ts +95 -0
  148. package/src/server/responses/core-normalize.ts +350 -0
  149. package/src/server/responses/core-opaque-recovery.ts +380 -0
  150. package/src/server/responses/core-options.ts +159 -0
  151. package/src/server/responses/core-replay.ts +225 -0
  152. package/src/server/responses/core.ts +192 -8893
  153. package/src/server/responses/passthrough-delivery.ts +856 -0
  154. package/src/server/responses/passthrough-dispatch.ts +1476 -0
  155. package/src/server/responses/passthrough-execution.ts +54 -0
  156. package/src/server/responses/request-prepare.ts +970 -0
  157. package/src/server/responses/request-send-budget.ts +164 -0
  158. package/src/server/responses/request-sidecar-auth.ts +149 -0
  159. package/src/server/responses/request-transport.ts +744 -0
  160. package/src/server/responses/response-effects.ts +157 -0
  161. package/src/server/responses/run-turn-execution.ts +448 -0
  162. package/src/server/responses/sidecar-execution.ts +469 -0
  163. package/src/server/responses-image-gen-repair.ts +1 -1
  164. package/src/server/workflow-refusal.ts +84 -0
  165. package/src/types/config.ts +30 -0
  166. package/src/usage/log.ts +146 -0
  167. package/src/usage/summary.ts +171 -21
@@ -0,0 +1,511 @@
1
+ /**
2
+ * Half-open recovery for a held account, and the pool-wide retry/probe budget
3
+ * that sits above it (#4546, wp3 follow-up).
4
+ *
5
+ * The transient hold (#4616) keeps a thread's binding while its account serves a
6
+ * 5xx streak, and detours requests to a healthy sibling. What it cannot answer is
7
+ * whether the held account is actually back: a soft-avoided account receives no
8
+ * traffic, so the two-success clearing rule can only fire through the "held"
9
+ * fallback, which hands the failing account back to every pinned thread at once.
10
+ * This module is the bounded trial that closes that gap -- a single-holder probe
11
+ * lease keyed on the health domain (the quota-cooldown domain already has its own
12
+ * lease in src/codex/routing.ts and is a different thing).
13
+ *
14
+ * Three rules the lease enforces:
15
+ *
16
+ * - While an account is held, exactly one in-flight probe may test it. Every
17
+ * other request keeps the remembered detour, so a failed probe costs the
18
+ * caller nothing -- the detour's identity is never dropped to run the trial.
19
+ * - The lease has a deadline and is released on success, failure, or expiry. A
20
+ * response that arrives after its lease was lost is STALE: it must not
21
+ * overwrite a newer binding or a newer failure state, so every lease carries
22
+ * the generation it was issued under and a settle that fails the fence
23
+ * mutates nothing.
24
+ * - When every candidate is held the caller gets a typed "binding remembered,
25
+ * dispatch withheld" outcome -- not a send to an account already known to be
26
+ * failing.
27
+ *
28
+ * The pool-wide limiter exists because per-request send budgets do not prevent
29
+ * a retry storm: thousands of requests each staying inside their own allowance
30
+ * still compose into an unbounded rate against an already-failing upstream.
31
+ * Recovery dispatches (retries and probes, never the initial send of a new
32
+ * request) are admitted only while they stay under a ratio of observed initial
33
+ * sends in a sliding window -- the standard overload-guidance shape.
34
+ */
35
+
36
+ /** How long a granted probe may be in flight before its lease is forfeit. */
37
+ export const TRANSIENT_PROBE_LEASE_MS = 30_000;
38
+ /**
39
+ * Minimum spacing between probes of the same held account. Without it every
40
+ * request that follows a settled probe becomes the next probe, which is the
41
+ * same storm the single-holder rule exists to bound -- just serialized.
42
+ */
43
+ export const TRANSIENT_PROBE_INTERVAL_MS = 15_000;
44
+
45
+ /**
46
+ * Grace kept on top of an entry's pacing and lease deadlines before it may be forgotten.
47
+ * Inside it a late settle can still answer "expired" rather than "stale", which is the
48
+ * distinction the settle contract exists to report.
49
+ */
50
+ const PROBE_STATE_RETENTION_MS = 60_000;
51
+ /**
52
+ * Hard ceiling on remembered accounts. Pacing state is per account id, and account ids churn
53
+ * with configuration: without a ceiling a long-lived proxy accumulates one entry per id it
54
+ * ever probed. Above the ceiling the entries whose pacing lapses soonest are dropped, which
55
+ * at worst lets one dormant account be probed earlier than its interval; an entry holding a
56
+ * LIVE lease is never dropped, because that would hand out a second concurrent probe and
57
+ * break the single-holder rule the lease exists to enforce.
58
+ */
59
+ export const MAX_TRANSIENT_PROBE_STATES = 1_024;
60
+ /** Below this the map is too small to be worth scanning on a grant. */
61
+ const PROBE_STATE_SWEEP_THRESHOLD = 64;
62
+ /**
63
+ * Eviction target once the ceiling is reached. Clearing a block at a time keeps the ordering
64
+ * pass off the common grant path: it runs once per block of new accounts instead of once per
65
+ * grant forever after the first time the ceiling is touched.
66
+ */
67
+ const PROBE_STATE_EVICTION_LOW_WATER = Math.floor(MAX_TRANSIENT_PROBE_STATES * 0.9);
68
+
69
+ export interface TransientProbeLease {
70
+ readonly accountId: string;
71
+ readonly leaseId: string;
72
+ /** Epoch the lease was issued under; a settle must match the CURRENT epoch. */
73
+ readonly generation: number;
74
+ readonly expiresAt: number;
75
+ }
76
+
77
+ export type TransientProbeOutcome = "recovered" | "failed";
78
+
79
+ /**
80
+ * What a settle did to the lease.
81
+ *
82
+ * - `applied`: the probe still held the lease inside its deadline; the caller
83
+ * may act on the outcome (clear the hold, or record the fresh failure).
84
+ * - `stale`: the lease was already lost -- expired and re-issued, or invalidated
85
+ * by newer authoritative state. The result is dropped; nothing is overwritten.
86
+ * - `expired`: the probe finished after its own deadline. The lease is dead
87
+ * either way; this answer exists so the caller can tell "lost a race" from
88
+ * "ran long".
89
+ */
90
+ export type TransientProbeSettle = "applied" | "stale" | "expired";
91
+
92
+ interface AccountProbeState {
93
+ /** Bumped on every lease grant and every external invalidation. */
94
+ generation: number;
95
+ leaseId?: string;
96
+ leaseExpiresAt?: number;
97
+ lastProbeAt?: number;
98
+ /**
99
+ * Moment this account's pacing interval lapses, recorded at grant time from the interval
100
+ * that grant actually used. Kept alongside `lastProbeAt` so cleanup honours a caller's
101
+ * longer interval instead of assuming the default.
102
+ */
103
+ pacedUntil?: number;
104
+ lastOutcome?: TransientProbeOutcome;
105
+ }
106
+
107
+ const probeStates = new Map<string, AccountProbeState>();
108
+ let probeLeaseSeq = 0;
109
+
110
+ function probeStateFor(accountId: string): AccountProbeState {
111
+ let state = probeStates.get(accountId);
112
+ if (!state) {
113
+ state = { generation: 0 };
114
+ probeStates.set(accountId, state);
115
+ }
116
+ return state;
117
+ }
118
+
119
+ function liveLease(state: AccountProbeState, now: number): boolean {
120
+ return state.leaseId !== undefined && state.leaseExpiresAt !== undefined && state.leaseExpiresAt > now;
121
+ }
122
+
123
+ /**
124
+ * Moment an entry stops carrying anything a future decision can read: its pacing interval and
125
+ * any unsettled lease deadline, plus the grace above.
126
+ */
127
+ function probeStateRetiresAt(state: AccountProbeState): number {
128
+ return Math.max(state.pacedUntil ?? 0, state.leaseExpiresAt ?? 0) + PROBE_STATE_RETENTION_MS;
129
+ }
130
+
131
+ /**
132
+ * Bound the remembered accounts. Called on the one path that can grow the map -- a grant is
133
+ * the only insertion -- so the ceiling holds without a timer.
134
+ *
135
+ * The first pass drops only entries that can no longer change an answer: no live lease, the
136
+ * pacing interval lapsed, and the grace elapsed. Re-creating such an entry later yields the
137
+ * same decisions it would have produced, and a late settle against it still cannot be applied
138
+ * because lease ids are issued from a monotonic counter and never repeat.
139
+ */
140
+ function sweepProbeStates(now: number): void {
141
+ if (probeStates.size <= PROBE_STATE_SWEEP_THRESHOLD) return;
142
+ for (const [accountId, state] of probeStates) {
143
+ if (liveLease(state, now)) continue;
144
+ if (now >= probeStateRetiresAt(state)) probeStates.delete(accountId);
145
+ }
146
+ if (probeStates.size <= MAX_TRANSIENT_PROBE_STATES) return;
147
+ // Still over the ceiling with nothing retired: churn is faster than the retention window.
148
+ // Evict in retirement order so the entries closest to meaningless go first, and never one
149
+ // holding a live lease.
150
+ const evictable = Array.from(probeStates)
151
+ .filter(([, state]) => !liveLease(state, now))
152
+ .sort((a, b) => probeStateRetiresAt(a[1]) - probeStateRetiresAt(b[1]));
153
+ let excess = probeStates.size - PROBE_STATE_EVICTION_LOW_WATER;
154
+ for (const [accountId] of evictable) {
155
+ if (excess <= 0) break;
156
+ probeStates.delete(accountId);
157
+ excess -= 1;
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Grant the single in-flight probe for a held account, or null when another
163
+ * probe is already out or the pacing interval has not elapsed. The grant bumps
164
+ * the epoch, so a result from any earlier lease is stale the moment it lands.
165
+ */
166
+ export function tryAcquireTransientProbe(
167
+ accountId: string,
168
+ now = Date.now(),
169
+ options?: { leaseMs?: number; minIntervalMs?: number },
170
+ ): TransientProbeLease | null {
171
+ const state = probeStateFor(accountId);
172
+ if (liveLease(state, now)) return null;
173
+ const interval = options?.minIntervalMs ?? TRANSIENT_PROBE_INTERVAL_MS;
174
+ if (state.lastProbeAt !== undefined && now - state.lastProbeAt < interval) return null;
175
+ const leaseMs = options?.leaseMs ?? TRANSIENT_PROBE_LEASE_MS;
176
+ const leaseId = `tprobe-${(probeLeaseSeq += 1).toString(36)}`;
177
+ const expiresAt = now + Math.max(1, leaseMs);
178
+ state.generation += 1;
179
+ state.leaseId = leaseId;
180
+ state.leaseExpiresAt = expiresAt;
181
+ state.lastProbeAt = now;
182
+ state.pacedUntil = now + Math.max(0, interval);
183
+ // After the grant, not before it: the entry this call just wrote holds a live lease and is
184
+ // therefore the one entry the sweep may never touch, so the ceiling is a real ceiling
185
+ // rather than "the ceiling plus whatever was inserted after the scan".
186
+ sweepProbeStates(now);
187
+ return {
188
+ accountId,
189
+ leaseId,
190
+ generation: state.generation,
191
+ expiresAt,
192
+ };
193
+ }
194
+
195
+ /** Side-effect-free mirror of {@link tryAcquireTransientProbe} eligibility. */
196
+ export function canAcquireTransientProbe(
197
+ accountId: string,
198
+ now = Date.now(),
199
+ options?: { minIntervalMs?: number },
200
+ ): boolean {
201
+ const state = probeStates.get(accountId);
202
+ if (!state) return true;
203
+ if (liveLease(state, now)) return false;
204
+ const interval = options?.minIntervalMs ?? TRANSIENT_PROBE_INTERVAL_MS;
205
+ return state.lastProbeAt === undefined || now - state.lastProbeAt >= interval;
206
+ }
207
+
208
+ /**
209
+ * Report a probe's outcome. Only the current lease holder inside its deadline
210
+ * applies: anything else is a late answer from a probe that already lost, and
211
+ * dropping it is what keeps it from overwriting a newer binding or a newer
212
+ * failure state. An applied settle clears the lease so the next probe is paced
213
+ * by the interval, not by the expiry.
214
+ */
215
+ export function settleTransientProbe(
216
+ lease: TransientProbeLease,
217
+ outcome: TransientProbeOutcome,
218
+ now = Date.now(),
219
+ ): TransientProbeSettle {
220
+ const state = probeStates.get(lease.accountId);
221
+ if (!state || state.leaseId !== lease.leaseId || state.generation !== lease.generation) {
222
+ return "stale";
223
+ }
224
+ // `>=`, matching liveLease: at exactly the deadline the lease is already gone, so applying
225
+ // the outcome there would let a probe act on a lease the grant path would refuse to
226
+ // recognise -- two answers to the same instant.
227
+ if (now >= lease.expiresAt) return "expired";
228
+ state.leaseId = undefined;
229
+ state.leaseExpiresAt = undefined;
230
+ state.lastOutcome = outcome;
231
+ return "applied";
232
+ }
233
+
234
+ /**
235
+ * Hand a lease back with no outcome -- the probe never reached upstream, so
236
+ * there is nothing to record. Only the holder may release; a stale lease is
237
+ * already dead and needs no cleanup.
238
+ */
239
+ export function releaseTransientProbe(lease: TransientProbeLease): void {
240
+ const state = probeStates.get(lease.accountId);
241
+ if (!state || state.leaseId !== lease.leaseId || state.generation !== lease.generation) return;
242
+ state.leaseId = undefined;
243
+ state.leaseExpiresAt = undefined;
244
+ }
245
+
246
+ /**
247
+ * Fence the epoch against newer authoritative state. A fresh failure recorded
248
+ * through the ordinary outcome path, or a binding that moved on, must not be
249
+ * overwritten by a probe result that was issued before it -- bumping the epoch
250
+ * makes every outstanding lease stale without waiting for its deadline.
251
+ */
252
+ export function invalidateTransientProbe(accountId: string): void {
253
+ const state = probeStates.get(accountId);
254
+ if (!state) return;
255
+ state.generation += 1;
256
+ state.leaseId = undefined;
257
+ state.leaseExpiresAt = undefined;
258
+ }
259
+
260
+ export interface TransientProbeDiagnostics {
261
+ readonly held: boolean;
262
+ readonly generation: number;
263
+ readonly leaseId?: string;
264
+ readonly leaseExpiresAt?: number;
265
+ readonly lastProbeAt?: number;
266
+ readonly lastOutcome?: TransientProbeOutcome;
267
+ }
268
+
269
+ /** Current lease state for one account, for diagnostics. Never mutates. */
270
+ export function transientProbeDiagnostics(accountId: string, now = Date.now()): TransientProbeDiagnostics {
271
+ const state = probeStates.get(accountId);
272
+ if (!state) return { held: false, generation: 0 };
273
+ return {
274
+ held: liveLease(state, now),
275
+ generation: state.generation,
276
+ ...(state.leaseId !== undefined ? { leaseId: state.leaseId, leaseExpiresAt: state.leaseExpiresAt } : {}),
277
+ ...(state.lastProbeAt !== undefined ? { lastProbeAt: state.lastProbeAt } : {}),
278
+ ...(state.lastOutcome !== undefined ? { lastOutcome: state.lastOutcome } : {}),
279
+ };
280
+ }
281
+
282
+ /** Test seam: lease state is module-global and must not leak between cases. */
283
+ export function clearTransientProbeLeasesForTests(): void {
284
+ probeStates.clear();
285
+ }
286
+
287
+ /**
288
+ * How many accounts currently carry probe state. Diagnostic, and the assertion surface for
289
+ * the {@link MAX_TRANSIENT_PROBE_STATES} bound.
290
+ */
291
+ export function transientProbeStateCount(): number {
292
+ return probeStates.size;
293
+ }
294
+
295
+ /**
296
+ * What a request may do while its bound account is held.
297
+ *
298
+ * - `probe`: this caller holds the lease and may send ONE trial to the held
299
+ * account.
300
+ * - `detour`: a probe is already out (or was refused); keep the remembered
301
+ * detour. The detour's identity survives the whole probing window -- a failed
302
+ * trial must not cost the caller its working route.
303
+ * - `withheld`: every candidate is held. The binding is remembered and dispatch
304
+ * is refused; `retryAt` is the earliest moment a probe could next go out.
305
+ * Sending anyway here is exactly the "must not send, sends anyway" defect the
306
+ * hold was added to close.
307
+ */
308
+ export type HeldAccountDispatch =
309
+ | { kind: "probe"; lease: TransientProbeLease }
310
+ | { kind: "detour"; accountId: string }
311
+ | { kind: "withheld"; boundAccountId: string; detourAccountId?: string; retryAt: number };
312
+
313
+ /**
314
+ * Decide what a request bound to a held account may do this turn. The probe is
315
+ * tried first -- somebody has to find out whether the account is back, and the
316
+ * lease guarantees it is exactly one somebody. Everyone else keeps the detour,
317
+ * and a caller with no detour left is told to wait rather than sent at an
318
+ * account already known to be failing.
319
+ */
320
+ export function resolveHeldAccountDispatch(input: {
321
+ boundAccountId: string;
322
+ detourAccountId?: string;
323
+ now?: number;
324
+ leaseMs?: number;
325
+ minProbeIntervalMs?: number;
326
+ backpressure?: PoolBackpressureLimiter;
327
+ }): HeldAccountDispatch {
328
+ const now = input.now ?? Date.now();
329
+ const limiter = input.backpressure ?? sharedPoolBackpressure();
330
+ // The lease check runs before the budget charge: a probe another holder already has out is
331
+ // not a dispatch, and charging the pool for it would shrink the recovery budget by phantom
332
+ // sends. Between the check and the grant there is no await, so eligibility cannot change.
333
+ if (
334
+ canAcquireTransientProbe(input.boundAccountId, now, {
335
+ ...(input.minProbeIntervalMs !== undefined ? { minIntervalMs: input.minProbeIntervalMs } : {}),
336
+ })
337
+ && limiter.tryPermitProbeDispatch(now)
338
+ ) {
339
+ const lease = tryAcquireTransientProbe(input.boundAccountId, now, {
340
+ ...(input.leaseMs !== undefined ? { leaseMs: input.leaseMs } : {}),
341
+ ...(input.minProbeIntervalMs !== undefined ? { minIntervalMs: input.minProbeIntervalMs } : {}),
342
+ });
343
+ if (lease) return { kind: "probe", lease };
344
+ }
345
+ if (input.detourAccountId !== undefined && input.detourAccountId !== input.boundAccountId) {
346
+ return { kind: "detour", accountId: input.detourAccountId };
347
+ }
348
+ return {
349
+ kind: "withheld",
350
+ boundAccountId: input.boundAccountId,
351
+ ...(input.detourAccountId !== undefined ? { detourAccountId: input.detourAccountId } : {}),
352
+ retryAt: nextProbeAt(input.boundAccountId, now, input.minProbeIntervalMs),
353
+ };
354
+ }
355
+
356
+ /** Earliest moment a probe of this account could next be granted. */
357
+ function nextProbeAt(accountId: string, now: number, minIntervalMs?: number): number {
358
+ const state = probeStates.get(accountId);
359
+ if (!state) return now;
360
+ const interval = minIntervalMs ?? TRANSIENT_PROBE_INTERVAL_MS;
361
+ const paced = state.lastProbeAt !== undefined ? state.lastProbeAt + interval : now;
362
+ const leased = liveLease(state, now) ? state.leaseExpiresAt! : now;
363
+ return Math.max(paced, leased);
364
+ }
365
+
366
+ /* ------------------------------------------------------------------ */
367
+ /* Pool-wide recovery backpressure */
368
+ /* ------------------------------------------------------------------ */
369
+
370
+ export interface PoolBackpressurePolicy {
371
+ /** Sliding window the ratio is measured over. */
372
+ readonly windowMs: number;
373
+ /**
374
+ * Recovery dispatches (retries + probes) admitted per observed initial send.
375
+ * 0.2 is the standard overload-guidance budget: at most one recovery send for
376
+ * every five new requests.
377
+ */
378
+ readonly maxRetryRatio: number;
379
+ /**
380
+ * Floor under the ratio so a quiet pool can still recover: with almost no
381
+ * traffic a strict ratio admits nothing, which would wedge every held
382
+ * account behind a probe that can never run.
383
+ */
384
+ readonly minRecoveryAllowance: number;
385
+ }
386
+
387
+ export const DEFAULT_POOL_BACKPRESSURE_POLICY: PoolBackpressurePolicy = {
388
+ windowMs: 10_000,
389
+ maxRetryRatio: 0.2,
390
+ minRecoveryAllowance: 3,
391
+ };
392
+
393
+ export interface PoolBackpressureState {
394
+ readonly windowMs: number;
395
+ readonly initialSends: number;
396
+ readonly recoveryDispatches: number;
397
+ /** Dispatches admitted under the current window's allowance. */
398
+ readonly allowance: number;
399
+ /** Lifetime refusals, including windows already rotated out. */
400
+ readonly refusedTotal: number;
401
+ readonly ratioLimit: number;
402
+ }
403
+
404
+ export interface PoolBackpressureLimiter {
405
+ /** A new request's FIRST send. Always recorded, never refused. */
406
+ recordInitialSend(now?: number): void;
407
+ /** Admit one retry dispatch, or refuse when the window's ratio is spent. */
408
+ tryPermitRetryDispatch(now?: number): boolean;
409
+ /** Admit one probe dispatch under the same shared recovery budget. */
410
+ tryPermitProbeDispatch(now?: number): boolean;
411
+ state(now?: number): PoolBackpressureState;
412
+ }
413
+
414
+ const BACKPRESSURE_BUCKETS = 10;
415
+
416
+ /**
417
+ * Ratio limiter over a bucketed sliding window. Buckets give a sliding answer
418
+ * without keeping per-event state: the window is the sum of the buckets whose
419
+ * span falls inside it, and grant/refuse decisions read that sum.
420
+ */
421
+ export function createPoolBackpressureLimiter(
422
+ policy: PoolBackpressurePolicy = DEFAULT_POOL_BACKPRESSURE_POLICY,
423
+ ): PoolBackpressureLimiter {
424
+ const bucketMs = Math.max(1, Math.floor(policy.windowMs / BACKPRESSURE_BUCKETS));
425
+ const buckets: Array<{ start: number; initials: number; recoveries: number }> = [];
426
+ let refusedTotal = 0;
427
+
428
+ function bucketFor(now: number): { start: number; initials: number; recoveries: number } {
429
+ const start = Math.floor(now / bucketMs) * bucketMs;
430
+ const last = buckets[buckets.length - 1];
431
+ if (last && last.start === start) return last;
432
+ while (buckets.length > 0 && buckets[0]!.start <= start - policy.windowMs) buckets.shift();
433
+ const bucket = { start, initials: 0, recoveries: 0 };
434
+ buckets.push(bucket);
435
+ return bucket;
436
+ }
437
+
438
+ function totals(now: number): { initials: number; recoveries: number } {
439
+ let initials = 0;
440
+ let recoveries = 0;
441
+ for (const bucket of buckets) {
442
+ if (bucket.start <= now - policy.windowMs) continue;
443
+ initials += bucket.initials;
444
+ recoveries += bucket.recoveries;
445
+ }
446
+ return { initials, recoveries };
447
+ }
448
+
449
+ function allowanceFor(initials: number): number {
450
+ return Math.max(policy.minRecoveryAllowance, Math.floor(initials * policy.maxRetryRatio));
451
+ }
452
+
453
+ function tryPermit(now: number): boolean {
454
+ const bucket = bucketFor(now);
455
+ const { initials, recoveries } = totals(now);
456
+ if (recoveries + 1 > allowanceFor(initials)) {
457
+ refusedTotal += 1;
458
+ return false;
459
+ }
460
+ bucket.recoveries += 1;
461
+ return true;
462
+ }
463
+
464
+ return {
465
+ recordInitialSend(now = Date.now()): void {
466
+ bucketFor(now).initials += 1;
467
+ },
468
+ tryPermitRetryDispatch(now = Date.now()): boolean {
469
+ return tryPermit(now);
470
+ },
471
+ tryPermitProbeDispatch(now = Date.now()): boolean {
472
+ return tryPermit(now);
473
+ },
474
+ state(now = Date.now()): PoolBackpressureState {
475
+ const { initials, recoveries } = totals(now);
476
+ return {
477
+ windowMs: policy.windowMs,
478
+ initialSends: initials,
479
+ recoveryDispatches: recoveries,
480
+ allowance: allowanceFor(initials),
481
+ refusedTotal,
482
+ ratioLimit: policy.maxRetryRatio,
483
+ };
484
+ },
485
+ };
486
+ }
487
+
488
+ let sharedLimiter: PoolBackpressureLimiter | undefined;
489
+
490
+ /**
491
+ * The process-wide limiter every recovery dispatch shares. A per-request
492
+ * limiter cannot see the storm, which is the entire reason this layer exists.
493
+ */
494
+ export function sharedPoolBackpressure(): PoolBackpressureLimiter {
495
+ sharedLimiter ??= createPoolBackpressureLimiter();
496
+ return sharedLimiter;
497
+ }
498
+
499
+ /**
500
+ * Point the shared limiter at a different policy. The ceiling is deliberately
501
+ * configurable here and not yet plumbed into OcxConfig -- the wiring lane owns
502
+ * that seam; this is the knob it turns.
503
+ */
504
+ export function configureSharedPoolBackpressure(policy: PoolBackpressurePolicy): void {
505
+ sharedLimiter = createPoolBackpressureLimiter(policy);
506
+ }
507
+
508
+ /** Test seam: the shared limiter is module-global. */
509
+ export function resetSharedPoolBackpressureForTests(): void {
510
+ sharedLimiter = undefined;
511
+ }
@@ -0,0 +1,88 @@
1
+ import {
2
+ assertServerAuthConfig,
3
+ corsHeaders,
4
+ managementCorsHeaders,
5
+ isAllowedRequestOrigin,
6
+ isAllowedManagementOrigin,
7
+ isApiAuthRequired,
8
+ isLoopbackHostname,
9
+ jsonResponse,
10
+ admissionFields,
11
+ resolveApiAuth,
12
+ resolveResponsesApiAuth,
13
+ requestPolicyView,
14
+ type DataPlaneAdmission,
15
+ type RequestPolicyView,
16
+ safeConfigDTO,
17
+ setCorsOrigin,
18
+ withCors,
19
+ withManagementCors,
20
+ } from "../auth-cors";
21
+
22
+ // Header-safe by construction: a key id reaches a response header, so anything outside this
23
+ // class could inject a header break or a control character into a response we control.
24
+ const REMOTE_CATALOG_KEY_ID_PATTERN = /^[A-Za-z0-9._-]{1,64}$/;
25
+ export const GUI_PAIRING_EXCHANGE_BODY_LIMIT = 4 * 1024;
26
+ export const REMOTE_WORKSPACE_PAIRING_BODY_LIMIT = 32 * 1024;
27
+
28
+ /**
29
+ * Read at most `limit` bytes of a request body, or refuse.
30
+ *
31
+ * Returns null the moment the body is known to exceed `limit`, without retaining the excess.
32
+ * `req.text()` cannot express that: it buffers to completion first, so a caller who omits
33
+ * Content-Length or uses chunked framing decides how much memory the process spends. That
34
+ * matters here because the one caller is an unauthenticated endpoint.
35
+ *
36
+ * limit+1 is the stopping point rather than limit, so a body exactly at the limit is still
37
+ * accepted and only a genuinely over-limit body is rejected.
38
+ */
39
+ export async function readBoundedRequestText(req: Request, limit: number): Promise<string | null> {
40
+ const body = req.body;
41
+ if (!body) return "";
42
+ const reader = body.getReader();
43
+ const chunks: Uint8Array[] = [];
44
+ let total = 0;
45
+ try {
46
+ for (;;) {
47
+ const { done, value } = await reader.read();
48
+ if (done) break;
49
+ if (!value || value.byteLength === 0) continue;
50
+ total += value.byteLength;
51
+ if (total > limit) return null;
52
+ chunks.push(value);
53
+ }
54
+ } finally {
55
+ // Cancel rather than only releasing the lock: on the reject path the peer may still be
56
+ // sending, and an uncancelled body keeps that transfer alive.
57
+ await reader.cancel().catch(() => {});
58
+ }
59
+ const joined = new Uint8Array(total);
60
+ let offset = 0;
61
+ for (const chunk of chunks) {
62
+ joined.set(chunk, offset);
63
+ offset += chunk.byteLength;
64
+ }
65
+ return new TextDecoder().decode(joined);
66
+ }
67
+
68
+ /**
69
+ * Name WHICH configured credential was admitted, so a multi-key operator can attribute a
70
+ * catalog read.
71
+ *
72
+ * Scoped to configured keys on purpose: an environment token or a loopback bind has no key
73
+ * to name, and emitting one anyway would invent an attribution that does not exist. 200 only
74
+ * — this route emits no validator and therefore never answers 304.
75
+ *
76
+ * An id that fails the header-safe pattern is omitted rather than sanitized, with one warning
77
+ * that does NOT repeat the id: logging the offending value is how a malformed id becomes a
78
+ * log-injection vector instead of a dropped header.
79
+ */
80
+ export function withRemoteCatalogKeyId(response: Response, admission: DataPlaneAdmission): Response {
81
+ if (response.status !== 200 || admission.kind !== "configured") return response;
82
+ if (!REMOTE_CATALOG_KEY_ID_PATTERN.test(admission.keyId)) {
83
+ console.warn("[remote-catalog] configured API key id is not header-safe; omitting x-opencodex-key-id");
84
+ return response;
85
+ }
86
+ response.headers.set("x-opencodex-key-id", admission.keyId);
87
+ return response;
88
+ }