@bitkyc08/opencodex 2.55.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 (256) 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-VuoiWj9J.js → index-Cz7CLdif.js} +21 -21
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +4 -3
  6. package/src/adapters/base.ts +21 -0
  7. package/src/adapters/codebuddy/adapter.ts +2 -1
  8. package/src/adapters/codebuddy/scaffold-guard.ts +248 -0
  9. package/src/adapters/command-code.ts +1 -1
  10. package/src/adapters/cursor/envelope-echo.ts +8 -2
  11. package/src/adapters/cursor/transport-retry.ts +46 -1
  12. package/src/adapters/cursor.ts +4 -0
  13. package/src/adapters/google.ts +7 -7
  14. package/src/adapters/kiro/adapter.ts +42 -1
  15. package/src/adapters/kiro/payload.ts +17 -3
  16. package/src/adapters/kiro/reasoning.ts +70 -7
  17. package/src/adapters/kiro/stream.ts +8 -2
  18. package/src/adapters/kiro/wire.ts +2 -1
  19. package/src/adapters/kiro-events.ts +21 -13
  20. package/src/adapters/kiro-retry.ts +23 -4
  21. package/src/adapters/openai-chat/errors.ts +116 -0
  22. package/src/adapters/openai-chat/messages.ts +346 -0
  23. package/src/adapters/openai-chat/passthrough.ts +146 -0
  24. package/src/adapters/openai-chat/response-events.ts +117 -0
  25. package/src/adapters/openai-chat/tool-call-validation.ts +200 -0
  26. package/src/adapters/openai-chat/tool-name-registry.ts +166 -0
  27. package/src/adapters/openai-chat/tool-schema.ts +495 -0
  28. package/src/adapters/openai-chat/wire.ts +50 -0
  29. package/src/adapters/openai-chat.ts +40 -1452
  30. package/src/adapters/openai-responses/canonical-forward.ts +202 -0
  31. package/src/adapters/openai-responses/image-gen.ts +406 -0
  32. package/src/adapters/openai-responses/internal.ts +3 -0
  33. package/src/adapters/openai-responses/passthrough.ts +642 -0
  34. package/src/adapters/openai-responses/prompt-cache.ts +83 -0
  35. package/src/adapters/openai-responses/reasoning.ts +220 -0
  36. package/src/adapters/openai-responses/request-strips.ts +185 -0
  37. package/src/adapters/openai-responses/tool-output-recovery.ts +509 -0
  38. package/src/adapters/openai-responses/tool-schema.ts +293 -0
  39. package/src/adapters/openai-responses/web-search.ts +156 -0
  40. package/src/adapters/openai-responses.ts +4 -2625
  41. package/src/bridge/errors.ts +58 -0
  42. package/src/bridge/internal.ts +174 -0
  43. package/src/bridge/response-json.ts +630 -0
  44. package/src/bridge/sse.ts +1462 -0
  45. package/src/bridge.ts +5 -2204
  46. package/src/chat/inbound.ts +12 -1
  47. package/src/claude/desktop-profile.ts +66 -9
  48. package/src/claude/outbound.ts +18 -0
  49. package/src/cli/account-main.ts +1 -1
  50. package/src/cli/capabilities.ts +2 -2
  51. package/src/cli/combo.ts +10 -1
  52. package/src/cli/index.ts +48 -5
  53. package/src/cli/registry.ts +2 -1
  54. package/src/cli/system-command.ts +4 -4
  55. package/src/clients/config-export.ts +7 -3
  56. package/src/codex/account-label.ts +14 -3
  57. package/src/codex/account-lifecycle.ts +3 -0
  58. package/src/codex/account-store.ts +184 -35
  59. package/src/codex/account-usability.ts +21 -0
  60. package/src/codex/auth-api/account-list.ts +507 -0
  61. package/src/codex/auth-api/http.ts +32 -0
  62. package/src/codex/auth-api/login-flow.ts +566 -0
  63. package/src/codex/auth-api/login-state.ts +64 -0
  64. package/src/codex/auth-api/main-account-probe.ts +331 -0
  65. package/src/codex/auth-api/pool-mode-gate.ts +274 -0
  66. package/src/codex/auth-api/pool-quota-probe.ts +512 -0
  67. package/src/codex/auth-api/reset-credit-service.ts +431 -0
  68. package/src/codex/auth-api/routes.ts +425 -0
  69. package/src/codex/auth-api/runtime-config.ts +48 -0
  70. package/src/codex/auth-api.ts +27 -3118
  71. package/src/codex/auth-context.ts +252 -35
  72. package/src/codex/catalog/aggregation.ts +80 -1
  73. package/src/codex/catalog/auto-review.ts +507 -0
  74. package/src/codex/catalog/build-entries.ts +981 -0
  75. package/src/codex/catalog/combo-member.ts +375 -0
  76. package/src/codex/catalog/derive-entry.ts +229 -0
  77. package/src/codex/catalog/effort.ts +0 -1
  78. package/src/codex/catalog/gated-native-warn.ts +63 -0
  79. package/src/codex/catalog/gather-capture.ts +533 -0
  80. package/src/codex/catalog/model-hints.ts +691 -0
  81. package/src/codex/catalog/model-visibility.ts +305 -0
  82. package/src/codex/catalog/provider-fetch.ts +52 -2942
  83. package/src/codex/catalog/provider-models.ts +685 -0
  84. package/src/codex/catalog/remote.ts +30 -0
  85. package/src/codex/catalog/restore.ts +132 -0
  86. package/src/codex/catalog/retained-sync.ts +714 -0
  87. package/src/codex/catalog/routed-gather.ts +895 -0
  88. package/src/codex/catalog/subagent-roster.ts +176 -0
  89. package/src/codex/catalog/sync.ts +52 -2698
  90. package/src/codex/cli-install-provenance.ts +7 -1
  91. package/src/codex/convergence.ts +7 -2
  92. package/src/codex/desktop-app/types.ts +11 -2
  93. package/src/codex/desktop-app/windows.ts +5 -5
  94. package/src/codex/inject/config-toml.ts +563 -0
  95. package/src/codex/inject/remove.ts +192 -0
  96. package/src/codex/inject/restore.ts +567 -0
  97. package/src/codex/inject/routing-classify.ts +109 -0
  98. package/src/codex/inject/routing-target.ts +125 -0
  99. package/src/codex/inject.ts +89 -1444
  100. package/src/codex/lineage.ts +458 -0
  101. package/src/codex/model-entitlements.ts +152 -15
  102. package/src/codex/pool-refresh-backoff.ts +161 -0
  103. package/src/codex/quota-rejection.ts +104 -15
  104. package/src/codex/routing/active-account.ts +194 -0
  105. package/src/codex/routing/cache-affinity.ts +70 -0
  106. package/src/codex/routing/cooldown-math.ts +285 -0
  107. package/src/codex/routing/health-store.ts +402 -0
  108. package/src/codex/routing/probe-lease.ts +358 -0
  109. package/src/codex/routing/selection.ts +780 -0
  110. package/src/codex/routing/thread-affinity.ts +586 -0
  111. package/src/codex/routing/transient-hold-dispatch.ts +141 -0
  112. package/src/codex/routing.ts +370 -2271
  113. package/src/codex/shim-fingerprint.ts +223 -0
  114. package/src/codex/shim-inspect.ts +175 -0
  115. package/src/codex/shim-probe.ts +367 -0
  116. package/src/codex/shim-restore-lock.ts +169 -0
  117. package/src/codex/shim-state-file.ts +151 -0
  118. package/src/codex/shim-templates.ts +265 -0
  119. package/src/codex/shim.ts +48 -1268
  120. package/src/codex/warmup.ts +1 -1
  121. package/src/combos/failover.ts +85 -0
  122. package/src/combos/request.ts +17 -10
  123. package/src/combos/types.ts +23 -2
  124. package/src/config/diagnostics.ts +705 -0
  125. package/src/config/feature-flags.ts +55 -0
  126. package/src/config/live-reconcile.ts +403 -0
  127. package/src/config/load-degrade.ts +880 -0
  128. package/src/config/mutation-lock.ts +244 -0
  129. package/src/config/openai-tier-backup.ts +268 -0
  130. package/src/config/pending-teardown.ts +31 -0
  131. package/src/config/persist-unlocked.ts +92 -0
  132. package/src/config/proxy-env.ts +188 -0
  133. package/src/config/salvage.ts +244 -0
  134. package/src/config/schema/config-schema.ts +640 -0
  135. package/src/config/schema/leaf-validators.ts +855 -0
  136. package/src/config/warn-memo.ts +28 -0
  137. package/src/config.ts +234 -4481
  138. package/src/generated/compatibility-version.json +649 -121
  139. package/src/images/loop.ts +1 -1
  140. package/src/lib/errors.ts +17 -0
  141. package/src/lib/request-execution-budget.ts +198 -23
  142. package/src/lib/spend-reservation-ledger.ts +958 -0
  143. package/src/lib/state-store-registrations.ts +6 -2
  144. package/src/lib/test-home-guard.ts +85 -1
  145. package/src/lib/upstream-retry.ts +132 -21
  146. package/src/lib/windows-elevation.ts +76 -14
  147. package/src/lib/workflow-budget.ts +553 -30
  148. package/src/oauth/index.ts +2 -2
  149. package/src/oauth/key-providers.ts +2 -2
  150. package/src/providers/kiro-models.ts +4 -3
  151. package/src/providers/label.ts +19 -1
  152. package/src/providers/model-discovery.ts +16 -0
  153. package/src/providers/quota/account-cache.ts +441 -0
  154. package/src/providers/quota/antigravity.ts +295 -0
  155. package/src/providers/quota/report-cache.ts +320 -0
  156. package/src/providers/quota/vendor-probes-key.ts +1243 -0
  157. package/src/providers/quota/vendor-probes-oauth.ts +590 -0
  158. package/src/providers/quota.ts +324 -3079
  159. package/src/providers/registry/entries-core.ts +1228 -0
  160. package/src/providers/registry/entries-extended.ts +1213 -0
  161. package/src/providers/registry/model-seeds.ts +912 -0
  162. package/src/providers/registry/types.ts +352 -0
  163. package/src/providers/registry.ts +24 -3536
  164. package/src/responses/continuation-ownership.ts +29 -0
  165. package/src/responses/reasoning-envelope.ts +6 -3
  166. package/src/responses/state/replay-fingerprint.ts +80 -0
  167. package/src/responses/state/snapshot-codec.ts +104 -0
  168. package/src/responses/state/spill-failure.ts +118 -0
  169. package/src/responses/state/spill-queue.ts +665 -0
  170. package/src/responses/state/temp-recovery.ts +257 -0
  171. package/src/responses/state.ts +82 -1143
  172. package/src/routing/identity-domains.ts +456 -0
  173. package/src/routing/probe-lease.ts +613 -0
  174. package/src/server/chat-completions.ts +3 -1
  175. package/src/server/chat-native.ts +37 -9
  176. package/src/server/index/bounded-request.ts +88 -0
  177. package/src/server/index/live-sideband.ts +601 -0
  178. package/src/server/index/serve-options.ts +1766 -0
  179. package/src/server/index/startup-warnings.ts +213 -0
  180. package/src/server/index/websocket-handler.ts +339 -0
  181. package/src/server/index.ts +45 -2552
  182. package/src/server/inspection-tee.ts +107 -0
  183. package/src/server/live.ts +46 -1
  184. package/src/server/management/combo-routes.ts +10 -1
  185. package/src/server/management/route-registry.ts +26 -23
  186. package/src/server/management/shared.ts +8 -5
  187. package/src/server/management/workflow-budget-routes.ts +133 -0
  188. package/src/server/management-api.ts +12 -0
  189. package/src/server/relay-eager.ts +2 -0
  190. package/src/server/relay.ts +14 -19
  191. package/src/server/request-log-conversation.ts +9 -7
  192. package/src/server/request-log.ts +372 -4
  193. package/src/server/response-log-body.ts +153 -0
  194. package/src/server/responses/account-change-state.ts +307 -0
  195. package/src/server/responses/adapter-continuation.ts +540 -0
  196. package/src/server/responses/adapter-delivery.ts +208 -0
  197. package/src/server/responses/adapter-dispatch.ts +1042 -0
  198. package/src/server/responses/codex-ws-wire.ts +5 -0
  199. package/src/server/responses/collaboration.ts +74 -4
  200. package/src/server/responses/combo-session-recall.ts +68 -8
  201. package/src/server/responses/compact.ts +113 -17
  202. package/src/server/responses/completion-policy.ts +33 -0
  203. package/src/server/responses/core-auth.ts +529 -0
  204. package/src/server/responses/core-codex-account.ts +907 -0
  205. package/src/server/responses/core-combo-failure.ts +210 -0
  206. package/src/server/responses/core-combo.ts +787 -0
  207. package/src/server/responses/core-errors.ts +170 -0
  208. package/src/server/responses/core-lifetime.ts +95 -0
  209. package/src/server/responses/core-normalize.ts +350 -0
  210. package/src/server/responses/core-opaque-recovery.ts +380 -0
  211. package/src/server/responses/core-options.ts +159 -0
  212. package/src/server/responses/core-replay.ts +298 -0
  213. package/src/server/responses/core.ts +192 -8893
  214. package/src/server/responses/encrypted-payload.ts +0 -1
  215. package/src/server/responses/input-admission.ts +126 -6
  216. package/src/server/responses/passthrough-delivery.ts +869 -0
  217. package/src/server/responses/passthrough-dispatch.ts +1494 -0
  218. package/src/server/responses/passthrough-error.ts +38 -2
  219. package/src/server/responses/passthrough-execution.ts +54 -0
  220. package/src/server/responses/request-prepare.ts +1080 -0
  221. package/src/server/responses/request-send-budget.ts +259 -0
  222. package/src/server/responses/request-sidecar-auth.ts +149 -0
  223. package/src/server/responses/request-spend.ts +147 -0
  224. package/src/server/responses/request-transport.ts +803 -0
  225. package/src/server/responses/response-effects.ts +157 -0
  226. package/src/server/responses/run-turn-execution.ts +476 -0
  227. package/src/server/responses/sidecar-execution.ts +463 -0
  228. package/src/server/responses/terminal-guard.ts +65 -4
  229. package/src/server/responses-image-gen-repair.ts +1 -1
  230. package/src/server/responses-undeclared-tool-guard.ts +9 -5
  231. package/src/server/workflow-refusal.ts +84 -0
  232. package/src/service/windows-ops.ts +210 -16
  233. package/src/service/windows-scheduler.ts +28 -21
  234. package/src/service.ts +1 -1
  235. package/src/types/config.ts +34 -1
  236. package/src/types/request.ts +8 -5
  237. package/src/types/tools.ts +24 -0
  238. package/src/types.ts +2 -0
  239. package/src/update/index.ts +10 -0
  240. package/src/update/stop-contract.d.mts +1 -0
  241. package/src/update/stop-contract.mjs +19 -0
  242. package/src/update/stop-decision.d.mts +1 -1
  243. package/src/update/stop-decision.mjs +12 -3
  244. package/src/usage/log.ts +147 -1
  245. package/src/usage/summary.ts +171 -21
  246. package/src/vision/anthropic-describe.ts +1 -1
  247. package/src/vision/describe.ts +5 -5
  248. package/src/web-search/anthropic-executor.ts +1 -1
  249. package/src/web-search/exa-executor.ts +1 -1
  250. package/src/web-search/executor.ts +1 -1
  251. package/src/web-search/gemini-executor.ts +1 -1
  252. package/src/web-search/loop.ts +1 -1
  253. package/src/web-search/ollama-executor.ts +1 -1
  254. package/src/web-search/parse.ts +67 -14
  255. package/src/web-search/passthrough-bridge.ts +64 -31
  256. package/src/web-search/xai-executor.ts +1 -1
