@bitkyc08/opencodex 2.59.0 → 2.61.0-preview.20260922

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 (374) hide show
  1. package/AGENTS_INSTALL.md +64 -0
  2. package/README.md +28 -1
  3. package/bin/ocx.mjs +382 -209
  4. package/gui/dist/assets/App-E64Rzjap.js +50 -0
  5. package/gui/dist/assets/Tray-_nfzD8k4.js +1 -0
  6. package/gui/dist/assets/index-DpdfZWMK.js +86 -0
  7. package/gui/dist/assets/index-_bpvxJu0.css +1 -0
  8. package/gui/dist/assets/usage-companion-chart-DtoK7T6h.js +1 -0
  9. package/gui/dist/favicon.png +0 -0
  10. package/gui/dist/index.html +2 -2
  11. package/gui/dist/provider-icons/stepfun-color.svg +1 -0
  12. package/package.json +8 -1
  13. package/src/adapters/anthropic-image-codec.ts +16 -2
  14. package/src/adapters/anthropic-image-normalize.ts +49 -2
  15. package/src/adapters/anthropic.ts +20 -1
  16. package/src/adapters/base.ts +23 -0
  17. package/src/adapters/coding-agent/protocol.ts +36 -6
  18. package/src/adapters/coding-agent/turn.ts +32 -4
  19. package/src/adapters/command-code.ts +52 -4
  20. package/src/adapters/cursor/catalog.ts +51 -7
  21. package/src/adapters/cursor/checkpoint-store.ts +3 -0
  22. package/src/adapters/cursor/discovery.ts +11 -8
  23. package/src/adapters/cursor/live-transport.ts +26 -9
  24. package/src/adapters/cursor/protobuf-request.ts +6 -3
  25. package/src/adapters/cursor/request-builder.ts +20 -4
  26. package/src/adapters/cursor/transport.ts +19 -0
  27. package/src/adapters/cursor.ts +14 -3
  28. package/src/adapters/declaration-carrier.ts +45 -0
  29. package/src/adapters/devin/cloud-direct/chat.ts +3 -1
  30. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +42 -5
  31. package/src/adapters/devin.ts +125 -36
  32. package/src/adapters/google-antigravity-replay.ts +1 -1
  33. package/src/adapters/google-antigravity-wire.ts +34 -7
  34. package/src/adapters/google-errors.ts +7 -1
  35. package/src/adapters/google-http.ts +49 -10
  36. package/src/adapters/google-tool-schema.ts +595 -31
  37. package/src/adapters/google-wire-compiler.ts +93 -10
  38. package/src/adapters/google-wire-shape.ts +461 -0
  39. package/src/adapters/google.ts +66 -11
  40. package/src/adapters/image.ts +4 -1
  41. package/src/adapters/input-media-guard.ts +21 -9
  42. package/src/adapters/kiro/usage.ts +3 -2
  43. package/src/adapters/kiro-tool-fallback.ts +1 -1
  44. package/src/adapters/ollama-native.ts +6 -0
  45. package/src/adapters/openai-chat/developer-role.ts +61 -0
  46. package/src/adapters/openai-chat/messages.ts +46 -27
  47. package/src/adapters/openai-chat/parallel-tool-calls.ts +32 -0
  48. package/src/adapters/openai-chat/passthrough.ts +33 -9
  49. package/src/adapters/openai-chat/reasoning-wire.ts +89 -0
  50. package/src/adapters/openai-chat-images.ts +3 -1
  51. package/src/adapters/openai-chat.ts +23 -58
  52. package/src/adapters/openai-responses/image-gen.ts +8 -6
  53. package/src/adapters/openai-responses/passthrough.ts +17 -3
  54. package/src/adapters/openai-responses/reasoning.ts +7 -0
  55. package/src/adapters/opencode-go-additional-tools.ts +12 -2
  56. package/src/adapters/registry.ts +3 -2
  57. package/src/adapters/run-turn-queue.ts +178 -29
  58. package/src/adapters/xai-web-search.ts +16 -1
  59. package/src/bridge/errors.ts +8 -2
  60. package/src/bridge/response-json.ts +9 -1
  61. package/src/bridge/sse.ts +13 -151
  62. package/src/chat/inbound.ts +141 -5
  63. package/src/claude/desktop-3p.ts +7 -1
  64. package/src/claude/desktop-first-party.ts +183 -0
  65. package/src/claude/desktop-gateway-state.ts +41 -0
  66. package/src/claude/inbound-content-options.ts +6 -0
  67. package/src/claude/inbound.ts +32 -6
  68. package/src/claude/intercept/connect-proxy.ts +179 -0
  69. package/src/claude/intercept/listener.ts +122 -0
  70. package/src/claude/intercept/local-ca.ts +298 -0
  71. package/src/claude/intercept/runtime.ts +98 -0
  72. package/src/claude/intercept/settings.ts +189 -0
  73. package/src/cli/access.ts +87 -0
  74. package/src/cli/account-auth.ts +19 -0
  75. package/src/cli/account-extended.ts +4 -4
  76. package/src/cli/capabilities.ts +31 -0
  77. package/src/cli/claude-desktop.ts +206 -16
  78. package/src/cli/codex-shim-autorestore.ts +3 -0
  79. package/src/cli/companion.ts +56 -0
  80. package/src/cli/dispatch.ts +46 -7
  81. package/src/cli/doctor.ts +28 -9
  82. package/src/cli/ensure-desired-integrations.ts +43 -5
  83. package/src/cli/help.ts +7 -9
  84. package/src/cli/hub.ts +3 -2
  85. package/src/cli/index.ts +203 -59
  86. package/src/cli/init.ts +8 -0
  87. package/src/cli/integrations.ts +7 -1
  88. package/src/cli/opencode.ts +2 -2
  89. package/src/cli/provider.ts +13 -1
  90. package/src/cli/registry.ts +41 -2
  91. package/src/cli/resolve.ts +230 -0
  92. package/src/cli/root.ts +24 -1
  93. package/src/cli/start-ownership-publication.ts +56 -0
  94. package/src/cli/status-probes.ts +2 -18
  95. package/src/cli/status.ts +62 -0
  96. package/src/cli/stop-report.ts +143 -0
  97. package/src/cli/uninstall-plan.ts +9 -0
  98. package/src/client/machine-api.ts +2 -2
  99. package/src/client/machine-listener.ts +4 -7
  100. package/src/client/runtime.ts +26 -2
  101. package/src/clients/aside-profiles.ts +4 -0
  102. package/src/clients/config-export/zcode-store.ts +157 -0
  103. package/src/clients/config-export.ts +36 -0
  104. package/src/codex/account-store.ts +65 -0
  105. package/src/codex/app-server-processes.ts +72 -40
  106. package/src/codex/auth-api/account-list.ts +19 -11
  107. package/src/codex/auth-api/login-flow.ts +6 -1
  108. package/src/codex/auth-api/pool-quota-probe.ts +30 -7
  109. package/src/codex/autostart-health.ts +28 -0
  110. package/src/codex/catalog/build-entries.ts +2 -2
  111. package/src/codex/catalog/effort.ts +3 -3
  112. package/src/codex/catalog/gather-capture.ts +21 -2
  113. package/src/codex/catalog/model-hints.ts +29 -28
  114. package/src/codex/catalog/parsing.ts +7 -0
  115. package/src/codex/catalog/provider-models.ts +43 -17
  116. package/src/codex/catalog/retained-sync.ts +24 -28
  117. package/src/codex/catalog/routed-gather.ts +19 -0
  118. package/src/codex/context-compat.ts +5 -2
  119. package/src/codex/convergence.ts +2 -2
  120. package/src/codex/desired-state.ts +4 -1
  121. package/src/codex/history-job.ts +6 -6
  122. package/src/codex/history-provider.ts +31 -166
  123. package/src/codex/history-rollout-read.ts +174 -0
  124. package/src/codex/inject/config-toml.ts +41 -6
  125. package/src/codex/inject/paginated-openai-compat.ts +90 -0
  126. package/src/codex/inject.ts +18 -15
  127. package/src/codex/injected-marker.ts +18 -0
  128. package/src/codex/internal/catalog-writer.ts +33 -1
  129. package/src/codex/main-account.ts +6 -0
  130. package/src/codex/model-cache.ts +99 -6
  131. package/src/codex/model-entitlement-admission.ts +59 -0
  132. package/src/codex/model-entitlements.ts +116 -54
  133. package/src/codex/native-main-admission.ts +83 -0
  134. package/src/codex/observed-model-denials.ts +101 -8
  135. package/src/codex/prompt-text-probe.ts +9 -6
  136. package/src/codex/routing/health-store.ts +39 -0
  137. package/src/codex/routing/selection.ts +37 -1
  138. package/src/codex/routing.ts +12 -42
  139. package/src/codex/shim-templates.ts +29 -3
  140. package/src/codex/shim.ts +1 -1
  141. package/src/codex/subagent-model-fallback.ts +22 -4
  142. package/src/combos/failover.ts +3 -0
  143. package/src/companion/settings.ts +132 -0
  144. package/src/config/admitted-identity.ts +222 -0
  145. package/src/config/atomic-write.ts +117 -5
  146. package/src/config/diagnostics.ts +22 -1
  147. package/src/config/feature-flags.ts +5 -0
  148. package/src/config/load-degrade.ts +52 -7
  149. package/src/config/process-state.ts +1 -1
  150. package/src/config/proxy-env.ts +8 -2
  151. package/src/config/schema/compaction-triggers.ts +11 -0
  152. package/src/config/schema/config-schema.ts +31 -1
  153. package/src/config/schema/leaf-validators.ts +59 -0
  154. package/src/config.ts +3 -3
  155. package/src/generated/compatibility-version.json +604 -280
  156. package/src/grok/reset-coupons.ts +38 -19
  157. package/src/images/loop.ts +6 -1
  158. package/src/integrations/aside-profile-context.ts +37 -3
  159. package/src/integrations/aside-profile-journal.ts +68 -3
  160. package/src/integrations/aside-profiles.ts +128 -3
  161. package/src/integrations/config-io.ts +44 -10
  162. package/src/integrations/merge.ts +120 -13
  163. package/src/integrations/mutation-plan.ts +921 -0
  164. package/src/integrations/registry.ts +38 -0
  165. package/src/integrations/state.ts +78 -45
  166. package/src/integrations/target.ts +208 -0
  167. package/src/integrations/writer.ts +134 -110
  168. package/src/lab/conformance/fixture-provider.ts +5 -0
  169. package/src/lab/live/transport.ts +4 -0
  170. package/src/lab/live/types.ts +5 -0
  171. package/src/lab/subject/behavior-fingerprint.ts +1 -1
  172. package/src/lib/admin-secrets.ts +9 -1
  173. package/src/lib/browser-launch-notice.ts +59 -0
  174. package/src/lib/bun-runtime.ts +6 -2
  175. package/src/lib/debug-log-buffer.ts +6 -1
  176. package/src/lib/debug.ts +63 -0
  177. package/src/lib/errors.ts +79 -0
  178. package/src/lib/http-response-semantics.ts +57 -0
  179. package/src/lib/lab-live-pinned-sender.ts +26 -12
  180. package/src/lib/open-url.ts +51 -7
  181. package/src/lib/package-tree-integrity.ts +2 -1
  182. package/src/lib/package-version.ts +8 -0
  183. package/src/lib/pinned-http.ts +142 -2
  184. package/src/lib/plain-data.ts +103 -0
  185. package/src/lib/process-control.ts +13 -5
  186. package/src/lib/provider-egress.ts +310 -0
  187. package/src/lib/provider-outbound.ts +109 -16
  188. package/src/lib/proxy-env.ts +82 -7
  189. package/src/lib/request-execution-budget.ts +72 -0
  190. package/src/lib/request-failure-attribution.ts +183 -0
  191. package/src/lib/request-failure-model.ts +236 -0
  192. package/src/lib/request-resend-gate.ts +138 -0
  193. package/src/lib/socks5-fetch.ts +136 -26
  194. package/src/lib/spend-ledger-owner.ts +364 -0
  195. package/src/lib/spend-reservation-ledger.ts +218 -27
  196. package/src/lib/standalone.ts +16 -0
  197. package/src/lib/upstream-retry.ts +167 -16
  198. package/src/lib/windows-system-proxy.ts +16 -11
  199. package/src/lib/winsw.ts +2 -2
  200. package/src/oauth/callback-server.ts +4 -3
  201. package/src/oauth/generic-account-failover.ts +1 -0
  202. package/src/oauth/health.ts +12 -1
  203. package/src/oauth/index.ts +27 -108
  204. package/src/oauth/login-cli.ts +80 -29
  205. package/src/oauth/login-flow-state.ts +127 -0
  206. package/src/providers/api-key-resolve.ts +133 -0
  207. package/src/providers/api-key-selection.ts +5 -1
  208. package/src/providers/derive.ts +34 -17
  209. package/src/providers/devin-cli-authmode-migration.ts +14 -10
  210. package/src/providers/key-failover.ts +97 -19
  211. package/src/providers/key-store.ts +34 -110
  212. package/src/providers/model-rename-fields.ts +147 -0
  213. package/src/providers/model-rename-migration.ts +179 -38
  214. package/src/providers/model-rename-startup.ts +7 -5
  215. package/src/providers/openai-virtual-models.ts +42 -2
  216. package/src/providers/quota/antigravity.ts +22 -2
  217. package/src/providers/quota/vendor-probes-key.ts +38 -23
  218. package/src/providers/reasoning-metadata.ts +43 -18
  219. package/src/providers/registry/entries-core.ts +47 -20
  220. package/src/providers/registry/entries-extended.ts +63 -4
  221. package/src/providers/registry/model-ids.ts +168 -0
  222. package/src/providers/registry/model-seeds.ts +56 -10
  223. package/src/providers/registry/types.ts +2 -0
  224. package/src/providers/resolved-model-policy-merge.ts +167 -0
  225. package/src/providers/resolved-model-policy.ts +406 -0
  226. package/src/providers/stale-vision-classification-migration.ts +137 -0
  227. package/src/providers/xai-transport.ts +12 -1
  228. package/src/reasoning-effort.ts +8 -0
  229. package/src/responses/apply-patch-envelope.ts +0 -12
  230. package/src/responses/freeform-wrapper-scan.ts +279 -0
  231. package/src/responses/function-call-compat.ts +38 -1
  232. package/src/responses/inline-document.ts +65 -0
  233. package/src/responses/input-media.ts +42 -8
  234. package/src/responses/legacy-dotted-tool-name-repair.ts +134 -0
  235. package/src/responses/muse-tool-name-alias.ts +19 -0
  236. package/src/responses/parser-content.ts +8 -2
  237. package/src/responses/parser-tools.ts +3 -0
  238. package/src/responses/parser.ts +3 -1
  239. package/src/responses/progressive-freeform-input.ts +130 -0
  240. package/src/responses/reasoning-envelope.ts +30 -0
  241. package/src/responses/schema.ts +3 -0
  242. package/src/responses/state.ts +5 -12
  243. package/src/responses/tool-name-aliases.ts +15 -1
  244. package/src/router.ts +108 -117
  245. package/src/routing/compatibility/behavior.ts +9 -0
  246. package/src/routing/compatibility/subject.ts +16 -1
  247. package/src/server/adapter-resolve.ts +9 -0
  248. package/src/server/admission-model-scope.ts +219 -0
  249. package/src/server/audio-live.ts +9 -3
  250. package/src/server/audio-upstream.ts +18 -0
  251. package/src/server/auth-cors.ts +29 -0
  252. package/src/server/chat-completions.ts +60 -4
  253. package/src/server/chat-native.ts +19 -4
  254. package/src/server/claude-messages.ts +61 -20
  255. package/src/server/effort-row.ts +11 -3
  256. package/src/server/grok-responses-control-frame.ts +160 -1
  257. package/src/server/grok-responses-snapshot-repair.ts +113 -11
  258. package/src/server/gui-freshness.ts +103 -0
  259. package/src/server/gui-static.ts +7 -9
  260. package/src/server/images.ts +59 -6
  261. package/src/server/index/claude-intercept-lifecycle.ts +49 -0
  262. package/src/server/index/serve-options.ts +142 -39
  263. package/src/server/index/spend-ledger-lifecycle.ts +92 -0
  264. package/src/server/index/startup-warnings.ts +24 -0
  265. package/src/server/index/websocket-handler.ts +6 -1
  266. package/src/server/index.ts +34 -38
  267. package/src/server/lifecycle.ts +4 -4
  268. package/src/server/live-call-bindings.ts +6 -0
  269. package/src/server/live.ts +88 -3
  270. package/src/server/management/agent-settings-routes.ts +121 -36
  271. package/src/server/management/aside-profile-routes.ts +266 -7
  272. package/src/server/management/companion-routes.ts +77 -0
  273. package/src/server/management/config-routes.ts +18 -2
  274. package/src/server/management/context.ts +3 -0
  275. package/src/server/management/integration-routes.ts +287 -5
  276. package/src/server/management/logs-usage-routes.ts +19 -0
  277. package/src/server/management/metrics-routes.ts +20 -0
  278. package/src/server/management/model-rows.ts +224 -12
  279. package/src/server/management/native-integration-routes.ts +103 -6
  280. package/src/server/management/oauth-account-routes.ts +45 -7
  281. package/src/server/management/route-registry.ts +20 -0
  282. package/src/server/management/shared.ts +28 -4
  283. package/src/server/management/system-restart.ts +7 -2
  284. package/src/server/management/system-routes.ts +2 -0
  285. package/src/server/management/usage-aggregate-cache.ts +4 -0
  286. package/src/server/management/usage-timeline-routes.ts +44 -0
  287. package/src/server/management-api.ts +10 -9
  288. package/src/server/management-auth.ts +15 -1
  289. package/src/server/proxy-liveness.ts +75 -0
  290. package/src/server/readiness.ts +29 -10
  291. package/src/server/relay-eager.ts +24 -2
  292. package/src/server/relay.ts +138 -12
  293. package/src/server/request-log-failure-attribution.ts +99 -0
  294. package/src/server/request-log.ts +169 -2
  295. package/src/server/request-metrics.ts +298 -0
  296. package/src/server/responses/adapter-continuation.ts +3 -3
  297. package/src/server/responses/adapter-dispatch.ts +11 -6
  298. package/src/server/responses/codex-ws-wire.ts +34 -8
  299. package/src/server/responses/combo-stream-preflight.ts +168 -6
  300. package/src/server/responses/compact.ts +43 -10
  301. package/src/server/responses/compaction-routing.ts +111 -0
  302. package/src/server/responses/core-codex-account.ts +8 -3
  303. package/src/server/responses/core-combo.ts +7 -7
  304. package/src/server/responses/core-normalize.ts +6 -12
  305. package/src/server/responses/core-opaque-recovery.ts +91 -0
  306. package/src/server/responses/core-options.ts +4 -0
  307. package/src/server/responses/encrypted-payload.ts +20 -2
  308. package/src/server/responses/fetch-helpers.ts +124 -8
  309. package/src/server/responses/input-admission.ts +10 -0
  310. package/src/server/responses/passthrough-delivery.ts +45 -15
  311. package/src/server/responses/passthrough-dispatch.ts +215 -44
  312. package/src/server/responses/passthrough-error.ts +27 -8
  313. package/src/server/responses/policy-fallback.ts +5 -13
  314. package/src/server/responses/request-prepare.ts +109 -18
  315. package/src/server/responses/request-send-budget.ts +17 -1
  316. package/src/server/responses/request-sidecar-auth.ts +1 -1
  317. package/src/server/responses/request-transport.ts +26 -6
  318. package/src/server/responses/reset-replay.ts +108 -0
  319. package/src/server/responses/run-turn-execution.ts +25 -3
  320. package/src/server/responses/sidecar-execution.ts +17 -2
  321. package/src/server/responses/ws-upstream.ts +14 -27
  322. package/src/server/responses-custom-tool-repair.ts +27 -54
  323. package/src/server/responses-request-tool-scope.ts +214 -0
  324. package/src/server/responses-undeclared-tool-guard.ts +35 -2
  325. package/src/server/search.ts +25 -1
  326. package/src/server/sse-payload-rewrite.ts +1 -1
  327. package/src/server/usage-ledger-retention.ts +73 -0
  328. package/src/service/cli.ts +48 -2
  329. package/src/service/health.ts +3 -2
  330. package/src/service/install-state-contract.d.mts +27 -0
  331. package/src/service/install-state-contract.mjs +34 -0
  332. package/src/service/launchd.ts +1 -1
  333. package/src/service/orchestration.ts +2 -4
  334. package/src/service/ownership-compatibility.ts +164 -0
  335. package/src/service/ownership-mutation-lease.d.mts +32 -0
  336. package/src/service/ownership-mutation-lease.mjs +211 -0
  337. package/src/service/repair.ts +45 -1
  338. package/src/service/state-lock.ts +269 -0
  339. package/src/service/state-record.d.mts +36 -0
  340. package/src/service/state-record.mjs +138 -0
  341. package/src/service/state.ts +582 -68
  342. package/src/service/windows-taskxml.ts +11 -10
  343. package/src/service.ts +7 -3
  344. package/src/tray/windows-tray.ps1 +156 -4
  345. package/src/types/config.ts +48 -3
  346. package/src/types/provider.ts +91 -0
  347. package/src/types/request.ts +30 -2
  348. package/src/types/tools.ts +33 -0
  349. package/src/types.ts +4 -0
  350. package/src/update/index.ts +207 -63
  351. package/src/update/job.ts +9 -5
  352. package/src/update/ownership-transaction.ts +47 -0
  353. package/src/update/restart-ownership.ts +54 -0
  354. package/src/update/runtime-ownership.d.mts +40 -0
  355. package/src/update/runtime-ownership.mjs +122 -0
  356. package/src/usage/attempt-delivery.ts +198 -0
  357. package/src/usage/cache-diagnostic.ts +305 -0
  358. package/src/usage/failure-fingerprint.ts +118 -0
  359. package/src/usage/failure-projection-cache.ts +174 -0
  360. package/src/usage/failure-projection.ts +174 -0
  361. package/src/usage/ledger-retention.ts +165 -0
  362. package/src/usage/log.ts +126 -79
  363. package/src/usage/request-outcome.ts +150 -0
  364. package/src/usage/retention-contract.ts +28 -0
  365. package/src/usage/summary.ts +2 -2
  366. package/src/usage/telemetry-contract.ts +237 -0
  367. package/src/usage/timeline.ts +236 -0
  368. package/src/vision/eligibility.ts +88 -9
  369. package/src/vision/plan.ts +34 -10
  370. package/src/web-search/alpha-search.ts +21 -1
  371. package/src/web-search/executor.ts +41 -2
  372. package/src/web-search/loop.ts +6 -1
  373. package/gui/dist/assets/index-C5IebErG.js +0 -136
  374. package/gui/dist/assets/index-OESInAjC.css +0 -1
@@ -8,13 +8,15 @@
8
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
- * statuses (returned as Response, never thrown) are NOT retried. Mid-stream SSE resets are
12
- * out of scope — the response has already resolved by then.
11
+ * statuses (returned as Response, never thrown) are NOT retried. A reset after the response
12
+ * head is out of scope here, because the response has already resolved by then; the Responses
13
+ * transport asks the same question at that stage through the shared resend gate.
13
14
  *
14
15
  * MUST stay a leaf module: imports nothing from server.ts or adapters (kiro-retry imports
15
16
  * the shared abort helpers from here).
16
17
  */
17
18
  import { clearableDeadline } from "./abort";
19
+ import { redactSecretString } from "./redact";
18
20
 
19
21
  /**
20
22
  * Responses the origin may already be executing. RFC 9110 §9.2.2 forbids an intermediary
@@ -96,6 +98,55 @@ export function isReplayRefusalCode(code: unknown): boolean {
96
98
  /** Client-facing status for {@link UPSTREAM_RESET_REPLAY_REFUSED_CODE}. */
97
99
  export const REPLAY_REFUSED_STATUS = 429;
98
100
 
101
+ /**
102
+ * The header every surface attaches to a replay refusal, and its only accepted value.
103
+ *
104
+ * Dropping `Retry-After` is necessary and not sufficient. The Stainless-generated clients --
105
+ * `openai` and `anthropic` for both Python and Node, which is what most callers of this proxy
106
+ * actually are -- decide from a status table (408, 409, 429 and every 5xx) and compute their own
107
+ * backoff when no wait is named, so a 429 with no header is still resent. `x-should-retry` is
108
+ * the one signal each of them reads BEFORE that table, and `"false"` is the exact string they
109
+ * compare against.
110
+ */
111
+ export const REPLAY_REFUSAL_NO_RETRY_HEADER = "x-should-retry";
112
+ export const REPLAY_REFUSAL_NO_RETRY_VALUE = "false";
113
+
114
+ /** Spreadable form for the surfaces that build their headers as an object literal. */
115
+ export const REPLAY_REFUSAL_CLIENT_HEADERS: Readonly<Record<string, string>> = Object.freeze({
116
+ [REPLAY_REFUSAL_NO_RETRY_HEADER]: REPLAY_REFUSAL_NO_RETRY_VALUE,
117
+ });
118
+
119
+ /**
120
+ * Apply the one client-facing retry policy a refusal carries: no wait, and no automatic resend.
121
+ *
122
+ * Kept as a single function rather than two rules each surface repeats, because the two halves
123
+ * are only correct together -- a surface that removed the wait but not the suppression still
124
+ * hands a retrying client a turn it may already have run.
125
+ */
126
+ export function applyReplayRefusalClientHeaders(headers: Headers): void {
127
+ headers.delete("retry-after");
128
+ headers.set(REPLAY_REFUSAL_NO_RETRY_HEADER, REPLAY_REFUSAL_NO_RETRY_VALUE);
129
+ }
130
+
131
+ /**
132
+ * Mark a response that re-wraps a refusal as the same refusal.
133
+ *
134
+ * The verdict has to be a property of the result the surfaces pass around, because the thing it
135
+ * would otherwise be read from is the status, and 429 is exactly what a refusal and a real rate
136
+ * limit have in common. Every formatter between the helper that made the refusal and the client
137
+ * builds a new Response, so each of them restates the verdict rather than dropping it.
138
+ */
139
+ export function retainReplayRefusal<T extends Response>(response: T): T {
140
+ markResponseNonReplayable(response);
141
+ markReplayRefusalResponse(response);
142
+ return response;
143
+ }
144
+
145
+ /** Carry the verdict from a response onto the one that replaces it. */
146
+ export function carryReplayRefusal<T extends Response>(source: Response, rewrapped: T): T {
147
+ return isReplayRefusalResponse(source) ? retainReplayRefusal(rewrapped) : rewrapped;
148
+ }
149
+
99
150
  // 1 initial + 2 retries: the pool may hold more than one stale socket.