@@ -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;
@@ -139,7 +186,11 @@ export interface RetryBackoffOptions {
139
186
  * first instead of silently lengthening every adapter's backoff.
140
187
  */
141
188
  retryAfterIsLowerBound?: boolean;
142
- /** Hard ceiling for an honoured `Retry-After`, so an hour-long wait cannot park a request. */
189
+ /**
190
+ * The wait deadline a caller applies to an honoured `Retry-After`. The delay itself is
191
+ * never shortened: an instruction longer than the deadline is a reason to END with the
192
+ * upstream answer, not to send early. Kept for callers that still pass it.
193
+ */
143
194
  retryAfterCeilingMs?: number;
144
195
  }
145
196
 
@@ -305,10 +356,10 @@ export function retryBackoffDelayMs(attempt: number, opts: RetryBackoffOptions):
305
356
  // A provider that names a wait is stating when it will serve again; sending earlier is a
306
357
  // request we already know will be refused, and refusing it twice is the retry storm the
307
358
  // header exists to prevent. The local maximum bounds our OWN exponential backoff and has no
308
- // business shortening someone else's instruction. The ceiling is separate: it stops an
309
- // hour-long Retry-After from parking a request forever.
310
- const ceiling = opts.retryAfterCeilingMs ?? RETRY_AFTER_CEILING_MS;
311
- return Math.min(Math.max(retryAfter, jittered), ceiling);
359
+ // business shortening someone else's instruction, so the instruction is returned in full.
360
+ // Whether the request can afford to wait that long is the caller's deadline decision --
361
+ // fetchWithTransientRetry ends with the upstream answer rather than retrying early.
362
+ return Math.max(retryAfter, jittered);
312
363
  }