100
151
  const RESET_RETRY_MAX_ATTEMPTS = 3;
101
152
  const RESET_RETRY_BASE_DELAY_MS = 150;
@@ -421,6 +472,20 @@ export interface ResetRetryOptions {
421
472
  * uncounted but UNCOUNTABLE: the callback existed on a type those call sites never reach.
422
473
  */
423
474
  onSendsConsumed?: (sends: number) => void;
475
+ /**
476
+ * Spend one operator-granted replacement for a pre-header reset this helper would otherwise
477
+ * refuse. Absent means no operator policy, which is the fail-closed answer.
478
+ *
479
+ * A callback rather than a count, and the difference is the whole point. A count handed to
480
+ * each leg of a request is a count each leg holds: a rotation leg, a refresh leg and a
481
+ * same-target 429 leg carry the same turn, so three numbers is three replacements of one
482
+ * possibly-executed inference. The callback draws on ONE allowance held by the logical
483
+ * request, which the post-header protocol gate draws on too.
484
+ *
485
+ * It never widens the send budget. A claimed replacement still has to fit inside
486
+ * `attempts`, exactly like every other send this leg makes.
487
+ */
488
+ claimAmbiguousResend?: () => boolean;
424
489
  }
425
490
 
426
491
  export interface TransientRetryOptions extends ResetRetryOptions {
@@ -494,6 +559,24 @@ export function applyUpstreamRecoveryInit<T extends RequestInit>(
494
559
  return { ...init, headers, keepalive: false };
495
560
  }
496
561
 
562
+ /**
563
+ * The refusal this proxy returns for an ambiguous reset it will not replace. The WeakSet
564
+ * markers protect in-process recovery and the code survives JSON re-wrapping, so a combo or
565
+ * account-recovery layer downstream cannot read it as a replayable upstream fault. The raw
566
+ * exception is never exposed: it can carry credentials or request data.
567
+ */
568
+ export function replayRefusalResponse(): Response {
569
+ const response = new Response(JSON.stringify({ error: {
570
+ type: "upstream_error",
571
+ code: UPSTREAM_RESET_REPLAY_REFUSED_CODE,
572
+ message: "The upstream connection closed before a response was received. The request may already have been processed; automatic replay was stopped.",
573
+ } }), {
574
+ status: REPLAY_REFUSED_STATUS,
575
+ headers: { "content-type": "application/json", ...REPLAY_REFUSAL_CLIENT_HEADERS },
576
+ });
577
+ return retainReplayRefusal(response);
578
+ }
579
+
497
580
  /**
498
581
  * Run `doFetch` within one send budget. Connection-reset-shaped rejections are
499
582
  * terminal by default; only an explicitly replay-safe operation receives reset retries
@@ -511,6 +594,10 @@ export async function fetchWithResetRetry(
511
594
  if (attempts === 0) throw new SendBudgetExhaustedError(opts.label);
512
595
  let lastError: unknown;
513
596
  let sawReset = false;
597
+ // True once this leg has spent the request's operator allowance. From that point the leg can
598
+ // only settle as the refusal: a second send of a possibly-executed turn is already out, and
599
+ // handing the client anything it would retry compounds it.
600
+ let spentOperatorReplacement = false;
514
601
  for (let attempt = 0; attempt < attempts; attempt++) {
515
602
  if (opts.abortSignal?.aborted) throw abortError(opts.abortSignal);
516
603
  // Reported before the await, one physical send at a time: a send that rejects has still
@@ -522,31 +609,37 @@ export async function fetchWithResetRetry(
522
609
  } catch (err) {
523
610
  if (opts.abortSignal?.aborted) throw err;
524
611
  if (!isConnectionResetError(err)) {
612
+ // Whatever ended the leg, an operator replacement already went out, so the first send
613
+ // may have run the turn. Settle it as the refusal instead of throwing into a caller
614
+ // whose transport-failure path answers with a client-retryable 502.
615
+ if (spentOperatorReplacement) return replayRefusalResponse();
525
616
  // A reset that already reached the origin is credential-visible
526
617
  // evidence: keep it attached so the terminal rejection cannot be
527
618
  // downgraded to the pre-connection neutral class (#914 review).
528
619
  if (sawReset) throw new UpstreamRetryEvidenceError([], err, true);
529
620
  throw err;
530
621
  }
531
- if (opts.replaySafe !== true) {
532
- // Return evidence instead of throwing a generic transport error: outer catches
622
+ if (opts.replaySafe === true) {
623
+ // Repeating this operation cannot duplicate anything, so an exhausted budget rethrows
624
+ // and the caller's own error path takes over.
625
+ if (attempt === attempts - 1) throw err;
626
+ } else {
627
+ // The stage table refuses an ambiguous pre-header reset. The only thing that overrides
628
+ // it is an operator allowance, and claiming it here is what keeps the grant single --
629
+ // the post-header protocol gate spends the same counter for the same logical request.
630
+ // Return evidence rather than throwing a generic transport error: outer catches
533
631
  // 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;
632
+ if (attempt + 1 >= attempts || opts.claimAmbiguousResend?.() !== true) {
633
+ return replayRefusalResponse();
634
+ }
635
+ spentOperatorReplacement = true;
544
636
  }
545
- if (attempt === attempts - 1) throw err;
546
637
  sawReset = true;
547
638
  lastError = err;
548
639
  console.warn(
549
- `[upstream-retry] connection reset${opts.label ? ` (${opts.label})` : ""} — retrying (${attempt + 2}/${attempts})`,
640
+ `[upstream-retry] connection reset${opts.label ? ` (${opts.label})` : ""} — ${
641
+ spentOperatorReplacement ? "replacing" : "retrying"
642
+ } (${attempt + 2}/${attempts})`,
550
643
  );
551
644
  await sleepWithAbort(retryBackoffDelayMs(attempt, {
552
645
  baseDelayMs: RESET_RETRY_BASE_DELAY_MS,
@@ -661,3 +754,61 @@ export async function fetchWithTransientRetry(
661
754
  opts.onSendsConsumed?.(sent);
662
755
  }
663
756
  }
757
+
758
+ export type ProtocolSafeRefetch = (signal?: AbortSignal) => Promise<Response>;
759
+
760
+ export interface ProtocolSafeRefetchOptions extends ResetRetryOptions {
761
+ /** The replacement must match the response contract already selected for the client. */
762
+ acceptResponse?: (response: Response) => boolean;
763
+ /**
764
+ * Spend the logical request's allowance, immediately before the replacement send.
765
+ *
766
+ * Asked here and not earlier so a failure this helper would refuse on its own terms -- a
767
+ * non-reset error, a cancelled caller, a spent send budget -- cannot drain the one
768
+ * replacement a later ambiguous reset was entitled to. False refuses the replacement.
769
+ */
770
+ authorize?: () => boolean;
771
+ }
772
+
773
+ /**
774
+ * Attempt ONE caller-authorized replacement of a stream that died after the response head.
775
+ *
776
+ * The caller owns the proof that nothing was observed -- it comes from protocol inspection,
777
+ * not from this module -- and owns the physical-send budget. What lives here is the part that
778
+ * is easy to get wrong: a replacement is only usable if it is a fresh, unlocked, unread body
779
+ * that matches the contract already promised to the client, and anything else has to be
780
+ * cancelled and the original failure preserved.
781
+ */
782
+ export async function refetchAfterProtocolSafeReset(
783
+ doFetch: ProtocolSafeRefetch,
784
+ err: unknown,
785
+ opts: ProtocolSafeRefetchOptions = {},
786
+ ): Promise<Response | null> {
787
+ if (!isConnectionResetError(err) || opts.abortSignal?.aborted || opts.attempts === 0) return null;
788
+ const label = opts.label
789
+ ? " (" + redactSecretString(opts.label).replace(/[\r\n\u0000-\u001f\u007f]/g, "").slice(0, 128) + ")"
790
+ : "";
791
+ if (opts.authorize && !opts.authorize()) {
792
+ console.warn("[upstream-retry] post-header reset replacement refused" + label + "; preserving original stream error");
793
+ return null;
794
+ }
795
+ let replacement: Response;
796
+ try {
797
+ replacement = await doFetch(opts.abortSignal);
798
+ } catch {
799
+ console.warn("[upstream-retry] protocol-safe refetch failed" + label + "; preserving original stream error");
800
+ return null;
801
+ }
802
+ const body = replacement.body;
803
+ let accepted = !opts.abortSignal?.aborted && replacement.ok && body !== null
804
+ && !replacement.bodyUsed && !body.locked && !isNonReplayableResponse(replacement);
805
+ try { if (accepted && opts.acceptResponse) accepted = opts.acceptResponse(replacement); }
806
+ catch { accepted = false; }
807
+ if (!accepted || opts.abortSignal?.aborted || body?.locked) {
808
+ try { void body?.cancel().catch(() => {}); } catch { /* already locked or closed */ }
809
+ console.warn("[upstream-retry] protocol-safe refetch rejected" + label + "; preserving original stream error");
810
+ return null;
811
+ }
812
+ console.warn("[upstream-retry] pre-output Responses reset" + label + "; using one replacement stream");
813
+ return replacement;
814
+ }
@@ -16,7 +16,7 @@ import { decodeWindowsTextBytes } from "./windows-text";
16
16
  const INTERNET_SETTINGS_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings";