313
364
 
314
365
  export function cancelResponseBodyBestEffort(res: Response): void {
@@ -348,22 +399,42 @@ export async function fetchWithAttemptDeadline(
348
399
  }
349
400
 
350
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;
351
408
  abortSignal?: AbortSignal;
352
409
  /** Short host/path label for the retry warn log (no secrets/query strings). */
353
410
  label?: string;
354
411
  /** Total upstream sends allowed, including the first one. Not a per-layer retry count. */
355
412
  attempts?: number;
413
+ /**
414
+ * Reports how many upstream sends this call actually consumed, so a caller that spans
415
+ * several legs of one request (initial send, then a 429/account-recovery refetch) can
416
+ * keep them on ONE budget instead of handing each leg a fresh one.
417
+ *
418
+ * It lives on the RESET options, not on the transient ones, because every leg that falls
419
+ * back to reset-only retry -- the non-policy adapter initial send, and every
420
+ * `rebuildAndRefetch` recovery kind whose provider has no transient policy -- was not merely
421
+ * uncounted but UNCOUNTABLE: the callback existed on a type those call sites never reach.
422
+ */
423
+ onSendsConsumed?: (sends: number) => void;
356
424
  }
357
425
 
358
426
  export interface TransientRetryOptions extends ResetRetryOptions {
359
427
  /** Test seam: per-attempt slow budget override (defaults to TRANSIENT_RETRY_SLOW_ATTEMPT_MS). */
360
428
  slowAttemptMs?: number;
361
429
  /**
362
- * Reports how many upstream sends this call actually consumed, so a caller that spans
363
- * several legs of one request (initial send, then a 429/account-recovery refetch) can
364
- * keep them on ONE budget instead of handing each leg a fresh one.
430
+ * How long this caller can wait on an honoured `Retry-After`, defaulting to
431
+ * {@link RETRY_AFTER_CEILING_MS}. It is a deadline, never a clamp: an instruction inside it
432
+ * is slept in full, and an instruction past it ends the call with the upstream answer and
433
+ * its `Retry-After` intact rather than sending early at a provider that already said it
434
+ * would refuse. A caller with a shorter budget than a minute says so and is not parked past
435
+ * it; a caller that can genuinely wait longer says so and is not cut short.
365
436
  */
366
- onSendsConsumed?: (sends: number) => void;
437
+ retryAfterCeilingMs?: number;
367
438
  }
368
439
 
369
440
  export type UpstreamSendRecovery = "connection-reset" | "transient-5xx";
@@ -424,9 +495,9 @@ export function applyUpstreamRecoveryInit<T extends RequestInit>(
424
495
  }
425
496
 
426
497
  /**
427
- * Run `doFetch`, retrying only connection-reset-shaped rejections (see
428
- * isConnectionResetError) with jittered backoff. The caller's thunk must be replay-safe
429
- * (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.
430
501
  */
431
502
  export async function fetchWithResetRetry(
432
503
  doFetch: ReplayableFetch,
@@ -442,6 +513,10 @@ export async function fetchWithResetRetry(
442
513
  let sawReset = false;
443
514
  for (let attempt = 0; attempt < attempts; attempt++) {
444
515
  if (opts.abortSignal?.aborted) throw abortError(opts.abortSignal);
516
+ // Reported before the await, one physical send at a time: a send that rejects has still
517
+ // been made, and this helper leaves through four exits (return, reset give-up, non-reset
518
+ // rethrow, abort), so a per-send report is the only shape that is correct on all of them.
519
+ opts.onSendsConsumed?.(1);
445
520
  try {
446
521
  return await doFetch(attempt === 0 ? firstRecovery : "connection-reset");
447
522
  } catch (err) {
@@ -453,6 +528,20 @@ export async function fetchWithResetRetry(
453
528
  if (sawReset) throw new UpstreamRetryEvidenceError([], err, true);
454
529
  throw err;
455
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
+ }
456
545
  if (attempt === attempts - 1) throw err;
457
546
  sawReset = true;
458
547
  lastError = err;
@@ -469,9 +558,9 @@ export async function fetchWithResetRetry(
469
558
  }
470
559
 
471
560
  /**
472
- * fetchWithResetRetry plus a transient-5xx status retry layer, PRE-STREAM only: a
473
- * returned Response has by definition not been relayed to the client yet, so replaying
474
- * 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
475
564
  * retry; every returned response (ok, non-transient, aborted, slow, exhausted) keeps
476
565
  * its body intact. Honors Retry-After via retryBackoffDelayMs.
477
566
  *
@@ -504,13 +593,22 @@ export async function fetchWithTransientRetry(
504
593
  // more send -- the loop condition alone was never enough, because every later recovery leg
505
594
  // called this helper again and the floor funded each of them.
506
595
  const remaining = () => Math.max(0, budget - sent);
596
+ // The inner reset layer now has its own `onSendsConsumed`, and these are the same physical
597
+ // sends `countedFetch` already counts. Forwarding the reporter down the `remaining()` path
598
+ // would report each of them twice, which is how a four-send cap becomes a two-send cap. One
599
+ // send is counted once, by the outermost layer that owns the budget.
600
+ const innerResetOptions = (): ResetRetryOptions => ({
601
+ ...opts,
602
+ attempts: remaining(),
603
+ onSendsConsumed: undefined,
604
+ });
507
605
  // Reported in `finally` rather than at each exit: this function returns from five places
508
606
  // and throws from one, and a caller sharing the budget across request legs must be told the
509
607
  // real count on every one of them.
510
608
  try {
511
609
  if (budget === 0) throw new SendBudgetExhaustedError(opts.label);
512
610
  let attemptStart = Date.now();
513
- let res = await fetchWithResetRetry(countedFetch, { ...opts, attempts: remaining() });
611
+ let res = await fetchWithResetRetry(countedFetch, innerResetOptions());
514
612
  for (let attempt = 0; sent < budget; attempt++) {
515
613
  // A non-replayable gateway status was settled after the request body had already left
516
614
  // for the origin; retrying it here is the automatic resend the marker exists to forbid.
@@ -519,6 +617,19 @@ export async function fetchWithTransientRetry(
519
617
  // a response whose body we just cancelled.
520
618
  if (opts.abortSignal?.aborted) return res;
521
619
  if (Date.now() - attemptStart > slowAttemptMs) return res;
620
+ const instructedDelay = retryAfterDelayMs(res.headers);
621
+ // The deadline is the CALLER'S, not this module's default. Reading the constant directly
622
+ // broke it in both directions: a caller with a 30s budget slept the full 45s an upstream
623
+ // asked for, and a caller that could genuinely wait 120s was handed the error back for a
624
+ // 90s instruction it was willing to honour.
625
+ const waitDeadlineMs = opts.retryAfterCeilingMs ?? RETRY_AFTER_CEILING_MS;
626
+ if (instructedDelay !== undefined && instructedDelay > waitDeadlineMs) {
627
+ // Honouring the stated wait would park this request past the deadline it can commit
628
+ // to, and sleeping only up to the deadline is a send the provider already said it will
629
+ // refuse. End here instead: the caller receives the upstream answer with its
630
+ // Retry-After intact and applies its own policy, exactly as on the direct path.
631
+ return res;
632
+ }
522
633
  console.warn(
523
634
  `[upstream-retry] transient ${res.status}${opts.label ? ` (${opts.label})` : ""} — retrying (${sent + 1}/${budget})`,
524
635
  );
@@ -535,7 +646,7 @@ export async function fetchWithTransientRetry(
535
646
  attemptStart = Date.now();
536
647
  transientStatuses.push(res.status);
537
648
  try {
538
- res = await fetchWithResetRetry(countedFetch, { ...opts, attempts: remaining() }, "transient-5xx");
649
+ res = await fetchWithResetRetry(countedFetch, innerResetOptions(), "transient-5xx");
539
650
  } catch (err) {
540
651
  // Keep the prior 5xx evidence attached: the origin already responded, so
541
652
  // this rejection is not pre-connection and must not classify as neutral.
@@ -250,6 +250,21 @@ export const OCX_ELEVATED_PROTOCOL_FAILED = 13;
250
250
  /** Windows ERROR_CANCELLED — reserved for UAC denial; never emitted by the elevated script. */
251
251
  export const OCX_ELEVATED_UAC_CANCELLED = 1223;
252
252
 
253
+ /**
254
+ * The elevated process could not read a staged payload (#4692).
255
+ *
256
+ * `hardenSecretPath` grants the staging account and strips inheritance, so a split-token
257
+ * elevation of the same user reads the file and an elevation answered with a DIFFERENT
258
+ * administrator's credentials does not. The elevated side cannot explain that itself: it
259
+ * runs hidden, so its stderr goes nowhere and only the exit code survives the boundary.
260
+ * Without a code of its own the operator would be told "exit code 1" for a cause that
261
+ * names its own remedy — the same undiagnosable failure this change set exists to remove.
262
+ *
263
+ * Deliberately outside OCX_ELEVATED_PROTOCOL_CODES: that list is the create-and-run
264
+ * transaction's alphabet, and this code belongs to the registration path.
265
+ */
266
+ export const OCX_ELEVATED_STAGING_UNREADABLE = 14;
267
+
253
268
  export const OCX_ELEVATED_PROTOCOL_CODES = [
254
269
  OCX_ELEVATED_SUCCESS,
255
270
  OCX_ELEVATED_CREATE_FAILED,
@@ -645,36 +660,83 @@ export function runWindowsElevated(file: string, args: string[]): Promise<number
645
660
  }
646
661
 
647
662
  /**
648
- * Register one scheduled-task definition without exposing a mutable XML pathname to
649
- * the elevated process. The XML bytes are fixed in the encoded PowerShell command
650
- * before UAC; Register-ScheduledTask receives that string directly after elevation.
663
+ * A task definition staged for the elevated process.
664
+ *
665
+ * The bytes live in a freshly created, ACL-hardened private directory, and the digest is
666
+ * taken over exactly those bytes by the caller that validated them. The elevated script
667
+ * reads the file once, hashes what it read, and refuses unless the digest matches, so a
668
+ * pathname is no longer a promise about content — it is a claim the receiver checks.
669
+ */
670
+ export interface StagedWindowsTaskXml {
671
+ /** Path inside the caller's hardened staging directory. */
672
+ readonly path: string;
673
+ /** Lowercase hex SHA-256 of the staged bytes (UTF-16LE, no BOM). */
674
+ readonly sha256: string;
675
+ }
676
+
677
+ /**
678
+ * Read a staged payload, prove it is the one that was validated, and decode it.
679
+ *
680
+ * One read: the bytes that are hashed are the same array that is decoded and registered.
681
+ * Hashing a path and then reopening it would reintroduce the swap window this check
682
+ * exists to close.
683
+ */
684
+ const READ_STAGED_TASK_XML = "function Read-OcxStagedTaskXml([string]$path, [string]$expectedHash) {"
685
+ // An unreadable payload is a diagnosable condition, not a generic throw: a hidden
686
+ // elevated process has nowhere to print, so the cause has to ride the exit code.
687
+ + " try { $bytes = [IO.File]::ReadAllBytes($path) }"
688
+ + " catch [System.UnauthorizedAccessException] { exit " + OCX_ELEVATED_STAGING_UNREADABLE + " }"
689
+ + " catch [System.Security.SecurityException] { exit " + OCX_ELEVATED_STAGING_UNREADABLE + " };"
690
+ + " $sha = [Security.Cryptography.SHA256]::Create();"
691
+ + " try { $actual = [BitConverter]::ToString($sha.ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() };"
692
+ + " if ($actual -cne $expectedHash) { throw 'Task Scheduler staged payload failed its integrity check.' };"
693
+ + " return [Text.Encoding]::Unicode.GetString($bytes) }";
694
+
695
+ /**
696
+ * Register one scheduled-task definition from staged, digest-verified bytes.
697
+ *
698
+ * The payloads used to be embedded as base64(utf16le) inside an inner PowerShell script
699
+ * that was itself base64(utf16le)-encoded into `-EncodedCommand`. Two layers of base64
700
+ * over UTF-16 cost about 14.2 command-line characters per XML character, and a
701
+ * replacement carries two payloads, so a ~2 KB task definition pushed the outer command
702
+ * past the Windows limit and the spawn failed with ENAMETOOLONG before UAC ever
703
+ * appeared (#4692). On a host where the trigger scope exports as an account name the
704
+ * re-register path runs on every repair, so repair could never succeed.
705
+ *
706
+ * The command now carries two paths and two 64-character digests, so its length no
707
+ * longer depends on the size of the XML at all.
708
+ *
709
+ * The original design goal was "immutable bytes, never a caller-writable pathname".
710
+ * That goal is kept by different means rather than abandoned: the staging directory is
711
+ * private and ACL-hardened, the files are created exclusively so nothing can be waiting
712
+ * at the path, and the digest makes a same-account swap during the UAC prompt fail
713
+ * closed instead of registering something else. An ACL alone could not do that last
714
+ * part, because a process running as the same user has the same SID.
715
+ *
716
+ * The replacement precondition is unchanged: the elevated process still re-queries the
717
+ * live registration and compares it to the captured predecessor before passing -Force.
651
718
  */
652
719
  export function runWindowsElevatedScheduledTaskRegistration(
653
720
  taskName: string,
654
- xml: string,
721
+ xml: StagedWindowsTaskXml,
655
722
  replace = false,
656
- expectedExistingXml?: string,
723
+ expectedExisting?: StagedWindowsTaskXml,
657
724
  ): Promise<number> {
658
- if (replace && !expectedExistingXml?.trim()) {
725
+ if (replace && !expectedExisting) {
659
726
  throw new Error("Elevated Task Scheduler replacement requires a captured existing definition.");
660
727
  }
661
- const xmlBase64 = Buffer.from(xml, "utf16le").toString("base64");
662
- const expectedExistingBase64 = expectedExistingXml === undefined
663
- ? null
664
- : Buffer.from(expectedExistingXml, "utf16le").toString("base64");
665
728
  const powerShellPath = windowsPowerShell();
666
729
  const powerShellDirectory = powerShellPath.replace(/[\\/][^\\/]+$/, "");
667
730
  const scheduledTasksModule = `${powerShellDirectory}\\Modules\\ScheduledTasks\\ScheduledTasks.psd1`;
668
731
  const inner = [
669
732
  `$taskName = ${psSingleQuote(taskName)}`,
670
- `$xmlBase64 = ${psSingleQuote(xmlBase64)}`,
671
- "$xml = [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($xmlBase64))",
733
+ READ_STAGED_TASK_XML,
734
+ `$xml = Read-OcxStagedTaskXml ${psSingleQuote(xml.path)} ${psSingleQuote(xml.sha256)}`,
672
735
  `$module = Microsoft.PowerShell.Core\\Import-Module -Name ${psSingleQuote(scheduledTasksModule)} -PassThru -Force -ErrorAction Stop`,
673
736
  "$registerTask = $module.ExportedCommands['Register-ScheduledTask']",
674
737
  "if ($null -eq $registerTask) { throw 'Trusted ScheduledTasks module does not export Register-ScheduledTask.' }",
675
738
  ...(replace ? [
676
- `$expectedBase64 = ${psSingleQuote(expectedExistingBase64!)}`,
677
- "$expectedXml = [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($expectedBase64))",
739
+ `$expectedXml = Read-OcxStagedTaskXml ${psSingleQuote(expectedExisting!.path)} ${psSingleQuote(expectedExisting!.sha256)}`,
678
740
  `$schtasks = ${psSingleQuote(resolveTrustedWindowsSchtasksExe())}`,
679
741
  "$currentXml = & $schtasks /query /tn $taskName /xml 2>$null | Out-String",
680
742
  "if ($LASTEXITCODE -ne 0) { throw 'Task Scheduler replacement precondition could not be read.' }",