17
17
 
18
18
  export type WindowsSystemProxyResult =
19
- | { kind: "proxy"; url: string }
19
+ | { kind: "proxy"; httpUrl?: string; httpsUrl?: string }
20
20
  | { kind: "disabled" }
21
21
  | { kind: "socks-only" }
22
22
  | { kind: "unsupported" }
@@ -63,35 +63,40 @@ export function readWindowsProxyRegistry(): WindowsProxyRegistryValues | null {
63
63
 
64
64
  /**
65
65
  * `ProxyServer` is either a bare `host:port` (applies to every scheme) or a semicolon list of
66
- * `scheme=host:port` entries. Prefer the https entry, then http; a SOCKS-only value cannot be
67
- * mirrored into HTTP_PROXY/HTTPS_PROXY.
66
+ * `scheme=host:port` entries. Bare values apply to both HTTP and HTTPS destinations; per-scheme
67
+ * values retain their WinINET scope. A SOCKS-only value cannot be mirrored into HTTP(S)_PROXY.
68
68
  */
69
- export function parseWindowsProxyServer(value: string): { kind: "proxy"; url: string } | { kind: "socks-only" } | { kind: "disabled" } {
69
+ export function parseWindowsProxyServer(value: string): Extract<WindowsSystemProxyResult, { kind: "proxy" | "socks-only" | "disabled" }> {
70
70
  const trimmed = value.trim();
71
71
  if (!trimmed) return { kind: "disabled" };
72
- if (!trimmed.includes("=")) return normalize(trimmed);
72
+ if (!trimmed.includes("=")) {
73
+ const url = normalize(trimmed);
74
+ return url ? { kind: "proxy", httpUrl: url, httpsUrl: url } : { kind: "disabled" };
75
+ }
73
76
  const entries = new Map<string, string>();
74
77
  for (const part of trimmed.split(";")) {
75
78
  const eq = part.indexOf("=");
76
79
  if (eq <= 0) continue;
77
80
  entries.set(part.slice(0, eq).trim().toLowerCase(), part.slice(eq + 1).trim());
78
81
  }
79
- const candidate = entries.get("https") || entries.get("http");
80
- if (candidate) return normalize(candidate);
82
+ const httpUrl = normalize(entries.get("http") ?? "");
83
+ const httpsUrl = normalize(entries.get("https") ?? "");
84
+ if (httpUrl || httpsUrl) return { kind: "proxy", ...(httpUrl && { httpUrl }), ...(httpsUrl && { httpsUrl }) };
81
85
  if (entries.has("socks")) return { kind: "socks-only" };
82
86
  return { kind: "disabled" };
83
87
  }
84
88
 
85
- function normalize(hostPort: string): { kind: "proxy"; url: string } | { kind: "disabled" } {
89
+ function normalize(hostPort: string): string | undefined {
90
+ if (!hostPort) return undefined;
86
91
  const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(hostPort) ? hostPort : `http://${hostPort}`;
87
92
  try {
88
93
  const url = new URL(withScheme);
89
- if (!url.hostname || (url.protocol !== "http:" && url.protocol !== "https:")) return { kind: "disabled" };
94
+ if (!url.hostname || (url.protocol !== "http:" && url.protocol !== "https:")) return undefined;
90
95
  // Keep userinfo: a credentialed proxy is valid in HTTP_PROXY. Only the log strips it.
91
96
  const auth = url.username ? `${url.username}${url.password ? `:${url.password}` : ""}@` : "";
92
- return { kind: "proxy", url: `${url.protocol}//${auth}${url.host}` };
97
+ return `${url.protocol}//${auth}${url.host}`;
93
98
  } catch {
94
- return { kind: "disabled" };
99
+ return undefined;
95
100
  }
96
101
  }
97
102
 
package/src/lib/winsw.ts CHANGED
@@ -72,7 +72,7 @@ export interface WinswEntry {
72
72
  bun: string;
73
73
  /** Provenance of `bun`, resolved together with it so the two can never disagree. */
74
74
  bunRuntimeSource: BunRuntimeSource;
75
- cli: string;
75
+ cli: string | null;
76
76
  }
77
77
 
78
78
  /**
@@ -118,7 +118,7 @@ export function buildWinswXml(entry: WinswEntry, env: NodeJS.ProcessEnv = proces
118
118
  <name>OpenCodex Proxy (native)</name>
119
119
  <description>OpenCodex proxy running as a native Windows service (windowless, starts at boot).</description>
120
120
  <executable>${xmlEscape(entry.bun)}</executable>
121
- <arguments>${xmlEscape(`"${entry.cli}" start --port ${safeListenPort}`)}</arguments>
121
+ <arguments>${xmlEscape(`${entry.cli ? `"${entry.cli}" ` : ""}start --port ${safeListenPort}`)}</arguments>
122
122
  ${envLines.join("\n")}
123
123
  <logpath>${xmlEscape(winswLogDir())}</logpath>
124
124
  <log mode="roll-by-size">
@@ -252,9 +252,10 @@ export abstract class OAuthCallbackFlow {
252
252
  if (!parsed.code) return null;
253
253
  // Kind-aware state enforcement: url/query-shaped input is an authorization
254
254
  // RESPONSE and must carry a matching state — missing state is rejected, not
255
- // downgraded to raw. Only a syntactically raw code (same PKCE session) is
256
- // exempt, so the CLI/GUI paste fallback still works.
257
- if (parsed.kind !== "raw" && expectedState && parsed.state !== expectedState) return null;
255
+ // downgraded to raw. A raw paste with an explicit code#state suffix is
256
+ // state-bearing too. Only a syntactically raw code WITHOUT a state suffix
257
+ // (same PKCE session) is exempt, so the CLI/GUI paste fallback still works.
258
+ if ((parsed.kind !== "raw" || parsed.state !== undefined) && expectedState && parsed.state !== expectedState) return null;
258
259
  return { code: parsed.code, state: parsed.state ?? expectedState };
259
260
  })
260
261
  .catch((): CallbackResult | null => null),
@@ -234,6 +234,7 @@ function stableGenericRoster(providerName: string): string[] {
234
234
  * quota-less provider off its active account on the very first request.
235
235
  */
236
236
  function isOverAutoSwitchThreshold(providerName: string, accountId: string, threshold: number, requestedModelId?: string | null): boolean {
237
+ if (threshold <= 0) return false;
237
238
  const headroom = accountHeadroomPercent(providerName, accountId, requestedModelId);
238
239
  if (headroom === null) return false;
239
240
  return 100 - headroom >= threshold;
@@ -202,6 +202,7 @@ export function projectStoredOAuthAccountHealth(
202
202
  export function projectCodexAccountHealth(input: {
203
203
  accountId: string;
204
204
  needsReauth: boolean;
205
+ reauthReason?: "unauthorized" | "forbidden" | "refresh_failed";
205
206
  now?: number;
206
207
  }): OAuthAccountHealth {
207
208
  // One read serves every verdict below. Each lookup re-reads and re-hardens the whole store
@@ -230,6 +231,16 @@ export function projectCodexAccountHealth(input: {
230
231
 
231
232
  const needsReauth = input.needsReauth || validationAuthFailed || terminalGrantFailure;
232
233
 
234
+ // A stored verification failure carries its own cause. When the caller did not name a
235
+ // reason, surface the persisted one instead of flattening every verdict into
236
+ // `refresh_failed`.
237
+ const storedVerificationFailed = validationAuthFailed || terminalGrantFailure;
238
+ const storedFailureReason: "unauthorized" | "forbidden" | undefined = !storedVerificationFailed
239
+ ? undefined
240
+ : record?.lastCodexValidationError === "http_status:403" ? "forbidden"
241
+ : record?.lastCodexValidationError === "http_status:401" ? "unauthorized"
242
+ : undefined;
243
+
233
244
  // Deferred validation is only worth reporting while the credential itself is still viable. A
234
245
  // revoked grant needs a re-login, not a "Refresh quotas" click, so reauth is resolved first.
235
246
  if (!needsReauth && record?.codexValidationPending) {
@@ -239,7 +250,7 @@ export function projectCodexAccountHealth(input: {
239
250
  const snap = getCodexAccountHealthSnapshot(input.accountId, now);
240
251
  return projectOAuthAccountHealth({
241
252
  needsReauth,
242
- reauthReason: needsReauth ? "refresh_failed" : undefined,
253
+ reauthReason: needsReauth ? (input.reauthReason ?? storedFailureReason ?? "refresh_failed") : undefined,
243
254
  cooldownUntilMs: snap?.cooldownUntil,
244
255
  cooldownReason: cooldownReasonFromSource(snap?.cooldownSource),
245
256
  now,
@@ -1,6 +1,5 @@
1
1
  import type { KiroOAuthMetadata, OAuthController, OAuthCredentials } from "./types";
2
2
  import { initializeProviderModelSelection } from "../providers/initial-model-selection";
3
- import { parseCallbackInput } from "./callback-server";
4
3
  import type { OcxConfig, OcxProviderConfig, RefreshPolicy } from "../types";
5
4
  import { ConfigMutationLockError, loadConfig, mutatePersistedConfig, saveConfig } from "../config";
6
5
  import { resolveProviderApiKey } from "../providers/key-store";
@@ -51,8 +50,9 @@ import { providerModelsUrl, resolveProviderModelDiscoveryUrl } from "../provider
51
50
  import { resolveProviderTransport } from "../providers/xai-transport";
52
51
  import { detectClaudeCodeToken, detectGrokCliToken, hasComparableGrokIdentity, isSameGrokIdentity, shouldAdoptGrokGeneration } from "./local-token-detect";
53
52
  import { logOAuthEvent } from "./log";
54
- import { captureConfigGeneration, sweepExpiredOnWrite, type GenerationContext } from "../lib/state-store-sweeper";
55
- import { retainedUtf8Bytes } from "../lib/admission";
53
+ import { captureConfigGeneration, sweepExpiredOnWrite } from "../lib/state-store-sweeper";
54
+ import { clearManualCodeSlot, ensureManualCodeSlot, kiroLoginSettling, loginAbort, loginState, waitForManualLoginCode } from "./login-flow-state";
55
+ export { reconcileOAuthFlowState, submitManualLoginCode } from "./login-flow-state";
56
56
  import { randomUUID } from "node:crypto";
57
57
  export {
58
58
  CODEX_HEALTH_AUTH_FAILED_NOTE,
@@ -1273,6 +1273,29 @@ const OAUTH_RECONCILE_FIELDS: (keyof OcxProviderConfig)[] = [
1273
1273
  // existing rows through enrichProviderFromRegistry, which is fill-only and
1274
1274
  // preserves explicit saved values.
1275
1275
 
1276
+ /**
1277
+ * Output-budget fields an OAuth preset may refresh but must never erase.
1278
+ *
1279
+ * These stay on the reconcile list so a preset that does declare a budget still
1280
+ * refreshes the saved row. What changes is the other branch: when the preset
1281
+ * declares nothing, the operator's value survives instead of being deleted.
1282
+ *
1283
+ * Without that, the fields behaved as if they could not be configured at all.
1284
+ * No OAuth preset seeds either one, so the delete branch was the only branch
1285
+ * these two ever took, and a hand-edited `defaultMaxOutputTokens` was gone
1286
+ * before the first turn of the next startup — leaving the adapter's own
1287
+ * fallback as the only reachable output cap (#5190).
1288
+ *
1289
+ * Scoped to the output budget on purpose. The input side (`contextWindow`,
1290
+ * `modelContextWindows`) describes what the account's models are, which the
1291
+ * preset and live discovery do own; an output budget is a spend decision the
1292
+ * operator makes.
1293
+ */
1294
+ const OAUTH_PRESERVE_WHEN_PRESET_UNSET: ReadonlySet<keyof OcxProviderConfig> = new Set([
1295
+ "defaultMaxOutputTokens",
1296
+ "modelMaxOutputTokens",
1297
+ ]);
1298
+
1276
1299
  const GOOGLE_ANTIGRAVITY_PROVIDER = "google-antigravity";
1277
1300
  const GOOGLE_ANTIGRAVITY_LIVE_DISCOVERY_VERSION = 2 as const;
1278
1301
 
@@ -1312,7 +1335,7 @@ function applyOAuthPresetCatalog(
1312
1335
  if (JSON.stringify(provider[field]) === JSON.stringify(preset[field])) continue;
1313
1336
  if (preset[field] !== undefined) {
1314
1337
  provider[field] = cloneProviderField(preset[field]) as never;
1315
- } else {
1338
+ } else if (!OAUTH_PRESERVE_WHEN_PRESET_UNSET.has(field)) {
1316
1339
  delete provider[field];
1317
1340
  }
1318
1341
  }
@@ -1710,110 +1733,6 @@ export async function runLogin(
1710
1733
  * localhost), the GUI can POST the final redirect URL or authorization code via
1711
1734
  * submitManualLoginCode(), which feeds OAuthController.onManualCodeInput.
1712
1735
  */
1713
- const loginState = new Map<string, { error?: string; done: boolean }>();
1714
- const loginAbort = new Map<string, AbortController>();
1715
- const kiroLoginSettling = new Set<string>();
1716
-
1717
- /** Pending paste for a login in progress: either a waiter or a stashed early submission. */
1718
- interface ManualCodeSlot {
1719
- pendingInput?: string;
1720
- resolve?: (value: string) => void;
1721
- /** Registered by the callback flow so submits can validate state synchronously. */
1722
- expectedState?: string;
1723
- }
1724
- const loginManual = new Map<string, ManualCodeSlot>();
1725
- const OAUTH_PENDING_CODE_MAX_BYTES = 4 * 1024;
1726
- let lastOAuthFlowReconciledGeneration = 0;
1727
-
1728
- export function reconcileOAuthFlowState(context: GenerationContext): number {
1729
- if (context.generation <= lastOAuthFlowReconciledGeneration) return 0;
1730
- let removed = 0;
1731
- for (const [provider, state] of loginState) {
1732
- if (context.providerNames.has(provider) || !state.done || loginAbort.has(provider)) continue;
1733
- if (loginState.delete(provider)) removed += 1;
1734
- if (loginManual.delete(provider)) removed += 1;
1735
- if (loginAbort.delete(provider)) removed += 1;
1736
- }
1737
- lastOAuthFlowReconciledGeneration = context.generation;
1738
- return removed;
1739
- }
1740
-
1741
- function clearManualCodeSlot(provider: string): void {
1742
- loginManual.delete(provider);
1743
- }
1744
-
1745
- function ensureManualCodeSlot(provider: string): ManualCodeSlot {
1746
- let slot = loginManual.get(provider);
1747
- if (!slot) {
1748
- slot = {};
1749
- loginManual.set(provider, slot);
1750
- }
1751
- return slot;
1752
- }
1753
-
1754
- /** Wait for a GUI/CLI paste of the OAuth redirect URL or code (or return a stashed early submit). */
1755
- function waitForManualLoginCode(provider: string, signal: AbortSignal, expectedState?: string): Promise<string> {
1756
- if (signal.aborted) {
1757
- return Promise.reject(new Error(`OAuth callback cancelled: ${signal.reason}`));
1758
- }
1759
- const slot = ensureManualCodeSlot(provider);
1760
- if (expectedState !== undefined) slot.expectedState = expectedState;
1761
- if (slot.pendingInput !== undefined) {
1762
- const value = slot.pendingInput;
1763
- slot.pendingInput = undefined;
1764
- return Promise.resolve(value);
1765
- }
1766
- return new Promise<string>((resolve, reject) => {
1767
- const onAbort = () => {
1768
- if (slot.resolve === resolve) slot.resolve = undefined;
1769
- reject(new Error(`OAuth callback cancelled: ${signal.reason}`));
1770
- };
1771
- signal.addEventListener("abort", onAbort, { once: true });
1772
- slot.resolve = (value: string) => {
1773
- signal.removeEventListener("abort", onAbort);
1774
- if (slot.resolve === resolve) slot.resolve = undefined;
1775
- resolve(value);
1776
- };
1777
- });
1778
- }
1779
-
1780
- /**
1781
- * Feed a pasted redirect URL or authorization code into an in-progress GUI login.
1782
- * Returns ok:false when no login is waiting (or input is empty). Invalid pastes are accepted
1783
- * here and re-prompted by the OAuth callback loop if they cannot be parsed / fail state checks.
1784
- */
1785
- export function submitManualLoginCode(provider: string, input: string): { ok: true } | { ok: false; error: string } {
1786
- const trimmed = input.trim();
1787
- if (!trimmed) return { ok: false, error: "empty code" };
1788
- if (retainedUtf8Bytes(trimmed) > OAUTH_PENDING_CODE_MAX_BYTES) return { ok: false, error: "code too large" };
1789
- const st = loginState.get(provider);
1790
- if (!st || st.done) return { ok: false, error: "no login in progress" };
1791
- const slot = ensureManualCodeSlot(provider);
1792
- // Synchronous validation (validated request/ack): reject un-parseable input and
1793
- // authorization responses (url/query kind) whose state is missing or mismatched
1794
- // once the flow has registered its expected state. Raw codes stay in-session-PKCE
1795
- // protected. Early posts (flow not yet waiting, no expectedState) are stashed and
1796
- // re-validated by the callback loop.
1797
- const parsed = parseCallbackInput(trimmed);
1798
- // Command Code's manual fallback accepts a pasted JSON callback payload
1799
- // (`{ apiKey, state, ... }`) which has no `code` param. Let it through the
1800
- // shared gate so the provider-specific parser can validate it.
1801
- const isCommandCodeJson = provider === "command-code" && trimmed.startsWith("{") && !parsed.code;
1802
- if (!parsed.code && !isCommandCodeJson) return { ok: false, error: "no authorization code found in input" };
1803
- if (parsed.kind !== "raw" && slot.expectedState !== undefined) {
1804
- if (parsed.state === undefined) return { ok: false, error: "redirect URL is missing the state parameter" };
1805
- if (parsed.state !== slot.expectedState) return { ok: false, error: "state mismatch — paste the redirect URL from THIS login attempt" };
1806
- }
1807
- if (slot.resolve) {
1808
- const resolve = slot.resolve;
1809
- slot.resolve = undefined;
1810
- resolve(trimmed);
1811
- } else {
1812
- // Race: GUI may POST before the flow reaches onManualCodeInput — stash for the waiter.
1813
- slot.pendingInput = trimmed;
1814
- }
1815
- return { ok: true };
1816
- }
1817
1736
 
1818
1737
  export interface OAuthAccountSummary {
1819
1738
  id: string;