@remodex/rmx 1.0.2

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 (588) hide show
  1. package/AGENTS_INSTALL.md +91 -0
  2. package/LICENSE +21 -0
  3. package/README.md +242 -0
  4. package/assets/architecture.png +0 -0
  5. package/assets/banner.png +0 -0
  6. package/assets/claude-code-models.gif +0 -0
  7. package/assets/codex-app-picker.png +0 -0
  8. package/bin/ocx.mjs +584 -0
  9. package/bin/package-main.mjs +9 -0
  10. package/gui/dist/assets/index-CZqebSPQ.css +1 -0
  11. package/gui/dist/assets/index-CkETtt7P.js +71 -0
  12. package/gui/dist/favicon.png +0 -0
  13. package/gui/dist/fonts/google-sans-cyrillic.woff2 +0 -0
  14. package/gui/dist/fonts/google-sans-latin.woff2 +0 -0
  15. package/gui/dist/icons.svg +24 -0
  16. package/gui/dist/index.html +25 -0
  17. package/gui/dist/logo.png +0 -0
  18. package/gui/dist/provider-icons/alibaba-color.svg +1 -0
  19. package/gui/dist/provider-icons/antigravity-color.svg +1 -0
  20. package/gui/dist/provider-icons/claude-color.svg +1 -0
  21. package/gui/dist/provider-icons/cline-color.svg +16 -0
  22. package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -0
  23. package/gui/dist/provider-icons/commandcode-color.svg +1 -0
  24. package/gui/dist/provider-icons/copilot-color.svg +1 -0
  25. package/gui/dist/provider-icons/cursor-color.svg +2 -0
  26. package/gui/dist/provider-icons/deepseek-color.svg +1 -0
  27. package/gui/dist/provider-icons/discord.svg +1 -0
  28. package/gui/dist/provider-icons/firepass-color.svg +1 -0
  29. package/gui/dist/provider-icons/fireworks-color.svg +1 -0
  30. package/gui/dist/provider-icons/gemini-color.svg +1 -0
  31. package/gui/dist/provider-icons/github-copilot-color.svg +1 -0
  32. package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -0
  33. package/gui/dist/provider-icons/grok.svg +1 -0
  34. package/gui/dist/provider-icons/groq-color.svg +1 -0
  35. package/gui/dist/provider-icons/huggingface-color.svg +1 -0
  36. package/gui/dist/provider-icons/kimi-color.svg +1 -0
  37. package/gui/dist/provider-icons/kiro-color.svg +15 -0
  38. package/gui/dist/provider-icons/lm-studio-color.svg +1 -0
  39. package/gui/dist/provider-icons/mistral-color.svg +1 -0
  40. package/gui/dist/provider-icons/moonshot-color.svg +1 -0
  41. package/gui/dist/provider-icons/nvidia-color.svg +1 -0
  42. package/gui/dist/provider-icons/ollama-color.svg +1 -0
  43. package/gui/dist/provider-icons/openai.svg +1 -0
  44. package/gui/dist/provider-icons/opencode.svg +2 -0
  45. package/gui/dist/provider-icons/openrouter-color.svg +1 -0
  46. package/gui/dist/provider-icons/pi.svg +21 -0
  47. package/gui/dist/provider-icons/qianfan-color.svg +1 -0
  48. package/gui/dist/provider-icons/qwen-portal-color.svg +1 -0
  49. package/gui/dist/provider-icons/telegram.svg +1 -0
  50. package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -0
  51. package/gui/dist/provider-icons/vllm-color.svg +1 -0
  52. package/gui/dist/provider-icons/xiaomi-color.svg +1 -0
  53. package/package.json +118 -0
  54. package/src/AGENTS.md +28 -0
  55. package/src/adapters/anthropic-image-guard.ts +251 -0
  56. package/src/adapters/anthropic-image-normalize.ts +518 -0
  57. package/src/adapters/anthropic.ts +1205 -0
  58. package/src/adapters/azure.ts +36 -0
  59. package/src/adapters/base.ts +83 -0
  60. package/src/adapters/client-fingerprint.ts +59 -0
  61. package/src/adapters/command-code.ts +453 -0
  62. package/src/adapters/cursor/arg-codec.ts +38 -0
  63. package/src/adapters/cursor/arg-normalize.ts +104 -0
  64. package/src/adapters/cursor/cursor-errors.ts +165 -0
  65. package/src/adapters/cursor/discovery.ts +276 -0
  66. package/src/adapters/cursor/effort-map.ts +139 -0
  67. package/src/adapters/cursor/exec-policy.ts +88 -0
  68. package/src/adapters/cursor/framing.ts +250 -0
  69. package/src/adapters/cursor/gen/agent_pb.ts +15274 -0
  70. package/src/adapters/cursor/kv-store.ts +52 -0
  71. package/src/adapters/cursor/live-models.ts +153 -0
  72. package/src/adapters/cursor/live-smoke-gate.ts +41 -0
  73. package/src/adapters/cursor/live-transport.ts +1235 -0
  74. package/src/adapters/cursor/mcp-config.ts +42 -0
  75. package/src/adapters/cursor/mcp-manager.ts +333 -0
  76. package/src/adapters/cursor/message-mapper.ts +49 -0
  77. package/src/adapters/cursor/native-exec-common.ts +59 -0
  78. package/src/adapters/cursor/native-exec-desktop.ts +184 -0
  79. package/src/adapters/cursor/native-exec-fs.ts +332 -0
  80. package/src/adapters/cursor/native-exec-mcp.ts +153 -0
  81. package/src/adapters/cursor/native-exec-network.ts +43 -0
  82. package/src/adapters/cursor/native-exec-shell.ts +548 -0
  83. package/src/adapters/cursor/native-exec-tools.ts +118 -0
  84. package/src/adapters/cursor/native-exec.ts +604 -0
  85. package/src/adapters/cursor/protobuf-events.ts +735 -0
  86. package/src/adapters/cursor/protobuf-request.ts +719 -0
  87. package/src/adapters/cursor/request-builder.ts +280 -0
  88. package/src/adapters/cursor/thread-continuity.ts +67 -0
  89. package/src/adapters/cursor/tool-definitions.ts +621 -0
  90. package/src/adapters/cursor/transport-retry.ts +132 -0
  91. package/src/adapters/cursor/transport.ts +57 -0
  92. package/src/adapters/cursor/types.ts +59 -0
  93. package/src/adapters/cursor.ts +196 -0
  94. package/src/adapters/google-antigravity-replay.ts +520 -0
  95. package/src/adapters/google-antigravity-wire.ts +140 -0
  96. package/src/adapters/google-errors.ts +85 -0
  97. package/src/adapters/google-http.ts +100 -0
  98. package/src/adapters/google-tool-schema.ts +173 -0
  99. package/src/adapters/google-truncation.ts +24 -0
  100. package/src/adapters/google-wire-compiler.ts +232 -0
  101. package/src/adapters/google.ts +859 -0
  102. package/src/adapters/identity.ts +77 -0
  103. package/src/adapters/image.ts +23 -0
  104. package/src/adapters/kiro-constants.ts +16 -0
  105. package/src/adapters/kiro-errors.ts +208 -0
  106. package/src/adapters/kiro-events.ts +197 -0
  107. package/src/adapters/kiro-images.ts +129 -0
  108. package/src/adapters/kiro-retry.ts +312 -0
  109. package/src/adapters/kiro-thinking.ts +104 -0
  110. package/src/adapters/kiro-tool-fallback.ts +36 -0
  111. package/src/adapters/kiro-tools.ts +224 -0
  112. package/src/adapters/kiro-truncation.ts +33 -0
  113. package/src/adapters/kiro-wire.ts +129 -0
  114. package/src/adapters/kiro.ts +1924 -0
  115. package/src/adapters/mimo-free.ts +263 -0
  116. package/src/adapters/openai-chat.ts +1265 -0
  117. package/src/adapters/openai-responses.ts +1309 -0
  118. package/src/adapters/run-turn-queue.ts +114 -0
  119. package/src/adapters/tool-catalog-nudge.ts +71 -0
  120. package/src/adapters/upstream-http-error.ts +48 -0
  121. package/src/android-remote/assets.ts +218 -0
  122. package/src/android-remote/attachments.ts +168 -0
  123. package/src/android-remote/auth.ts +237 -0
  124. package/src/android-remote/cloudflare-provisioning.ts +409 -0
  125. package/src/android-remote/cloudflare-secret.ts +106 -0
  126. package/src/android-remote/cloudflare-tunnel.ts +488 -0
  127. package/src/android-remote/cloudflared.ts +286 -0
  128. package/src/android-remote/codex-app-server.ts +565 -0
  129. package/src/android-remote/desktop-history-page.ts +936 -0
  130. package/src/android-remote/desktop-ipc.ts +3643 -0
  131. package/src/android-remote/desktop-ownership-store.ts +98 -0
  132. package/src/android-remote/desktop-project-registration.ts +129 -0
  133. package/src/android-remote/desktop-session-stream.ts +1110 -0
  134. package/src/android-remote/desktop-workspace-state.ts +443 -0
  135. package/src/android-remote/file-change-parser.ts +112 -0
  136. package/src/android-remote/gateway.ts +9108 -0
  137. package/src/android-remote/mutation-store.ts +299 -0
  138. package/src/android-remote/projection.ts +1780 -0
  139. package/src/android-remote/queued-turn-store.ts +248 -0
  140. package/src/android-remote/session-command-recovery.ts +1384 -0
  141. package/src/android-remote/store.ts +466 -0
  142. package/src/android-remote/thread-reconciliation.ts +133 -0
  143. package/src/android-remote/thread-source-paths.ts +307 -0
  144. package/src/android-remote/thread-stream.ts +546 -0
  145. package/src/android-remote/turn-activity.ts +235 -0
  146. package/src/android-remote/user-message-identity.ts +94 -0
  147. package/src/bridge.ts +1793 -0
  148. package/src/chat/inbound.ts +295 -0
  149. package/src/chat/outbound.ts +821 -0
  150. package/src/claude/agents-inject.ts +267 -0
  151. package/src/claude/alias.ts +149 -0
  152. package/src/claude/auth-detect.ts +229 -0
  153. package/src/claude/auth-mode-migration.ts +32 -0
  154. package/src/claude/auth-mode.ts +62 -0
  155. package/src/claude/context-windows.ts +189 -0
  156. package/src/claude/desktop-3p-guard.ts +35 -0
  157. package/src/claude/desktop-3p-paths.ts +84 -0
  158. package/src/claude/desktop-3p.ts +601 -0
  159. package/src/claude/desktop-health.ts +26 -0
  160. package/src/claude/desktop-profile.ts +263 -0
  161. package/src/claude/gateway-cache.ts +70 -0
  162. package/src/claude/inbound-debug.ts +163 -0
  163. package/src/claude/inbound.ts +519 -0
  164. package/src/claude/model-info.ts +154 -0
  165. package/src/claude/outbound.ts +898 -0
  166. package/src/cli/access.ts +108 -0
  167. package/src/cli/account-api.ts +296 -0
  168. package/src/cli/account-auth.ts +250 -0
  169. package/src/cli/account-catalog-refresh.ts +14 -0
  170. package/src/cli/account-extended.ts +476 -0
  171. package/src/cli/account-main.ts +317 -0
  172. package/src/cli/account.ts +297 -0
  173. package/src/cli/agent-driven.ts +70 -0
  174. package/src/cli/agent.ts +184 -0
  175. package/src/cli/catalog-prewarm.ts +27 -0
  176. package/src/cli/claude-desktop.ts +211 -0
  177. package/src/cli/claude.ts +302 -0
  178. package/src/cli/codex-shim-autorestore.ts +45 -0
  179. package/src/cli/codex-shim-readiness.ts +69 -0
  180. package/src/cli/combo.ts +124 -0
  181. package/src/cli/config-command.ts +183 -0
  182. package/src/cli/debug.ts +228 -0
  183. package/src/cli/desktop-first-run.ts +25 -0
  184. package/src/cli/doctor.ts +1022 -0
  185. package/src/cli/export-command.ts +201 -0
  186. package/src/cli/help.ts +370 -0
  187. package/src/cli/index.ts +1565 -0
  188. package/src/cli/init.ts +224 -0
  189. package/src/cli/integrations.ts +225 -0
  190. package/src/cli/interactive-confirm.ts +133 -0
  191. package/src/cli/internal-dispatch.ts +35 -0
  192. package/src/cli/launcher-context.ts +77 -0
  193. package/src/cli/models-runtime.ts +224 -0
  194. package/src/cli/models.ts +340 -0
  195. package/src/cli/observe.ts +170 -0
  196. package/src/cli/opencode.ts +587 -0
  197. package/src/cli/provider-runtime.ts +179 -0
  198. package/src/cli/provider.ts +476 -0
  199. package/src/cli/ready.ts +301 -0
  200. package/src/cli/route-policy.ts +92 -0
  201. package/src/cli/runtime-api.ts +328 -0
  202. package/src/cli/star-prompt.ts +211 -0
  203. package/src/cli/status-oauth.ts +78 -0
  204. package/src/cli/status.ts +321 -0
  205. package/src/cli/system-command.ts +196 -0
  206. package/src/cli/system-restart-client.ts +146 -0
  207. package/src/cli/tray-proxy.ts +205 -0
  208. package/src/cli/v2.ts +200 -0
  209. package/src/cli.ts +10 -0
  210. package/src/clients/config-export.ts +1109 -0
  211. package/src/codex/account-id.ts +34 -0
  212. package/src/codex/account-label.ts +34 -0
  213. package/src/codex/account-lifecycle.ts +172 -0
  214. package/src/codex/account-namespace-match.ts +63 -0
  215. package/src/codex/account-namespaces.ts +195 -0
  216. package/src/codex/account-pause.ts +20 -0
  217. package/src/codex/account-priority.ts +83 -0
  218. package/src/codex/account-runtime-state.ts +31 -0
  219. package/src/codex/account-store.ts +517 -0
  220. package/src/codex/account-usability.ts +40 -0
  221. package/src/codex/admission.ts +263 -0
  222. package/src/codex/app-server-processes.ts +799 -0
  223. package/src/codex/auth-api.ts +2098 -0
  224. package/src/codex/auth-collision.ts +107 -0
  225. package/src/codex/auth-context.ts +480 -0
  226. package/src/codex/autostart-health.ts +156 -0
  227. package/src/codex/catalog/account-models.ts +67 -0
  228. package/src/codex/catalog/aggregation.ts +471 -0
  229. package/src/codex/catalog/bundled.ts +533 -0
  230. package/src/codex/catalog/effort.ts +432 -0
  231. package/src/codex/catalog/filesystem-evidence.ts +302 -0
  232. package/src/codex/catalog/kinds.ts +2 -0
  233. package/src/codex/catalog/metadata.ts +287 -0
  234. package/src/codex/catalog/native-models.ts +7 -0
  235. package/src/codex/catalog/parsing.ts +503 -0
  236. package/src/codex/catalog/provider-fetch.ts +2267 -0
  237. package/src/codex/catalog/sync.ts +1606 -0
  238. package/src/codex/catalog-admission.ts +197 -0
  239. package/src/codex/catalog-refresh-status.ts +87 -0
  240. package/src/codex/catalog-write-serialization.ts +242 -0
  241. package/src/codex/catalog.ts +15 -0
  242. package/src/codex/codex-write-lock.ts +384 -0
  243. package/src/codex/convergence-types.ts +593 -0
  244. package/src/codex/convergence.ts +580 -0
  245. package/src/codex/custom-model-catalog-migration.ts +176 -0
  246. package/src/codex/data/upstream-models.json +830 -0
  247. package/src/codex/desired-state.ts +230 -0
  248. package/src/codex/desktop-client-processes.ts +521 -0
  249. package/src/codex/exec-invocation.ts +22 -0
  250. package/src/codex/features.ts +1091 -0
  251. package/src/codex/generation.ts +202 -0
  252. package/src/codex/history-job.ts +347 -0
  253. package/src/codex/history-lock.ts +242 -0
  254. package/src/codex/history-migration-guardian.ts +115 -0
  255. package/src/codex/history-provider.ts +1075 -0
  256. package/src/codex/history-transition.ts +105 -0
  257. package/src/codex/history-worker.ts +204 -0
  258. package/src/codex/home.ts +206 -0
  259. package/src/codex/inject-coordination.ts +257 -0
  260. package/src/codex/inject.ts +1857 -0
  261. package/src/codex/injected-marker.ts +79 -0
  262. package/src/codex/integration-record.ts +266 -0
  263. package/src/codex/internal/catalog-writer.ts +203 -0
  264. package/src/codex/internal/history-writer.ts +105 -0
  265. package/src/codex/journal.ts +172 -0
  266. package/src/codex/main-account-cache.ts +56 -0
  267. package/src/codex/main-account.ts +40 -0
  268. package/src/codex/management-convergence.ts +114 -0
  269. package/src/codex/model-cache.ts +267 -0
  270. package/src/codex/native-main-admission.ts +47 -0
  271. package/src/codex/native-main-auth-temp.ts +187 -0
  272. package/src/codex/native-main-claim.ts +167 -0
  273. package/src/codex/native-main-lock-file.ts +162 -0
  274. package/src/codex/native-main-owner.ts +329 -0
  275. package/src/codex/native-profile-api.ts +247 -0
  276. package/src/codex/native-profile-manager.ts +1531 -0
  277. package/src/codex/native-profile-processes.ts +121 -0
  278. package/src/codex/native-profile-recovery.ts +99 -0
  279. package/src/codex/native-profile-stage-store.ts +387 -0
  280. package/src/codex/native-profile-startup.ts +348 -0
  281. package/src/codex/native-profile-store.ts +855 -0
  282. package/src/codex/native-profile-types.ts +120 -0
  283. package/src/codex/native-residue.ts +691 -0
  284. package/src/codex/paths.ts +78 -0
  285. package/src/codex/plugins-doctor.ts +242 -0
  286. package/src/codex/pool-rotation.ts +295 -0
  287. package/src/codex/project-config-warnings.ts +426 -0
  288. package/src/codex/prompt-journal.ts +311 -0
  289. package/src/codex/prompt-layers.ts +967 -0
  290. package/src/codex/prompt-lock.ts +143 -0
  291. package/src/codex/provider-adoption.ts +242 -0
  292. package/src/codex/quota-rejection.ts +224 -0
  293. package/src/codex/quota.ts +494 -0
  294. package/src/codex/refresh.ts +60 -0
  295. package/src/codex/routing.ts +1855 -0
  296. package/src/codex/runtime.ts +659 -0
  297. package/src/codex/shim.ts +1215 -0
  298. package/src/codex/subagent-defaults.ts +557 -0
  299. package/src/codex/subagent-model-fallback.ts +560 -0
  300. package/src/codex/sync.ts +238 -0
  301. package/src/codex/transition-state.ts +612 -0
  302. package/src/codex/upstream-host-health.ts +368 -0
  303. package/src/codex/user-identity.ts +374 -0
  304. package/src/codex/warmup.ts +192 -0
  305. package/src/codex/websocket-registry.ts +100 -0
  306. package/src/codex/write-coordination.ts +114 -0
  307. package/src/combos/failover.ts +140 -0
  308. package/src/combos/index.ts +44 -0
  309. package/src/combos/request.ts +64 -0
  310. package/src/combos/resolve.ts +232 -0
  311. package/src/combos/types.ts +392 -0
  312. package/src/config.ts +3270 -0
  313. package/src/generated/model-metadata.ts +144 -0
  314. package/src/github/star-state.ts +203 -0
  315. package/src/grok/inject.ts +540 -0
  316. package/src/grok/inspect.ts +45 -0
  317. package/src/grok/status.ts +127 -0
  318. package/src/grok/sync.ts +66 -0
  319. package/src/images/artifacts.ts +516 -0
  320. package/src/images/fulfill-video.ts +163 -0
  321. package/src/images/fulfill.ts +149 -0
  322. package/src/images/index.ts +4 -0
  323. package/src/images/loop.ts +922 -0
  324. package/src/images/plan.ts +133 -0
  325. package/src/images/synthetic-tool.ts +133 -0
  326. package/src/images/types.ts +41 -0
  327. package/src/images/xai-client.ts +141 -0
  328. package/src/images/xai-video-client.ts +163 -0
  329. package/src/index.ts +22 -0
  330. package/src/integrations/config-io.ts +151 -0
  331. package/src/integrations/journal.ts +315 -0
  332. package/src/integrations/merge.ts +135 -0
  333. package/src/integrations/native/ownership-preflight.ts +202 -0
  334. package/src/integrations/ownership.ts +111 -0
  335. package/src/integrations/registry.ts +108 -0
  336. package/src/integrations/serialize.ts +235 -0
  337. package/src/integrations/state.ts +290 -0
  338. package/src/integrations/store.ts +103 -0
  339. package/src/integrations/writer.ts +492 -0
  340. package/src/lib/abort.ts +146 -0
  341. package/src/lib/admin-secrets.ts +25 -0
  342. package/src/lib/admission.ts +83 -0
  343. package/src/lib/app-owned-memory-stores.ts +173 -0
  344. package/src/lib/app-owned-memory.ts +265 -0
  345. package/src/lib/bounded-body.ts +242 -0
  346. package/src/lib/bun-binary-validator.d.mts +3 -0
  347. package/src/lib/bun-binary-validator.mjs +18 -0
  348. package/src/lib/bun-runtime.ts +184 -0
  349. package/src/lib/bun-stream-caps.ts +127 -0
  350. package/src/lib/config-ownership.ts +438 -0
  351. package/src/lib/crash-guard.ts +344 -0
  352. package/src/lib/debug-log-buffer.ts +83 -0
  353. package/src/lib/debug-settings.ts +108 -0
  354. package/src/lib/debug.ts +31 -0
  355. package/src/lib/destination-policy.ts +316 -0
  356. package/src/lib/errors.ts +364 -0
  357. package/src/lib/eventstream-decoder.ts +253 -0
  358. package/src/lib/gcp-adc.ts +341 -0
  359. package/src/lib/injection-debug-log.ts +58 -0
  360. package/src/lib/local-management-attestation.ts +51 -0
  361. package/src/lib/open-url.ts +25 -0
  362. package/src/lib/pinned-http.ts +182 -0
  363. package/src/lib/privacy.ts +20 -0
  364. package/src/lib/process-control.ts +168 -0
  365. package/src/lib/provider-environment.ts +470 -0
  366. package/src/lib/provider-outbound.ts +203 -0
  367. package/src/lib/provider-url.ts +14 -0
  368. package/src/lib/proxy-env.ts +18 -0
  369. package/src/lib/redact.ts +510 -0
  370. package/src/lib/remodex-home.ts +616 -0
  371. package/src/lib/retry-after.ts +55 -0
  372. package/src/lib/service-secrets.ts +178 -0
  373. package/src/lib/shadow-call.ts +54 -0
  374. package/src/lib/sidecar-tracker.ts +52 -0
  375. package/src/lib/sse-decoder.ts +364 -0
  376. package/src/lib/state-store-registrations.ts +109 -0
  377. package/src/lib/state-store-sweeper.ts +184 -0
  378. package/src/lib/system-restart-contract.ts +73 -0
  379. package/src/lib/test-home-guard.ts +98 -0
  380. package/src/lib/token-estimate.ts +69 -0
  381. package/src/lib/translator-budget.ts +366 -0
  382. package/src/lib/upstream-reachability.ts +91 -0
  383. package/src/lib/upstream-retry.ts +508 -0
  384. package/src/lib/win-exec.ts +115 -0
  385. package/src/lib/win-paths.ts +68 -0
  386. package/src/lib/windows-elevation.ts +705 -0
  387. package/src/lib/windows-secret-acl.ts +817 -0
  388. package/src/lib/windows-user-principal.ts +283 -0
  389. package/src/lib/winsw.ts +402 -0
  390. package/src/model-sources.ts +73 -0
  391. package/src/oauth/anthropic-routing.ts +594 -0
  392. package/src/oauth/anthropic.ts +177 -0
  393. package/src/oauth/callback-server.ts +294 -0
  394. package/src/oauth/chatgpt.ts +150 -0
  395. package/src/oauth/command-code.ts +239 -0
  396. package/src/oauth/cursor.ts +231 -0
  397. package/src/oauth/github-copilot.ts +428 -0
  398. package/src/oauth/google-antigravity.ts +230 -0
  399. package/src/oauth/health.ts +443 -0
  400. package/src/oauth/index.ts +1280 -0
  401. package/src/oauth/key-providers.ts +128 -0
  402. package/src/oauth/kimi.ts +213 -0
  403. package/src/oauth/kiro-credentials.ts +726 -0
  404. package/src/oauth/kiro.ts +621 -0
  405. package/src/oauth/local-token-detect.ts +121 -0
  406. package/src/oauth/log.ts +48 -0
  407. package/src/oauth/login-cli.ts +163 -0
  408. package/src/oauth/pkce.ts +15 -0
  409. package/src/oauth/store.ts +655 -0
  410. package/src/oauth/token-guardian.ts +309 -0
  411. package/src/oauth/types.ts +62 -0
  412. package/src/oauth/xai.ts +241 -0
  413. package/src/providers/alibaba-region-backup.ts +75 -0
  414. package/src/providers/alibaba-region-migration.ts +156 -0
  415. package/src/providers/alibaba-region-startup.ts +36 -0
  416. package/src/providers/antigravity-models.ts +317 -0
  417. package/src/providers/api-keys.ts +140 -0
  418. package/src/providers/base-url-choices.ts +64 -0
  419. package/src/providers/codex-capacity.ts +288 -0
  420. package/src/providers/command-code-efforts.ts +85 -0
  421. package/src/providers/context-cap.ts +73 -0
  422. package/src/providers/derive.ts +451 -0
  423. package/src/providers/free-directory.ts +187 -0
  424. package/src/providers/github-copilot-transport.ts +56 -0
  425. package/src/providers/google-vertex-location.ts +14 -0
  426. package/src/providers/key-failover.ts +271 -0
  427. package/src/providers/kiro-models.ts +67 -0
  428. package/src/providers/label.ts +19 -0
  429. package/src/providers/model-discovery-limits.ts +16 -0
  430. package/src/providers/model-discovery.ts +361 -0
  431. package/src/providers/openai-sidecar.ts +235 -0
  432. package/src/providers/openai-tier-startup.ts +27 -0
  433. package/src/providers/openai-tiers.ts +301 -0
  434. package/src/providers/openai-virtual-models.ts +83 -0
  435. package/src/providers/openrouter-routing.ts +102 -0
  436. package/src/providers/provider-id-rewrite.ts +179 -0
  437. package/src/providers/quota.ts +1942 -0
  438. package/src/providers/reasoning-capabilities.ts +336 -0
  439. package/src/providers/registry.ts +2375 -0
  440. package/src/providers/slug-codec.ts +74 -0
  441. package/src/providers/xai-transport.ts +149 -0
  442. package/src/reasoning-effort.ts +243 -0
  443. package/src/responses/compaction.ts +124 -0
  444. package/src/responses/hosted-tool-policy.ts +9 -0
  445. package/src/responses/parser.ts +714 -0
  446. package/src/responses/reasoning-envelope.ts +60 -0
  447. package/src/responses/reasoning-replay-cache.ts +106 -0
  448. package/src/responses/schema.ts +159 -0
  449. package/src/responses/spill-store.ts +431 -0
  450. package/src/responses/state.ts +1039 -0
  451. package/src/responses/tool-groups.ts +19 -0
  452. package/src/router.ts +802 -0
  453. package/src/routing/analytics.ts +377 -0
  454. package/src/routing/capability.ts +205 -0
  455. package/src/routing/cost.ts +77 -0
  456. package/src/routing/evaluator.ts +444 -0
  457. package/src/routing/health.ts +401 -0
  458. package/src/routing/history/cursor.ts +43 -0
  459. package/src/routing/history/indexer.ts +605 -0
  460. package/src/routing/history/schema.ts +72 -0
  461. package/src/routing/profile-namespace.ts +15 -0
  462. package/src/routing/profile.ts +424 -0
  463. package/src/routing/quota.ts +145 -0
  464. package/src/routing/request-evidence.ts +45 -0
  465. package/src/routing/trace.ts +686 -0
  466. package/src/server/adapter-resolve.ts +83 -0
  467. package/src/server/auth-cors.ts +606 -0
  468. package/src/server/chat-completions.ts +379 -0
  469. package/src/server/claude-messages.ts +980 -0
  470. package/src/server/effort-policy.ts +251 -0
  471. package/src/server/github-copilot-responses-repair.ts +338 -0
  472. package/src/server/gui-static.ts +152 -0
  473. package/src/server/image-retry.ts +42 -0
  474. package/src/server/images.ts +485 -0
  475. package/src/server/index.ts +1633 -0
  476. package/src/server/lifecycle.ts +482 -0
  477. package/src/server/live.ts +609 -0
  478. package/src/server/management/agent-settings-routes.ts +1180 -0
  479. package/src/server/management/android-remote-routes.ts +390 -0
  480. package/src/server/management/api-access.ts +141 -0
  481. package/src/server/management/api-key-usage.ts +167 -0
  482. package/src/server/management/body.ts +35 -0
  483. package/src/server/management/combo-routes.ts +244 -0
  484. package/src/server/management/config-routes.ts +602 -0
  485. package/src/server/management/context.ts +88 -0
  486. package/src/server/management/integration-routes.ts +538 -0
  487. package/src/server/management/logs-usage-routes.ts +516 -0
  488. package/src/server/management/model-routes.ts +519 -0
  489. package/src/server/management/model-rows.ts +143 -0
  490. package/src/server/management/native-integration-routes.ts +781 -0
  491. package/src/server/management/oauth-account-routes.ts +573 -0
  492. package/src/server/management/provider-routes.ts +781 -0
  493. package/src/server/management/request-history-routes.ts +191 -0
  494. package/src/server/management/routing-analytics-routes.ts +74 -0
  495. package/src/server/management/routing-profile-routes.ts +384 -0
  496. package/src/server/management/shared.ts +277 -0
  497. package/src/server/management/sidebar-routes.ts +106 -0
  498. package/src/server/management/sync-response.ts +69 -0
  499. package/src/server/management/system-restart.ts +433 -0
  500. package/src/server/management/system-routes.ts +141 -0
  501. package/src/server/management/usage-summary-cache.ts +86 -0
  502. package/src/server/management-api.ts +269 -0
  503. package/src/server/management-auth.ts +353 -0
  504. package/src/server/memory-watchdog.ts +156 -0
  505. package/src/server/port-reclaim.ts +307 -0
  506. package/src/server/ports.ts +156 -0
  507. package/src/server/proxy-liveness.ts +326 -0
  508. package/src/server/proxy-stop.ts +92 -0
  509. package/src/server/readiness.ts +99 -0
  510. package/src/server/relay-eager.ts +353 -0
  511. package/src/server/relay.ts +1179 -0
  512. package/src/server/request-decompress.ts +132 -0
  513. package/src/server/request-log-conversation.ts +168 -0
  514. package/src/server/request-log.ts +1072 -0
  515. package/src/server/responses/collaboration.ts +409 -0
  516. package/src/server/responses/compact.ts +710 -0
  517. package/src/server/responses/core.ts +3561 -0
  518. package/src/server/responses/encrypted-payload.ts +308 -0
  519. package/src/server/responses/fetch-helpers.ts +171 -0
  520. package/src/server/responses/passthrough-error.ts +78 -0
  521. package/src/server/responses/policy-fallback.ts +152 -0
  522. package/src/server/responses/terminal-guard.ts +230 -0
  523. package/src/server/responses/upstream-error.ts +48 -0
  524. package/src/server/responses-image-gen-repair.ts +132 -0
  525. package/src/server/responses-item-id-repair.ts +272 -0
  526. package/src/server/responses-json-events.ts +52 -0
  527. package/src/server/responses-model-rewrite.ts +29 -0
  528. package/src/server/responses-snapshot-repair.ts +621 -0
  529. package/src/server/responses.ts +10 -0
  530. package/src/server/search.ts +181 -0
  531. package/src/server/sse-frame-buffer.ts +292 -0
  532. package/src/server/sse-payload-rewrite.ts +263 -0
  533. package/src/server/startup-action-control.ts +308 -0
  534. package/src/server/startup-health-cache.ts +119 -0
  535. package/src/server/system-env.ts +418 -0
  536. package/src/server/windows-tcp-drop.ts +184 -0
  537. package/src/server/windows-tray-control.ts +41 -0
  538. package/src/server/ws-bridge.ts +470 -0
  539. package/src/service-manager-probe.ts +824 -0
  540. package/src/service.ts +3011 -0
  541. package/src/stall-timeout.ts +20 -0
  542. package/src/storage/cleanup-job.ts +57 -0
  543. package/src/storage/cleanup.ts +3085 -0
  544. package/src/storage/policy-job.ts +457 -0
  545. package/src/storage/policy-scheduler.ts +40 -0
  546. package/src/storage/policy-worker.ts +59 -0
  547. package/src/storage/policy.ts +527 -0
  548. package/src/storage/restore-job.ts +299 -0
  549. package/src/storage/restore-worker.ts +58 -0
  550. package/src/storage/scanner.ts +238 -0
  551. package/src/storage/storage-mutation-coordinator.ts +139 -0
  552. package/src/storage/worker-lifecycle.ts +215 -0
  553. package/src/tray/assets/opencodex-tray-offline.ico +0 -0
  554. package/src/tray/assets/opencodex-tray-online.ico +0 -0
  555. package/src/tray/assets/opencodex-tray-warning.ico +0 -0
  556. package/src/tray/assets/opencodex-tray.png +0 -0
  557. package/src/tray/windows-tray.ps1 +364 -0
  558. package/src/tray/windows.ts +738 -0
  559. package/src/types.ts +1531 -0
  560. package/src/update/badge.ts +72 -0
  561. package/src/update/desktop-release.ts +1620 -0
  562. package/src/update/index.ts +402 -0
  563. package/src/update/job.ts +1906 -0
  564. package/src/update/notify.ts +261 -0
  565. package/src/update/npm-cache-preflight.d.mts +47 -0
  566. package/src/update/npm-cache-preflight.mjs +201 -0
  567. package/src/update/npm-invocation.d.mts +23 -0
  568. package/src/update/npm-invocation.mjs +94 -0
  569. package/src/update/tray-update-plan.d.mts +18 -0
  570. package/src/update/tray-update-plan.mjs +38 -0
  571. package/src/usage/cost.ts +0 -0
  572. package/src/usage/debug.ts +97 -0
  573. package/src/usage/expected-prices.ts +283 -0
  574. package/src/usage/log.ts +695 -0
  575. package/src/usage/summary.ts +585 -0
  576. package/src/usage/totals.ts +14 -0
  577. package/src/vision/anthropic-describe.ts +185 -0
  578. package/src/vision/describe.ts +127 -0
  579. package/src/vision/index.ts +558 -0
  580. package/src/vision/reasoning.ts +55 -0
  581. package/src/web-search/anthropic-executor.ts +189 -0
  582. package/src/web-search/executor.ts +105 -0
  583. package/src/web-search/format-result.ts +89 -0
  584. package/src/web-search/index.ts +196 -0
  585. package/src/web-search/loop.ts +791 -0
  586. package/src/web-search/parse.ts +235 -0
  587. package/src/web-search/progress-stream.ts +342 -0
  588. package/src/web-search/synthetic-tool.ts +47 -0
@@ -0,0 +1,2098 @@
1
+ import {
2
+ ConfigMutationLockError,
3
+ loadConfig,
4
+ mutatePersistedConfig,
5
+ saveConfigPreservingClaudeCode,
6
+ withConfigMutationLockSync,
7
+ } from "../config";
8
+ import { withCodexAccountLogLabel } from "./account-label";
9
+ import {
10
+ getCodexAccountCredential,
11
+ getValidCodexToken,
12
+ isCodexAccountGenerationLive,
13
+ markCodexAccountValidated,
14
+ readCodexAccountRecord,
15
+ saveCodexAccountCredential,
16
+ CodexCredentialGenerationConflictError,
17
+ CodexCredentialRefreshLockTimeoutError,
18
+ CodexCredentialRefreshBusyError,
19
+ CodexCredentialRefreshStaleError,
20
+ TokenRefreshError,
21
+ } from "./account-store";
22
+ import { deleteCodexAccount, reconcileMainCodexAccountRuntimeState } from "./account-lifecycle";
23
+ import {
24
+ appendDefaultCodexAccountNamespace,
25
+ codexAccountPickerEnabled,
26
+ } from "./account-namespaces";
27
+ import {
28
+ catalogRefreshIsPending,
29
+ normalizeCatalogDisposition,
30
+ } from "./catalog-refresh-status";
31
+ import { isCodexAccountPaused, setCodexAccountPaused } from "./account-pause";
32
+ import {
33
+ clearCodexAccountPin,
34
+ getCodexAccountPriority,
35
+ isCodexAccountPriorityKey,
36
+ pinnedCodexAccountId,
37
+ setCodexAccountPin,
38
+ setCodexAccountPriority,
39
+ } from "./account-priority";
40
+ import {
41
+ claimDueCodexQuotaRecoveryProbes,
42
+ clearCodexAccountCooldown,
43
+ clearThreadAccountMapForAccount,
44
+ getEffectiveActiveCodexAccountId,
45
+ isEffectiveCodexAccountPinned,
46
+ reconcileCodexActiveAfterExclusion,
47
+ resetCodexRoutingForManualSelection,
48
+ settleCodexQuotaRecoveryProbe,
49
+ } from "./routing";
50
+ import {
51
+ DEFAULT_ACCOUNT_PRIORITY,
52
+ MAX_ACCOUNT_PRIORITY,
53
+ MIN_ACCOUNT_PRIORITY,
54
+ normalizeAccountPoolStickyLimit,
55
+ normalizeAccountPoolStrategy,
56
+ parseAccountPoolStickyLimit,
57
+ parseAccountPoolStrategy,
58
+ parseAccountPriority,
59
+ } from "./pool-rotation";
60
+ import { checkAccountIdCollision, getMainChatgptAccountId, readCodexTokens, readCodexTokensResult } from "./auth-collision";
61
+ export { checkAccountIdCollision, getMainChatgptAccountId } from "./auth-collision";
62
+ export { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state";
63
+ import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state";
64
+ import {
65
+ clearAccountQuota,
66
+ getAccountQuota,
67
+ isCompleteCodexQuotaRecoverySnapshot,
68
+ isCodexQuotaExhausted,
69
+ listAccountQuotas,
70
+ parseUsageQuota,
71
+ setAccountQuotaFromParsed,
72
+ updateAccountQuota,
73
+ type StoredAccountQuota,
74
+ type WhamUsageResponse,
75
+ } from "./quota";
76
+ export {
77
+ applyAccountQuotaFromUpstreamHeaders,
78
+ clearAccountQuota,
79
+ getAccountQuota,
80
+ parseUsageQuota,
81
+ setAccountQuotaFromParsed,
82
+ updateAccountQuota,
83
+ } from "./quota";
84
+ import { extractAccountId, decodeJwtPayload } from "../oauth/chatgpt";
85
+ import { getMainAccountPlan, MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account";
86
+ import { captureConfigGeneration, registerStateSweepAfterTick } from "../lib/state-store-sweeper";
87
+ import { reconcileLiveStateStores } from "../lib/state-store-registrations";
88
+ import {
89
+ captureMainAccountIdentityGeneration,
90
+ clearMainAccountInfoCache,
91
+ getMainAccountCredentialPresence,
92
+ getMainAccountInfoCache,
93
+ isMainAccountIdentityGenerationLive,
94
+ setMainAccountCredentialPresence,
95
+ setMainAccountInfoCache,
96
+ type MainAccountInfo,
97
+ } from "./main-account-cache";
98
+ export { clearMainAccountInfoCache } from "./main-account-cache";
99
+ import { maskEmail } from "../lib/privacy";
100
+ import { CodexWarmupError, codexWarmupFailureReason, warmCodexAccount } from "./warmup";
101
+ export { maskEmail } from "../lib/privacy";
102
+ import type { CodexAccount, CodexAccountCredentials, OcxConfig } from "../types";
103
+ import type { CatalogDisposition } from "./convergence-types";
104
+ import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers";
105
+ import { providerCodexAccountMode } from "../providers/registry";
106
+ import { readBoundedResponseBody } from "../lib/bounded-body";
107
+ import {
108
+ oauthAccountHealthFields,
109
+ projectCodexAccountHealth,
110
+ type OAuthAccountHealth,
111
+ type OAuthHealthLabel,
112
+ } from "../oauth/health";
113
+ import {
114
+ CODEX_ACCOUNT_ID_RE,
115
+ hasLegacyMainCodexPoolAccount,
116
+ isSelectableCodexPoolAccount,
117
+ isValidCodexAccountId,
118
+ } from "./account-id";
119
+ import { codexAccountIdNamespaceCollisionError } from "./account-namespace-match";
120
+ import { ResourceAdmissionError, type AdmissionLease } from "../lib/admission";
121
+ import { tryAcquireNativeMainProfileClaim } from "./native-main-admission";
122
+ import { withNativeMainSharedClaim } from "./native-main-claim";
123
+ import { resolveNativeProfileContext } from "./native-profile-store";
124
+ import { NativeProfileError } from "./native-profile-types";
125
+
126
+ function isNativeMainClaimUnavailable(error: unknown): error is NativeProfileError {
127
+ return error instanceof NativeProfileError
128
+ && (error.code === "NATIVE_MAIN_CLAIM_BUSY" || error.code === "NATIVE_MAIN_CLAIM_UNAVAILABLE");
129
+ }
130
+
131
+ function withNativeMainCredentialClaim<T>(operation: () => Promise<T>): Promise<T> {
132
+ return withNativeMainSharedClaim(resolveNativeProfileContext(), operation);
133
+ }
134
+
135
+ function jsonResponse(data: unknown, status = 200): Response {
136
+ return new Response(JSON.stringify(data), {
137
+ status,
138
+ headers: { "Content-Type": "application/json" },
139
+ });
140
+ }
141
+
142
+ function nativeMainProfileBusyResponse(): Response {
143
+ const response = jsonResponse({ error: "server_busy", code: "server_busy" }, 503);
144
+ response.headers.set("Retry-After", "1");
145
+ return response;
146
+ }
147
+
148
+ const MANUAL_IMPORT_ENV = "OPENCODEX_ENABLE_UNVERIFIED_CODEX_IMPORT";
149
+ const CODEX_CREDENTIAL_PERSISTENCE_ERROR = "Account was saved, but credential setup did not complete. Reauthenticate or remove the account.";
150
+ const CODEX_CREDENTIAL_PERSISTENCE_CODE = "codex_credential_persistence_failed";
151
+
152
+ const MAX_CODEX_LOGIN_STATE_ROWS = 32;
153
+ const CODEX_LOGIN_TERMINAL_TTL_MS = 300_000;
154
+ interface CodexLoginStateRow {
155
+ status: string;
156
+ startedAt: number;
157
+ accountId?: string;
158
+ email?: string;
159
+ error?: string;
160
+ code?: string;
161
+ needsReauth?: boolean;
162
+ catalogRefreshPending?: boolean;
163
+ doneAt?: number;
164
+ }
165
+ const codexAuthLoginState = new Map<string, CodexLoginStateRow>();
166
+ export class CodexLoginStateBusyError extends ResourceAdmissionError {
167
+ constructor() { super("codex_login_state_rows", MAX_CODEX_LOGIN_STATE_ROWS); this.name = "CodexLoginStateBusyError"; }
168
+ }
169
+
170
+ function setCodexLoginState(flowId: string, patch: Partial<CodexLoginStateRow>): void {
171
+ const row = codexAuthLoginState.get(flowId);
172
+ if (row) Object.assign(row, patch);
173
+ }
174
+
175
+ function pruneCodexLoginState(now = Date.now()): void {
176
+ for (const [id, row] of codexAuthLoginState) {
177
+ if (row.doneAt !== undefined && now - row.doneAt >= CODEX_LOGIN_TERMINAL_TTL_MS) codexAuthLoginState.delete(id);
178
+ }
179
+ while (codexAuthLoginState.size >= MAX_CODEX_LOGIN_STATE_ROWS) {
180
+ const terminal = [...codexAuthLoginState].filter(([, row]) => row.doneAt !== undefined)
181
+ .sort((a, b) => (a[1].doneAt ?? 0) - (b[1].doneAt ?? 0))[0];
182
+ if (!terminal) break;
183
+ codexAuthLoginState.delete(terminal[0]);
184
+ }
185
+ }
186
+
187
+ function configuredPoolAccount(config: OcxConfig, accountId: string): CodexAccount | null {
188
+ if (!isValidCodexAccountId(accountId)) return null;
189
+ return (config.codexAccounts ?? [])
190
+ .find(account => account.id === accountId && isSelectableCodexPoolAccount(account)) ?? null;
191
+ }
192
+
193
+ function codexAccountPersistenceConflict(
194
+ config: OcxConfig,
195
+ accountId: string,
196
+ mode: "create" | "reauth",
197
+ ): string | undefined {
198
+ if (mode === "reauth") {
199
+ return configuredPoolAccount(config, accountId)
200
+ ? undefined
201
+ : "Pool account was removed while login was in progress. Add it again as a new account.";
202
+ }
203
+ const namespaceCollision = codexAccountIdNamespaceCollisionError(config.codexAccountNamespaces, accountId);
204
+ if (namespaceCollision) return namespaceCollision;
205
+ return (config.codexAccounts ?? []).some(account => account.id === accountId)
206
+ || Boolean(getCodexAccountCredential(accountId))
207
+ ? `Account id already exists: ${accountId}`
208
+ : undefined;
209
+ }
210
+
211
+ function isThirtyDayOnlyPlan(plan: string | null | undefined): boolean {
212
+ const normalized = plan?.trim().toLowerCase();
213
+ return normalized === "go" || normalized === "free";
214
+ }
215
+
216
+ function quotaForPlan<T extends Omit<StoredAccountQuota, "updatedAt"> | StoredAccountQuota | null>(
217
+ quota: T,
218
+ plan: string | null | undefined,
219
+ ): T {
220
+ if (!quota || !isThirtyDayOnlyPlan(plan)) return quota;
221
+ return {
222
+ ...(quota.monthlyPercent !== undefined ? { monthlyPercent: quota.monthlyPercent } : {}),
223
+ ...(quota.monthlyResetAt !== undefined ? { monthlyResetAt: quota.monthlyResetAt } : {}),
224
+ ...(quota.resetCredits !== undefined ? { resetCredits: quota.resetCredits } : {}),
225
+ ...("updatedAt" in quota ? { updatedAt: quota.updatedAt } : {}),
226
+ } as T;
227
+ }
228
+
229
+ function poolAccountDto(
230
+ account: CodexAccount,
231
+ quotaResult: PoolQuotaResult,
232
+ hasCredential: boolean,
233
+ paused: boolean,
234
+ priority: number,
235
+ ): CodexAuthAccountDto {
236
+ const quota = quotaForPlan(quotaResult.quota, account.plan);
237
+ const needsReauth = !hasCredential || quotaResult.needsReauth || isAccountNeedsReauth(account.id);
238
+ const health = projectCodexAccountHealth({ accountId: account.id, needsReauth });
239
+ return {
240
+ id: account.id,
241
+ email: maskEmail(account.email) ?? account.email,
242
+ ...(account.alias !== undefined ? { alias: account.alias } : {}),
243
+ ...(account.plan !== undefined ? { plan: account.plan } : {}),
244
+ ...(account.logLabel !== undefined ? { logLabel: account.logLabel } : {}),
245
+ isMain: false,
246
+ paused,
247
+ priority,
248
+ quota: quota ? { ...quota } : null,
249
+ needsReauth,
250
+ hasCredential,
251
+ ...(quotaResult.quotaProbeSkipped ? { quotaProbeSkipped: true as const } : {}),
252
+ ...oauthAccountHealthFields("codex", account.id, health),
253
+ };
254
+ }
255
+
256
+ interface ResetCreditAuth {
257
+ isMain: boolean;
258
+ accessToken: string;
259
+ chatgptAccountId: string;
260
+ nativeMainLease?: AdmissionLease;
261
+ nativeMainSharedClaimHeld?: true;
262
+ }
263
+
264
+ async function withResetCreditAuth<T>(
265
+ runtimeConfig: OcxConfig,
266
+ accountId: string,
267
+ operation: (auth: ResetCreditAuth) => Promise<T>,
268
+ ): Promise<{ ok: true; value: T } | { ok: false; response: Response }> {
269
+ if (accountId === MAIN_CODEX_ACCOUNT_ID) {
270
+ if (hasLegacyMainCodexPoolAccount(runtimeConfig.codexAccounts)) {
271
+ return { ok: false, response: jsonResponse({ error: "Remove the legacy __main__ pool row before using the Desktop account" }, 409) };
272
+ }
273
+ const nativeMainLease = tryAcquireNativeMainProfileClaim();
274
+ if (!nativeMainLease) return { ok: false, response: nativeMainProfileBusyResponse() };
275
+ try {
276
+ try {
277
+ return await withNativeMainCredentialClaim(async () => {
278
+ const tokens = readCodexTokens();
279
+ if (!tokens) {
280
+ return { ok: false, response: jsonResponse({ error: "Main Codex account not logged in" }, 401) };
281
+ }
282
+ return {
283
+ ok: true,
284
+ value: await operation({
285
+ isMain: true,
286
+ accessToken: tokens.access_token,
287
+ chatgptAccountId: tokens.account_id,
288
+ nativeMainLease,
289
+ nativeMainSharedClaimHeld: true,
290
+ }),
291
+ };
292
+ });
293
+ } catch (error) {
294
+ if (isNativeMainClaimUnavailable(error)) {
295
+ return { ok: false, response: nativeMainProfileBusyResponse() };
296
+ }
297
+ throw error;
298
+ }
299
+ } finally {
300
+ nativeMainLease.release();
301
+ }
302
+ }
303
+ if (!isValidCodexAccountId(accountId)) {
304
+ return { ok: false, response: jsonResponse({ error: "Invalid account id format" }, 400) };
305
+ }
306
+ if (!configuredPoolAccount(runtimeConfig, accountId)) {
307
+ return { ok: false, response: jsonResponse({ error: "Unknown Codex account" }, 404) };
308
+ }
309
+ const cred = await getValidCodexToken(accountId);
310
+ return {
311
+ ok: true,
312
+ value: await operation({
313
+ isMain: false,
314
+ accessToken: cred.accessToken,
315
+ chatgptAccountId: cred.chatgptAccountId,
316
+ }),
317
+ };
318
+ }
319
+
320
+ function safeResetCreditsDto(input: unknown): { credits: { granted_at: string; expires_at: string }[]; available_count?: number } {
321
+ const obj = typeof input === "object" && input !== null ? input as Record<string, unknown> : {};
322
+ const rawCredits = Array.isArray(obj.credits) ? obj.credits : [];
323
+ const credits = rawCredits.flatMap((raw): { granted_at: string; expires_at: string }[] => {
324
+ if (typeof raw !== "object" || raw === null) return [];
325
+ const credit = raw as Record<string, unknown>;
326
+ return typeof credit.granted_at === "string" && typeof credit.expires_at === "string"
327
+ ? [{ granted_at: credit.granted_at, expires_at: credit.expires_at }]
328
+ : [];
329
+ });
330
+ const rawAvailable = (obj.rate_limit_reset_credits as { available_count?: unknown } | null | undefined)?.available_count
331
+ ?? obj.available_count;
332
+ return {
333
+ credits,
334
+ ...(typeof rawAvailable === "number" && Number.isFinite(rawAvailable) ? { available_count: rawAvailable } : {}),
335
+ };
336
+ }
337
+
338
+ function safeResetCreditConsumeDto(input: unknown): { code: string } {
339
+ const obj = typeof input === "object" && input !== null ? input as Record<string, unknown> : {};
340
+ return { code: typeof obj.code === "string" ? obj.code : "unknown" };
341
+ }
342
+
343
+ export function isUnverifiedCodexImportEnabled(): boolean {
344
+ return process.env[MANUAL_IMPORT_ENV] === "1";
345
+ }
346
+
347
+ function manualImportDisabledResponse(): Response {
348
+ return jsonResponse({
349
+ error: "Manual Codex account import is disabled. Use OAuth login to add a pool account.",
350
+ code: "manual_import_disabled",
351
+ }, 403);
352
+ }
353
+
354
+ async function verifyCodexAccountWarmup(
355
+ accountId: string,
356
+ accessToken: string,
357
+ chatgptAccountId: string,
358
+ ): Promise<{ ok: true; validatedAt: number } | { ok: false; response: Response }> {
359
+ try {
360
+ await warmCodexAccount({ accessToken, chatgptAccountId });
361
+ return { ok: true, validatedAt: Date.now() };
362
+ } catch (err) {
363
+ const reason = codexWarmupFailureReason(err);
364
+ const upstream = err instanceof CodexWarmupError ? err.upstreamDetail : undefined;
365
+ return {
366
+ ok: false,
367
+ response: jsonResponse({
368
+ error: upstream
369
+ ? `Codex account warmup failed: ${upstream}`
370
+ : "Codex account warmup failed. Reauthenticate the account and try again.",
371
+ code: "codex_warmup_failed",
372
+ reason,
373
+ accountId,
374
+ }, 401),
375
+ };
376
+ }
377
+ }
378
+
379
+ function expireCodexAuthFlow(flowId: string | null, error = "Login cancelled"): void {
380
+ const ids = flowId
381
+ ? [flowId]
382
+ : [...codexAuthLoginState].filter(([, state]) => state.status === "pending").map(([id]) => id);
383
+ for (const id of ids) {
384
+ let owner = codexAuthLoginState.get(id);
385
+ if (!owner) {
386
+ pruneCodexLoginState();
387
+ if (codexAuthLoginState.size >= MAX_CODEX_LOGIN_STATE_ROWS) continue;
388
+ owner = { status: "error", startedAt: Date.now() };
389
+ codexAuthLoginState.set(id, owner);
390
+ }
391
+ Object.assign(owner, { status: "error", error, doneAt: Date.now() });
392
+ setTimeout(() => { if (codexAuthLoginState.get(id) === owner) codexAuthLoginState.delete(id); }, 30_000);
393
+ }
394
+ }
395
+
396
+ const MAIN_CACHE_TTL = 5 * 60_000;
397
+ const POOL_CACHE_TTL = 5 * 60_000;
398
+ const POOL_QUOTA_REFRESH_CONCURRENCY = 4;
399
+
400
+ function nonEmptyPlan(value: unknown): string | null {
401
+ return typeof value === "string" && value.trim() !== "" ? value : null;
402
+ }
403
+
404
+ function isRuntimeConfig(config: OcxConfig): boolean {
405
+ return !!config && typeof config === "object" && !!config.providers;
406
+ }
407
+
408
+ function getRuntimeConfig(config: OcxConfig): OcxConfig {
409
+ return isRuntimeConfig(config) ? config : loadConfig();
410
+ }
411
+
412
+ function saveRuntimeConfig(sourceConfig: OcxConfig, nextConfig: OcxConfig): void {
413
+ saveConfigPreservingClaudeCode(nextConfig);
414
+ if (sourceConfig === nextConfig || !isRuntimeConfig(sourceConfig)) return;
415
+ for (const key of Object.keys(sourceConfig) as Array<keyof OcxConfig>) {
416
+ delete sourceConfig[key];
417
+ }
418
+ Object.assign(sourceConfig, nextConfig);
419
+ }
420
+
421
+ interface StagedNewCodexAccountState {
422
+ credential: CodexAccountCredentials;
423
+ validatedAt: number;
424
+ }
425
+
426
+ type PersistNewCodexAccountOutcome =
427
+ | { status: "committed"; pickerVisibilityChanged: boolean }
428
+ | { status: "publication-failed"; pickerVisibilityChanged: boolean };
429
+
430
+ function codexCredentialPersistenceFailure(accountId: string, catalogRefreshPending: boolean) {
431
+ return {
432
+ error: CODEX_CREDENTIAL_PERSISTENCE_ERROR,
433
+ code: CODEX_CREDENTIAL_PERSISTENCE_CODE,
434
+ accountId,
435
+ needsReauth: true as const,
436
+ ...(catalogRefreshPending ? { catalogRefreshPending: true as const } : {}),
437
+ };
438
+ }
439
+
440
+ /** Persist config before publishing secret or runtime state under the shared mutation coordinator. */
441
+ function persistNewCodexAccount(
442
+ sourceConfig: OcxConfig,
443
+ runtimeConfig: OcxConfig,
444
+ addedAccount: CodexAccount,
445
+ staged: StagedNewCodexAccountState,
446
+ ): PersistNewCodexAccountOutcome {
447
+ return withConfigMutationLockSync(() => {
448
+ const previousConfig = { ...runtimeConfig };
449
+ let pickerVisibilityChanged: boolean;
450
+ try {
451
+ const accounts = [...(runtimeConfig.codexAccounts ?? [])];
452
+ const retainedPickerBindingRestored = codexAccountPickerEnabled(runtimeConfig)
453
+ && Object.values(runtimeConfig.codexAccountNamespaces ?? {}).includes(addedAccount.id);
454
+ accounts.push(addedAccount);
455
+ runtimeConfig.codexAccounts = accounts;
456
+
457
+ // Presence of the explicit flag distinguishes a dashboard-managed map from
458
+ // a hand-authored legacy map. Preserve manual maps exactly.
459
+ const tracksPickerNamespaces = runtimeConfig.codexAccountPickerEnabled !== undefined;
460
+ if (tracksPickerNamespaces && runtimeConfig.codexAccountNamespaces) {
461
+ runtimeConfig.codexAccountNamespaces = { ...runtimeConfig.codexAccountNamespaces };
462
+ }
463
+ const namespaceAdded = tracksPickerNamespaces
464
+ && appendDefaultCodexAccountNamespace(runtimeConfig, addedAccount);
465
+ pickerVisibilityChanged = namespaceAdded || retainedPickerBindingRestored;
466
+ saveRuntimeConfig(sourceConfig, runtimeConfig);
467
+ } catch (error) {
468
+ for (const key of Object.keys(runtimeConfig) as Array<keyof OcxConfig>) {
469
+ delete runtimeConfig[key];
470
+ }
471
+ Object.assign(runtimeConfig, previousConfig);
472
+ throw error;
473
+ }
474
+
475
+ try {
476
+ saveCodexAccountCredential(addedAccount.id, staged.credential);
477
+ markCodexAccountValidated(addedAccount.id, staged.validatedAt);
478
+ clearAccountNeedsReauth(addedAccount.id);
479
+ } catch {
480
+ // Config is already durable. Return the failure outcome through the coordinator so its
481
+ // generation commit is not rolled back while config.json remains changed.
482
+ return { status: "publication-failed" as const, pickerVisibilityChanged };
483
+ }
484
+ return { status: "committed" as const, pickerVisibilityChanged };
485
+ });
486
+ }
487
+
488
+ /** Bounded catalog-convergence callback supplied by the management dispatcher. */
489
+ export type CodexAuthCatalogConvergence = () => Promise<CatalogDisposition>;
490
+
491
+ interface AccountNamespaceCatalogRefresh {
492
+ catalogRefreshPending: boolean;
493
+ }
494
+
495
+ /** Collapse post-persistence convergence into the one public recovery bit. */
496
+ async function convergeAccountNamespaceCatalog(
497
+ config: OcxConfig,
498
+ changed: boolean,
499
+ convergeCodexCatalog?: CodexAuthCatalogConvergence,
500
+ ): Promise<AccountNamespaceCatalogRefresh> {
501
+ if (!changed || !codexAccountPickerEnabled(config)) {
502
+ return { catalogRefreshPending: false };
503
+ }
504
+ if (!convergeCodexCatalog) return { catalogRefreshPending: true };
505
+
506
+ try {
507
+ const catalogRefresh = normalizeCatalogDisposition(await convergeCodexCatalog());
508
+ if (!catalogRefresh) return { catalogRefreshPending: true };
509
+ return { catalogRefreshPending: catalogRefreshIsPending(catalogRefresh) };
510
+ } catch {
511
+ return { catalogRefreshPending: true };
512
+ }
513
+ }
514
+
515
+ async function mapWithConcurrency<T, R>(
516
+ items: T[],
517
+ concurrency: number,
518
+ mapper: (item: T) => Promise<R>,
519
+ ): Promise<R[]> {
520
+ const results = new Array<R>(items.length);
521
+ let next = 0;
522
+ const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
523
+ while (next < items.length) {
524
+ const index = next++;
525
+ results[index] = await mapper(items[index]!);
526
+ }
527
+ });
528
+ await Promise.all(workers);
529
+ return results;
530
+ }
531
+
532
+ const MAIN_TERMINAL_AUTH_CODES = new Set([
533
+ "invalid_workspace_selected",
534
+ "invalid_refresh_token",
535
+ ]);
536
+
537
+ async function isTerminalMainAuthResponse(resp: Response): Promise<boolean> {
538
+ if (resp.status === 401) return true;
539
+ if (resp.status !== 403) return false;
540
+ try {
541
+ const body = await readBoundedResponseBody(resp, { totalTimeoutMs: 1_000, inactivityTimeoutMs: 1_000 });
542
+ if (!body.displaySafe) return false;
543
+ const parsed = JSON.parse(body.text) as {
544
+ detail?: { code?: unknown } | string;
545
+ error?: { code?: unknown } | string;
546
+ code?: unknown;
547
+ };
548
+ const code = typeof parsed.detail === "object" && parsed.detail !== null
549
+ ? parsed.detail.code
550
+ : typeof parsed.error === "object" && parsed.error !== null
551
+ ? parsed.error.code
552
+ : parsed.code;
553
+ return typeof code === "string" && MAIN_TERMINAL_AUTH_CODES.has(code);
554
+ } catch {
555
+ return false;
556
+ }
557
+ }
558
+
559
+ interface MainAccountInfoFetchResult {
560
+ info: MainAccountInfo;
561
+ /** Whether this attempt safely inspected the physical native-main credential. */
562
+ credentialChecked: boolean;
563
+ /** Meaningful only when credentialChecked is true. */
564
+ hasCredential: boolean;
565
+ /** Main identity generation captured while the native-main claim was held. */
566
+ identityGeneration?: number;
567
+ /** Present only when this call freshly parsed a WHAM usage response. */
568
+ freshQuota?: Omit<StoredAccountQuota, "updatedAt">;
569
+ /** Present only when this call's WHAM response included `rate_limit_reset_credits.available_count`. */
570
+ freshResetCredits?: number;
571
+ }
572
+
573
+ export interface MainAccountInfoSnapshot {
574
+ info: MainAccountInfo;
575
+ mainIdentityGeneration: number;
576
+ }
577
+
578
+ export async function fetchMainAccountInfoSnapshot(forceRefresh = false): Promise<MainAccountInfoSnapshot> {
579
+ const result = await fetchMainAccountInfoAttempt(forceRefresh, 1);
580
+ return {
581
+ info: result.info,
582
+ mainIdentityGeneration: result.identityGeneration ?? captureMainAccountIdentityGeneration(),
583
+ };
584
+ }
585
+
586
+ export async function fetchMainAccountInfo(forceRefresh = false): Promise<MainAccountInfo> {
587
+ return (await fetchMainAccountInfoSnapshot(forceRefresh)).info;
588
+ }
589
+
590
+ const EMPTY_MAIN_ACCOUNT_INFO: MainAccountInfo = { email: null, plan: null, quota: null };
591
+
592
+ async function retryMainAccountInfoIfIdentityChanged(
593
+ requestAccountId: string | null,
594
+ retriesRemaining: number,
595
+ nativeMainLease: AdmissionLease,
596
+ ): Promise<MainAccountInfoFetchResult | null> {
597
+ const currentAccountId = getMainChatgptAccountId();
598
+ if (currentAccountId === null || currentAccountId === requestAccountId) return null;
599
+ reconcileMainCodexAccountRuntimeState();
600
+ return retriesRemaining > 0
601
+ ? fetchMainAccountInfoWhileOwned(true, retriesRemaining - 1, nativeMainLease)
602
+ : { info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true };
603
+ }
604
+
605
+ async function fetchMainAccountInfoAttempt(
606
+ forceRefresh: boolean,
607
+ retriesRemaining: number,
608
+ existingNativeMainLease?: AdmissionLease,
609
+ nativeMainSharedClaimHeld = false,
610
+ ): Promise<MainAccountInfoFetchResult> {
611
+ const nativeMainLease = existingNativeMainLease ?? tryAcquireNativeMainProfileClaim();
612
+ if (!nativeMainLease) {
613
+ return {
614
+ info: EMPTY_MAIN_ACCOUNT_INFO,
615
+ credentialChecked: false,
616
+ hasCredential: false,
617
+ identityGeneration: captureMainAccountIdentityGeneration(),
618
+ };
619
+ }
620
+ try {
621
+ const operation = async () => ({
622
+ ...await fetchMainAccountInfoWhileOwned(forceRefresh, retriesRemaining, nativeMainLease),
623
+ identityGeneration: captureMainAccountIdentityGeneration(),
624
+ });
625
+ if (nativeMainSharedClaimHeld) return await operation();
626
+ try {
627
+ return await withNativeMainCredentialClaim(operation);
628
+ } catch (error) {
629
+ if (isNativeMainClaimUnavailable(error)) {
630
+ return {
631
+ info: EMPTY_MAIN_ACCOUNT_INFO,
632
+ credentialChecked: false,
633
+ hasCredential: false,
634
+ identityGeneration: captureMainAccountIdentityGeneration(),
635
+ };
636
+ }
637
+ throw error;
638
+ }
639
+ } finally {
640
+ if (!existingNativeMainLease) nativeMainLease.release();
641
+ }
642
+ }
643
+
644
+ async function fetchMainAccountInfoWhileOwned(
645
+ forceRefresh: boolean,
646
+ retriesRemaining: number,
647
+ nativeMainLease: AdmissionLease,
648
+ ): Promise<MainAccountInfoFetchResult> {
649
+ const writerGeneration = captureConfigGeneration();
650
+ reconcileMainCodexAccountRuntimeState();
651
+ const tokenRead = readCodexTokensResult();
652
+ setMainAccountCredentialPresence(tokenRead.status === "ok");
653
+ if (tokenRead.status !== "ok") {
654
+ // A local read failure is NOT proof of sign-out: a missing file can be a non-atomic rewrite
655
+ // gap, and malformed JSON can be a half-written file. Clearing the cache and marking the
656
+ // account for reauth here destroyed healthy email/plan/quota state and pinned a working
657
+ // account as unusable. Preserve what we already know and let the caller retry; request
658
+ // routing stays fail-closed because getMainAccountToken() re-reads the file itself, and the
659
+ // account DTO still reports hasCredential=false while the file is unreadable.
660
+ const preserved = getMainAccountInfoCache();
661
+ return { info: preserved ?? EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: false };
662
+ }
663
+ const tokens = tokenRead.tokens;
664
+ const requestAccountId = extractAccountId(tokens.id_token, tokens.access_token) ?? (tokens.account_id || null);
665
+ const cached = getMainAccountInfoCache();
666
+ if (!forceRefresh && cached && Date.now() - cached.ts < MAIN_CACHE_TTL) {
667
+ return { info: cached, credentialChecked: true, hasCredential: true };
668
+ }
669
+ try {
670
+ const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", {
671
+ headers: { Authorization: `Bearer ${tokens.access_token}`, "ChatGPT-Account-Id": tokens.account_id },
672
+ signal: AbortSignal.timeout(8000),
673
+ });
674
+ if (!resp.ok) {
675
+ const terminalAuthFailure = await isTerminalMainAuthResponse(resp);
676
+ const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease);
677
+ if (retried) return retried;
678
+ if (terminalAuthFailure) {
679
+ clearMainAccountInfoCache();
680
+ markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID, writerGeneration);
681
+ }
682
+ return { info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true };
683
+ }
684
+ const data = (await resp.json()) as WhamUsageResponse;
685
+ const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease);
686
+ if (retried) return retried;
687
+ const plan = nonEmptyPlan(data.plan_type) ?? nonEmptyPlan(cached?.plan) ?? nonEmptyPlan(getMainAccountPlan());
688
+ const quota = parseUsageQuota({ ...data, ...(plan ? { plan_type: plan } : {}) });
689
+ const freshResetCredits = quota?.resetCredits;
690
+ const result = {
691
+ email: data.email ?? null,
692
+ plan,
693
+ quota,
694
+ ts: Date.now(),
695
+ };
696
+ setMainAccountInfoCache(result);
697
+ clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
698
+ // Mirror main quota + plan into the shared stores so the rotation engine can
699
+ // score and auto-switch the main account exactly like a pool account (Option A).
700
+ setMainAccountPlan(result.plan);
701
+ if (result.quota) {
702
+ setAccountQuotaFromParsed(MAIN_CODEX_ACCOUNT_ID, result.quota, writerGeneration);
703
+ }
704
+ return {
705
+ info: result,
706
+ credentialChecked: true,
707
+ hasCredential: true,
708
+ ...(quota ? { freshQuota: quota } : {}),
709
+ ...(freshResetCredits !== undefined ? { freshResetCredits } : {}),
710
+ };
711
+ } catch {
712
+ const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease);
713
+ return retried ?? { info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true };
714
+ }
715
+ }
716
+
717
+ interface PoolQuotaResult {
718
+ quota: StoredAccountQuota | null;
719
+ needsReauth: boolean;
720
+ /** Credential generation whose cache or network result this DTO state belongs to. */
721
+ credentialGeneration?: number;
722
+ /** Present only when this call freshly parsed a WHAM usage response. */
723
+ freshQuota?: Omit<StoredAccountQuota, "updatedAt">;
724
+ /** Present only when this call's WHAM response included a non-empty `plan_type`. */
725
+ freshPlan?: string;
726
+ /** Credential generation used by this fresh quota request. */
727
+ freshCredentialGeneration?: number;
728
+ /** Present only when this call's WHAM response included `rate_limit_reset_credits.available_count`. */
729
+ freshResetCredits?: number;
730
+ quotaProbeSkipped?: true;
731
+ }
732
+
733
+ interface PoolQuotaRefreshFlight {
734
+ state: {
735
+ startCredentialGeneration?: number;
736
+ resolvedCredentialGeneration?: number;
737
+ };
738
+ promise: Promise<PoolQuotaResult>;
739
+ }
740
+
741
+ const poolQuotaRefreshInFlight = new Map<string, Set<PoolQuotaRefreshFlight>>();
742
+ const MAX_POOL_QUOTA_FLIGHTS = 16;
743
+
744
+ export class PoolQuotaProbeBusyError extends ResourceAdmissionError {
745
+ constructor() {
746
+ super("pool_quota_flights", MAX_POOL_QUOTA_FLIGHTS);
747
+ this.name = "PoolQuotaProbeBusyError";
748
+ }
749
+ }
750
+
751
+ function poolQuotaFlightCount(): number {
752
+ let count = 0;
753
+ for (const flights of poolQuotaRefreshInFlight.values()) count += flights.size;
754
+ return count;
755
+ }
756
+
757
+ /** Focused admission tests only; returns cleanup for the synthetic owners it inserts. */
758
+ export function seedCodexAuthAdmissionForTests(options: { loginFlows?: number; quotaFlights?: number }): () => void {
759
+ const prefix = `admission-test-${crypto.randomUUID()}`;
760
+ for (let index = 0; index < (options.loginFlows ?? 0); index++) {
761
+ codexAuthLoginState.set(`${prefix}-login-${index}`, { status: "starting", startedAt: Date.now() });
762
+ }
763
+ for (let index = 0; index < (options.quotaFlights ?? 0); index++) {
764
+ poolQuotaRefreshInFlight.set(`${prefix}-quota-${index}`, new Set([{
765
+ state: {},
766
+ promise: new Promise<PoolQuotaResult>(() => {}),
767
+ }]));
768
+ }
769
+ return () => {
770
+ for (const key of [...codexAuthLoginState.keys()]) if (key.startsWith(prefix)) codexAuthLoginState.delete(key);
771
+ for (const key of [...poolQuotaRefreshInFlight.keys()]) if (key.startsWith(prefix)) poolQuotaRefreshInFlight.delete(key);
772
+ };
773
+ }
774
+
775
+ export interface CodexAuthAccountDto {
776
+ id: string;
777
+ alias?: string;
778
+ email: string;
779
+ plan?: string | null;
780
+ logLabel?: string;
781
+ isMain: boolean;
782
+ paused: boolean;
783
+ /** Selection order; higher is used earlier. Always present, 0 when unset. */
784
+ priority: number;
785
+ quota: (StoredAccountQuota | (Omit<StoredAccountQuota, "updatedAt"> & { updatedAt: number })) | null;
786
+ needsReauth?: boolean;
787
+ hasCredential: boolean;
788
+ health: OAuthAccountHealth;
789
+ healthLabel: OAuthHealthLabel;
790
+ healthSummary: string;
791
+ healthAction?: string;
792
+ quotaProbeSkipped?: true;
793
+ }
794
+
795
+ interface FreshPoolPlanUpdate {
796
+ accountId: string;
797
+ plan: string;
798
+ credentialGeneration: number;
799
+ }
800
+
801
+ /**
802
+ * Persist only validated plan leaves against the latest disk snapshot. A quota GET must not save
803
+ * the long-lived runtime object wholesale: unrelated manual/provider writes may have landed while
804
+ * WHAM requests were in flight. Missing or malformed files fail closed: a read path must not
805
+ * recreate a deleted config from the server's older in-memory snapshot.
806
+ */
807
+ function reconcileFreshPoolAccountPlans(runtimeConfig: OcxConfig, updates: FreshPoolPlanUpdate[]): void {
808
+ if (updates.length === 0) return;
809
+ let outcome: ReturnType<typeof mutatePersistedConfig<FreshPoolPlanUpdate[]>>;
810
+ try {
811
+ outcome = mutatePersistedConfig(persistedConfig => {
812
+ const accepted: FreshPoolPlanUpdate[] = [];
813
+ let changed = false;
814
+ for (const update of updates) {
815
+ if (!isCodexAccountGenerationLive(update.accountId, update.credentialGeneration)) continue;
816
+ const liveAccount = configuredPoolAccount(runtimeConfig, update.accountId);
817
+ const persistedAccount = configuredPoolAccount(persistedConfig, update.accountId);
818
+ if (!liveAccount || !persistedAccount) continue;
819
+ accepted.push(update);
820
+ if (persistedAccount.plan !== update.plan) {
821
+ persistedAccount.plan = update.plan;
822
+ changed = true;
823
+ }
824
+ }
825
+ return { changed, value: accepted };
826
+ });
827
+ } catch (error) {
828
+ // Plan persistence is derived metadata on a read route. Contention must fail closed without
829
+ // turning account listing into a 500; a later refresh can retry against the latest files.
830
+ if (error instanceof ConfigMutationLockError) return;
831
+ throw error;
832
+ }
833
+ if (outcome.status === "unavailable") return;
834
+ for (const update of outcome.value) {
835
+ // A replacement immediately after the durable commit is allowed to supersede the result, but
836
+ // the long-lived object must never be updated from that stale generation.
837
+ if (!isCodexAccountGenerationLive(update.accountId, update.credentialGeneration)) continue;
838
+ const liveAccount = configuredPoolAccount(runtimeConfig, update.accountId);
839
+ if (liveAccount) {
840
+ liveAccount.plan = update.plan;
841
+ }
842
+ }
843
+ }
844
+
845
+ async function fetchFreshPoolAccountQuota(
846
+ accountId: string,
847
+ existing: StoredAccountQuota | null,
848
+ configuredPlan?: string,
849
+ onCredentialGeneration?: (generation: number) => void,
850
+ ): Promise<PoolQuotaResult> {
851
+ const writerGeneration = captureConfigGeneration();
852
+ let requestCredentialGeneration = readCodexAccountRecord(accountId)?.generation;
853
+ try {
854
+ const { accessToken, chatgptAccountId, generation } = await getValidCodexToken(accountId);
855
+ requestCredentialGeneration = generation;
856
+ onCredentialGeneration?.(generation);
857
+ const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", {
858
+ headers: { Authorization: `Bearer ${accessToken}`, "ChatGPT-Account-Id": chatgptAccountId },
859
+ signal: AbortSignal.timeout(8000),
860
+ });
861
+ if (!resp.ok) {
862
+ return {
863
+ quota: existing ?? null,
864
+ needsReauth: resp.status === 401,
865
+ credentialGeneration: generation,
866
+ };
867
+ }
868
+ const data = (await resp.json()) as WhamUsageResponse;
869
+ const freshPlan = nonEmptyPlan(data.plan_type) ?? undefined;
870
+ const quota = parseUsageQuota({ ...data, plan_type: freshPlan ?? configuredPlan });
871
+ const freshResetCredits = quota?.resetCredits;
872
+ if (!quota) {
873
+ return {
874
+ quota: isCodexAccountGenerationLive(accountId, generation) ? existing ?? null : getAccountQuota(accountId),
875
+ needsReauth: false,
876
+ credentialGeneration: generation,
877
+ ...(freshPlan !== undefined
878
+ ? { freshPlan, freshCredentialGeneration: generation }
879
+ : {}),
880
+ };
881
+ }
882
+ if (!isCodexAccountGenerationLive(accountId, generation)) {
883
+ return { quota: null, needsReauth: false, credentialGeneration: generation };
884
+ }
885
+ setAccountQuotaFromParsed(accountId, quota, writerGeneration);
886
+ return {
887
+ quota: getAccountQuota(accountId),
888
+ needsReauth: false,
889
+ credentialGeneration: generation,
890
+ freshQuota: quota,
891
+ freshCredentialGeneration: generation,
892
+ ...(freshPlan !== undefined ? { freshPlan } : {}),
893
+ ...(freshResetCredits !== undefined ? { freshResetCredits } : {}),
894
+ };
895
+ } catch (e) {
896
+ if (e instanceof CodexCredentialGenerationConflictError || e instanceof CodexCredentialRefreshLockTimeoutError
897
+ || e instanceof CodexCredentialRefreshBusyError || e instanceof CodexCredentialRefreshStaleError) {
898
+ return {
899
+ quota: existing ?? null,
900
+ needsReauth: false,
901
+ credentialGeneration: requestCredentialGeneration,
902
+ ...(e instanceof CodexCredentialRefreshBusyError || e instanceof CodexCredentialRefreshStaleError
903
+ ? { quotaProbeSkipped: true as const }
904
+ : {}),
905
+ };
906
+ }
907
+ if (e instanceof TokenRefreshError) {
908
+ return { quota: existing ?? null, needsReauth: true, credentialGeneration: requestCredentialGeneration };
909
+ }
910
+ return { quota: existing ?? null, needsReauth: false, credentialGeneration: requestCredentialGeneration };
911
+ }
912
+ }
913
+
914
+ async function fetchPoolAccountQuota(accountId: string, forceRefresh = false, configuredPlan?: string): Promise<PoolQuotaResult> {
915
+ const existing = getAccountQuota(accountId);
916
+ if (!forceRefresh && existing && Date.now() - existing.updatedAt < POOL_CACHE_TTL) {
917
+ return {
918
+ quota: existing,
919
+ needsReauth: false,
920
+ credentialGeneration: readCodexAccountRecord(accountId)?.generation,
921
+ };
922
+ }
923
+ // A token refresh may increment the generation (and rotate the refresh token) before WHAM
924
+ // completes. Join a flight whose starting or resolved generation is still current, but let a
925
+ // replacement credential with the same pool id start its own request.
926
+ const record = readCodexAccountRecord(accountId);
927
+ const flights = poolQuotaRefreshInFlight.get(accountId);
928
+ const current = flights && [...flights].find(flight => {
929
+ const generation = flight.state.resolvedCredentialGeneration
930
+ ?? flight.state.startCredentialGeneration;
931
+ return generation !== undefined && isCodexAccountGenerationLive(accountId, generation);
932
+ });
933
+ if (current) return current.promise;
934
+ if (poolQuotaFlightCount() >= MAX_POOL_QUOTA_FLIGHTS) throw new PoolQuotaProbeBusyError();
935
+
936
+ const state: PoolQuotaRefreshFlight["state"] = {
937
+ startCredentialGeneration: record?.generation,
938
+ };
939
+ const refresh = fetchFreshPoolAccountQuota(
940
+ accountId,
941
+ existing,
942
+ configuredPlan,
943
+ generation => { state.resolvedCredentialGeneration = generation; },
944
+ );
945
+ const flight: PoolQuotaRefreshFlight = { state, promise: refresh };
946
+ const activeFlights = flights ?? new Set<PoolQuotaRefreshFlight>();
947
+ activeFlights.add(flight);
948
+ if (!flights) poolQuotaRefreshInFlight.set(accountId, activeFlights);
949
+ try {
950
+ return await refresh;
951
+ } finally {
952
+ activeFlights.delete(flight);
953
+ if (activeFlights.size === 0 && poolQuotaRefreshInFlight.get(accountId) === activeFlights) {
954
+ poolQuotaRefreshInFlight.delete(accountId);
955
+ }
956
+ }
957
+ }
958
+
959
+ let primeInFlight: Promise<void> | null = null;
960
+ let cooldownRecoveryInFlight: Promise<void> | null = null;
961
+
962
+ export async function runCodexCooldownRecoveryProbes(config: OcxConfig, now = Date.now()): Promise<void> {
963
+ const openai = config.providers[OPENAI_CODEX_PROVIDER_ID];
964
+ if (!openai
965
+ || openai.disabled === true
966
+ || !isCanonicalOpenAiForwardProvider(openai)
967
+ || providerCodexAccountMode(OPENAI_CODEX_PROVIDER_ID, openai) !== "pool") return;
968
+ if (cooldownRecoveryInFlight) return cooldownRecoveryInFlight;
969
+ cooldownRecoveryInFlight = (async () => {
970
+ const claims = claimDueCodexQuotaRecoveryProbes(config, POOL_QUOTA_REFRESH_CONCURRENCY, now);
971
+ await mapWithConcurrency(claims, POOL_QUOTA_REFRESH_CONCURRENCY, async claim => {
972
+ const account = configuredPoolAccount(config, claim.accountId);
973
+ if (!account) {
974
+ settleCodexQuotaRecoveryProbe(claim, false, {}, now);
975
+ return;
976
+ }
977
+ try {
978
+ const result = await fetchPoolAccountQuota(claim.accountId, true, account.plan);
979
+ // Defence in depth: `spark` is already excluded at the claim site, since generic WHAM
980
+ // cannot prove a spark recovery. Keep the settle-side guard so a future claim change
981
+ // cannot silently start clearing spark on generic evidence.
982
+ const recovered = claim.scope !== "spark"
983
+ && isCompleteCodexQuotaRecoverySnapshot(result.freshQuota ?? null, result.freshPlan ?? account.plan);
984
+ settleCodexQuotaRecoveryProbe(claim, recovered, {
985
+ credentialGeneration: result.freshCredentialGeneration,
986
+ }, now);
987
+ } catch {
988
+ settleCodexQuotaRecoveryProbe(claim, false, {}, now);
989
+ }
990
+ });
991
+ })().catch(() => {
992
+ // Background recovery is best-effort; routing keeps the cooldown on failure.
993
+ }).finally(() => { cooldownRecoveryInFlight = null; });
994
+ return cooldownRecoveryInFlight;
995
+ }
996
+
997
+ export function registerCodexCooldownRecoveryProbeWorker(config: OcxConfig): void {
998
+ registerStateSweepAfterTick({
999
+ name: "codex-cooldown-recovery",
1000
+ afterTick: () => { void runCodexCooldownRecoveryProbes(config); },
1001
+ });
1002
+ }
1003
+
1004
+ export interface PrimeCodexPoolQuotasOptions {
1005
+ /** Test seams for proving fenced/recovery priming performs no native-main work. */
1006
+ reconcileMainAccount?: typeof reconcileMainCodexAccountRuntimeState;
1007
+ readMainTokens?: typeof readCodexTokens;
1008
+ fetchMainInfo?: typeof fetchMainAccountInfo;
1009
+ }
1010
+
1011
+ function tryAcquireNativeMainPrimeLease(): AdmissionLease | null {
1012
+ return tryAcquireNativeMainProfileClaim();
1013
+ }
1014
+
1015
+ /**
1016
+ * Best-effort prime of pool-account (and main) quota so the rotation engine has
1017
+ * real usage scores instead of leaving every account at the unknown sentinel.
1018
+ *
1019
+ * Quota is otherwise populated only from live upstream headers (an idle pool
1020
+ * account never serves traffic, so it never gets scored) or from the dashboard
1021
+ * WHAM fetch (a CLI-only user never opens it). Without priming, every account
1022
+ * stays unknown and auto-switch cannot move (see Phase 10). This runs at startup
1023
+ * and lazily before routing when the active account is unknown.
1024
+ *
1025
+ * Single-flight: concurrent callers share one pass instead of stampeding N WHAM
1026
+ * fetches. Per-fetch 8s timeouts and the 5-minute POOL_CACHE_TTL already bound
1027
+ * cost, so the worst case is one WHAM call per account per TTL window. Failures
1028
+ * are swallowed: a blocked WSL network must never crash startup or a request.
1029
+ */
1030
+ export async function primeCodexPoolQuotas(
1031
+ config: OcxConfig,
1032
+ reason: string,
1033
+ options: PrimeCodexPoolQuotasOptions = {},
1034
+ ): Promise<void> {
1035
+ const openai = config.providers[OPENAI_CODEX_PROVIDER_ID];
1036
+ if (
1037
+ !openai
1038
+ || openai.disabled === true
1039
+ || !isCanonicalOpenAiForwardProvider(openai)
1040
+ || providerCodexAccountMode(OPENAI_CODEX_PROVIDER_ID, openai) !== "pool"
1041
+ ) return;
1042
+ if (primeInFlight) return primeInFlight;
1043
+ primeInFlight = (async () => {
1044
+ const runtimeConfig = getRuntimeConfig(config);
1045
+ const pool = (runtimeConfig.codexAccounts ?? []).filter(isSelectableCodexPoolAccount);
1046
+ const stale = pool.filter(a => {
1047
+ const q = getAccountQuota(a.id);
1048
+ return !q || Date.now() - q.updatedAt >= POOL_CACHE_TTL;
1049
+ });
1050
+ const primeMain = async () => {
1051
+ const mainLease = tryAcquireNativeMainPrimeLease();
1052
+ if (!mainLease) return;
1053
+ try {
1054
+ try {
1055
+ await withNativeMainCredentialClaim(async () => {
1056
+ // Keep one local owner and one cross-process reader from physical
1057
+ // identity reconciliation through WHAM and all quota publication.
1058
+ (options.reconcileMainAccount ?? reconcileMainCodexAccountRuntimeState)();
1059
+ if (getAccountQuota(MAIN_CODEX_ACCOUNT_ID)) return;
1060
+ if (!(options.readMainTokens ?? readCodexTokens)()) return;
1061
+ if (options.fetchMainInfo) await options.fetchMainInfo(false);
1062
+ else await fetchMainAccountInfoAttempt(false, 1, mainLease, true);
1063
+ });
1064
+ } catch (error) {
1065
+ if (!isNativeMainClaimUnavailable(error)) throw error;
1066
+ }
1067
+ } finally {
1068
+ mainLease.release();
1069
+ }
1070
+ };
1071
+ try {
1072
+ await Promise.allSettled([
1073
+ primeMain(),
1074
+ mapWithConcurrency(stale, POOL_QUOTA_REFRESH_CONCURRENCY, async a => {
1075
+ if (!getCodexAccountCredential(a.id)) return;
1076
+ await fetchPoolAccountQuota(a.id, false, a.plan);
1077
+ }),
1078
+ ]);
1079
+ } catch {
1080
+ // Priming is best-effort; never propagate.
1081
+ }
1082
+ if (process.env.OPENCODEX_DEBUG_QUOTA === "1") {
1083
+ console.warn(`[codex-quota] prime done (reason=${reason}, pool=${pool.length}, refreshed=${stale.length})`);
1084
+ }
1085
+ })().finally(() => { primeInFlight = null; });
1086
+ return primeInFlight;
1087
+ }
1088
+
1089
+ /** Test-only: drop any in-flight prime pass so a leaked single-flight promise
1090
+ * from another suite cannot coalesce into the next prime. */
1091
+ export function clearCodexQuotaPrimeState(): void {
1092
+ primeInFlight = null;
1093
+ }
1094
+
1095
+ /** Test-only reset for the worker-level single-flight. */
1096
+ export function clearCodexCooldownRecoveryProbeState(): void {
1097
+ cooldownRecoveryInFlight = null;
1098
+ }
1099
+
1100
+ export function effectiveCodexAuthAccountId(config: OcxConfig): string {
1101
+ return getEffectiveActiveCodexAccountId(config) ?? MAIN_CODEX_ACCOUNT_ID;
1102
+ }
1103
+
1104
+ export interface CodexAuthAccountsSnapshot {
1105
+ accounts: CodexAuthAccountDto[];
1106
+ mainIdentityGeneration: number;
1107
+ }
1108
+
1109
+ export async function listCodexAuthAccountsSnapshot(
1110
+ config: OcxConfig,
1111
+ forceRefresh = false,
1112
+ ): Promise<CodexAuthAccountsSnapshot> {
1113
+ const runtimeConfig = getRuntimeConfig(config);
1114
+ const poolAccounts = (runtimeConfig.codexAccounts ?? []).filter(isSelectableCodexPoolAccount);
1115
+ const mainResult = await fetchMainAccountInfoAttempt(forceRefresh, 1);
1116
+ const refreshedPool = await mapWithConcurrency(poolAccounts, POOL_QUOTA_REFRESH_CONCURRENCY, async account => {
1117
+ const cred = getCodexAccountCredential(account.id);
1118
+ let quotaResult: PoolQuotaResult;
1119
+ if (!cred) {
1120
+ quotaResult = { quota: null, needsReauth: true };
1121
+ } else {
1122
+ try {
1123
+ quotaResult = await fetchPoolAccountQuota(account.id, forceRefresh, account.plan);
1124
+ } catch (error) {
1125
+ if (!(error instanceof PoolQuotaProbeBusyError)) throw error;
1126
+ quotaResult = {
1127
+ quota: getAccountQuota(account.id),
1128
+ needsReauth: false,
1129
+ credentialGeneration: readCodexAccountRecord(account.id)?.generation,
1130
+ quotaProbeSkipped: true,
1131
+ };
1132
+ }
1133
+ }
1134
+ return { accountId: account.id, quotaResult };
1135
+ });
1136
+
1137
+ // WHAM plan_type is authoritative only for the credential generation that fetched it. Collect
1138
+ // changes after every parallel read settles, then apply one narrow disk patch for the batch.
1139
+ const planUpdates = refreshedPool.flatMap(({ accountId, quotaResult }): FreshPoolPlanUpdate[] => {
1140
+ const plan = quotaResult.freshPlan;
1141
+ const credentialGeneration = quotaResult.freshCredentialGeneration;
1142
+ return plan && credentialGeneration !== undefined
1143
+ ? [{ accountId, plan, credentialGeneration }]
1144
+ : [];
1145
+ });
1146
+ reconcileFreshPoolAccountPlans(runtimeConfig, planUpdates);
1147
+
1148
+ const withQuota = refreshedPool.flatMap(({ accountId, quotaResult }) => {
1149
+ const currentAccount = configuredPoolAccount(runtimeConfig, accountId);
1150
+ if (!currentAccount) return [];
1151
+ const currentCredential = getCodexAccountCredential(accountId);
1152
+ if (!currentCredential) {
1153
+ return [poolAccountDto(
1154
+ currentAccount,
1155
+ { quota: null, needsReauth: true },
1156
+ false,
1157
+ isCodexAccountPaused(runtimeConfig, accountId),
1158
+ getCodexAccountPriority(runtimeConfig, accountId),
1159
+ )];
1160
+ }
1161
+ const resultGeneration = quotaResult.credentialGeneration ?? quotaResult.freshCredentialGeneration;
1162
+ const generationLive = resultGeneration === undefined
1163
+ || isCodexAccountGenerationLive(accountId, resultGeneration);
1164
+ const effectiveQuotaResult = !generationLive
1165
+ ? { quota: null, needsReauth: false }
1166
+ : quotaResult;
1167
+ // Response DTO can show the WHAM plan even when disk persistence fails closed (lock busy /
1168
+ // missing config). Persistence still remains generation-gated via reconcileFreshPoolAccountPlans.
1169
+ const dtoAccount = generationLive && quotaResult.freshPlan
1170
+ ? { ...currentAccount, plan: quotaResult.freshPlan }
1171
+ : currentAccount;
1172
+ return [poolAccountDto(
1173
+ dtoAccount,
1174
+ effectiveQuotaResult,
1175
+ true,
1176
+ isCodexAccountPaused(runtimeConfig, accountId),
1177
+ getCodexAccountPriority(runtimeConfig, accountId),
1178
+ )];
1179
+ });
1180
+ const fetchedMainGeneration = mainResult.identityGeneration ?? captureMainAccountIdentityGeneration();
1181
+ const mainSnapshotLive = isMainAccountIdentityGenerationLive(fetchedMainGeneration);
1182
+ const mainInfo = mainSnapshotLive ? mainResult.info : EMPTY_MAIN_ACCOUNT_INFO;
1183
+ const hasMainCredential = mainSnapshotLive && mainResult.credentialChecked
1184
+ ? mainResult.hasCredential
1185
+ : getMainAccountCredentialPresence() ?? false;
1186
+ const mainNeedsReauth = (mainSnapshotLive && mainResult.credentialChecked && !hasMainCredential)
1187
+ || isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
1188
+ const mainHealth = projectCodexAccountHealth({
1189
+ accountId: MAIN_CODEX_ACCOUNT_ID,
1190
+ needsReauth: mainNeedsReauth,
1191
+ });
1192
+ const main: CodexAuthAccountDto = {
1193
+ id: MAIN_CODEX_ACCOUNT_ID,
1194
+ email: maskEmail(mainInfo.email) ?? "Codex App login",
1195
+ plan: mainInfo.plan,
1196
+ isMain: true,
1197
+ paused: isCodexAccountPaused(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
1198
+ priority: getCodexAccountPriority(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
1199
+ hasCredential: hasMainCredential,
1200
+ needsReauth: mainNeedsReauth,
1201
+ quota: mainInfo.quota ? {
1202
+ ...quotaForPlan({
1203
+ ...mainInfo.quota,
1204
+ updatedAt: getAccountQuota(MAIN_CODEX_ACCOUNT_ID)?.updatedAt ?? Date.now(),
1205
+ }, mainInfo.plan),
1206
+ } : null,
1207
+ ...oauthAccountHealthFields("codex", MAIN_CODEX_ACCOUNT_ID, mainHealth),
1208
+ };
1209
+ return {
1210
+ accounts: [main, ...withQuota],
1211
+ mainIdentityGeneration: mainSnapshotLive
1212
+ ? fetchedMainGeneration
1213
+ : captureMainAccountIdentityGeneration(),
1214
+ };
1215
+ }
1216
+
1217
+ export async function listCodexAuthAccounts(config: OcxConfig, forceRefresh = false): Promise<CodexAuthAccountDto[]> {
1218
+ return (await listCodexAuthAccountsSnapshot(config, forceRefresh)).accounts;
1219
+ }
1220
+
1221
+ interface PauseExhaustedResult {
1222
+ pausedAccountIds: string[];
1223
+ checkedAccountCount: number;
1224
+ failedAccountCount: number;
1225
+ }
1226
+
1227
+ function selectFallbackAfterPause(config: OcxConfig, pausedActiveId: string): void {
1228
+ reconcileCodexActiveAfterExclusion(config, pausedActiveId);
1229
+ }
1230
+
1231
+ async function pauseExhaustedCodexAccounts(
1232
+ config: OcxConfig,
1233
+ persistPausedAccounts: () => void,
1234
+ ): Promise<PauseExhaustedResult> {
1235
+ const poolAccounts = (config.codexAccounts ?? []).filter(account => !account.isMain);
1236
+ const nativeMainLease = tryAcquireNativeMainProfileClaim();
1237
+ try {
1238
+ const performPause = async (mainLease?: AdmissionLease): Promise<PauseExhaustedResult> => {
1239
+ const mainWork = async (): Promise<{
1240
+ shouldPause: boolean;
1241
+ checkedAccountCount: number;
1242
+ failedAccountCount: number;
1243
+ }> => {
1244
+ if (!mainLease) return { shouldPause: false, checkedAccountCount: 0, failedAccountCount: 1 };
1245
+ const mainResult = await fetchMainAccountInfoAttempt(true, 1, mainLease, true);
1246
+ if (!mainResult.credentialChecked || !mainResult.hasCredential) {
1247
+ return { shouldPause: false, checkedAccountCount: 0, failedAccountCount: 0 };
1248
+ }
1249
+ if (!mainResult.freshQuota || !mainResult.info.plan) {
1250
+ return { shouldPause: false, checkedAccountCount: 0, failedAccountCount: 1 };
1251
+ }
1252
+ return {
1253
+ shouldPause: !isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID)
1254
+ && isCodexQuotaExhausted(mainResult.freshQuota, mainResult.info.plan),
1255
+ checkedAccountCount: 1,
1256
+ failedAccountCount: 0,
1257
+ };
1258
+ };
1259
+ const [mainResult, poolResults] = await Promise.all([
1260
+ mainWork(),
1261
+ mapWithConcurrency(poolAccounts, POOL_QUOTA_REFRESH_CONCURRENCY, async account => {
1262
+ if (!getCodexAccountCredential(account.id)) return { account, quotaResult: null };
1263
+ try {
1264
+ return {
1265
+ account,
1266
+ quotaResult: await fetchPoolAccountQuota(account.id, true, account.plan),
1267
+ };
1268
+ } catch {
1269
+ // Settle each pool probe independently so a busy/failing account cannot
1270
+ // abandon an already-confirmed main decision before atomic publication.
1271
+ return { account, quotaResult: null };
1272
+ }
1273
+ }),
1274
+ ]);
1275
+
1276
+ let checkedAccountCount = mainResult.checkedAccountCount;
1277
+ let failedAccountCount = mainResult.failedAccountCount;
1278
+ const exhaustedIds: string[] = mainResult.shouldPause ? [MAIN_CODEX_ACCOUNT_ID] : [];
1279
+ for (const { account, quotaResult } of poolResults) {
1280
+ const currentAccount = (config.codexAccounts ?? []).find(candidate => candidate.id === account.id && !candidate.isMain);
1281
+ if (!currentAccount) continue;
1282
+ const generation = quotaResult?.freshCredentialGeneration;
1283
+ const plan = quotaResult?.freshPlan ?? currentAccount.plan;
1284
+ if (!quotaResult?.freshQuota || generation === undefined || !isCodexAccountGenerationLive(account.id, generation) || !plan) {
1285
+ failedAccountCount += 1;
1286
+ continue;
1287
+ }
1288
+ checkedAccountCount += 1;
1289
+ if (!isCodexAccountPaused(config, account.id) && isCodexQuotaExhausted(quotaResult.freshQuota, plan)) {
1290
+ exhaustedIds.push(account.id);
1291
+ }
1292
+ }
1293
+
1294
+ for (const id of exhaustedIds) {
1295
+ setCodexAccountPaused(config, id, true);
1296
+ clearThreadAccountMapForAccount(id);
1297
+ }
1298
+ for (const id of exhaustedIds) selectFallbackAfterPause(config, id);
1299
+ const result = {
1300
+ pausedAccountIds: exhaustedIds,
1301
+ checkedAccountCount,
1302
+ failedAccountCount,
1303
+ };
1304
+ // Persist while both the in-process admission and cross-process shared
1305
+ // claim still own the physical-main identity used for the decision.
1306
+ if (result.pausedAccountIds.length > 0) persistPausedAccounts();
1307
+ return result;
1308
+ };
1309
+
1310
+ if (!nativeMainLease) return await performPause();
1311
+ try {
1312
+ return await withNativeMainCredentialClaim(() => performPause(nativeMainLease));
1313
+ } catch (error) {
1314
+ if (isNativeMainClaimUnavailable(error)) return await performPause();
1315
+ throw error;
1316
+ }
1317
+ } finally {
1318
+ nativeMainLease?.release();
1319
+ }
1320
+ }
1321
+
1322
+ export async function handleCodexAuthAPI(
1323
+ req: Request,
1324
+ url: URL,
1325
+ config: OcxConfig,
1326
+ convergeCodexCatalog?: CodexAuthCatalogConvergence,
1327
+ ): Promise<Response | null> {
1328
+
1329
+ if (url.pathname === "/api/codex-auth/accounts" && req.method === "GET") {
1330
+ const forceRefresh = url.searchParams.get("refresh") === "1" || url.searchParams.get("refresh") === "true";
1331
+ return jsonResponse({ accounts: await listCodexAuthAccounts(config, forceRefresh) });
1332
+ }
1333
+
1334
+ if (url.pathname === "/api/codex-auth/accounts" && req.method === "POST") {
1335
+ if (!isUnverifiedCodexImportEnabled()) return manualImportDisabledResponse();
1336
+
1337
+ let body: { id: string; email: string; plan?: string; accessToken: string; refreshToken: string; chatgptAccountId: string };
1338
+ try { body = (await req.json()) as typeof body; } catch { return jsonResponse({ error: "Invalid JSON" }, 400); }
1339
+ if (!body.id || !body.email || !body.accessToken || !body.refreshToken || !body.chatgptAccountId) {
1340
+ return jsonResponse({ error: "Missing required fields" }, 400);
1341
+ }
1342
+ if (!isValidCodexAccountId(body.id)) {
1343
+ return jsonResponse({ error: "Invalid account id format" }, 400);
1344
+ }
1345
+ if (body.accessToken.length > 10_000 || body.refreshToken.length > 10_000) {
1346
+ return jsonResponse({ error: "Input too large" }, 400);
1347
+ }
1348
+ const runtimeConfig = getRuntimeConfig(config);
1349
+ const preflightConflict = codexAccountPersistenceConflict(runtimeConfig, body.id, "create");
1350
+ if (preflightConflict) return jsonResponse({ error: preflightConflict }, 400);
1351
+ // 1.1: Duplicate check is scoped by personal vs workspace plan bucket.
1352
+ const derivedAccountId = extractAccountId(undefined, body.accessToken) ?? body.chatgptAccountId;
1353
+ const collision = checkAccountIdCollision(derivedAccountId, body.email, body.plan);
1354
+ if (collision.collision) {
1355
+ return jsonResponse({ error: collision.reason }, 400);
1356
+ }
1357
+ // 4.2: use JWT exp for expiresAt instead of hardcoded 1 hour
1358
+ const payload = decodeJwtPayload(body.accessToken);
1359
+ const exp = typeof payload?.exp === "number" ? payload.exp * 1000 : Date.now() + 3600_000;
1360
+ const warmup = await verifyCodexAccountWarmup(body.id, body.accessToken, derivedAccountId);
1361
+ if (!warmup.ok) return warmup.response;
1362
+ const latestConfig = getRuntimeConfig(config);
1363
+ const commitConflict = codexAccountPersistenceConflict(latestConfig, body.id, "create");
1364
+ if (commitConflict) return jsonResponse({ error: commitConflict }, 400);
1365
+ const addedAccount = withCodexAccountLogLabel(
1366
+ { id: body.id, email: body.email, plan: body.plan, isMain: false },
1367
+ latestConfig.codexAccounts ?? [],
1368
+ );
1369
+ const persistence = persistNewCodexAccount(
1370
+ config,
1371
+ latestConfig,
1372
+ addedAccount,
1373
+ {
1374
+ credential: {
1375
+ accessToken: body.accessToken,
1376
+ refreshToken: body.refreshToken,
1377
+ expiresAt: exp,
1378
+ chatgptAccountId: derivedAccountId,
1379
+ },
1380
+ validatedAt: warmup.validatedAt,
1381
+ },
1382
+ );
1383
+ reconcileLiveStateStores();
1384
+ if (persistence.status === "publication-failed") markAccountNeedsReauth(body.id);
1385
+ const catalogRefresh = await convergeAccountNamespaceCatalog(
1386
+ latestConfig,
1387
+ persistence.pickerVisibilityChanged,
1388
+ convergeCodexCatalog,
1389
+ );
1390
+ if (persistence.status === "publication-failed") {
1391
+ return jsonResponse({
1392
+ ok: false,
1393
+ ...codexCredentialPersistenceFailure(body.id, catalogRefresh.catalogRefreshPending === true),
1394
+ }, 500);
1395
+ }
1396
+ return jsonResponse({ ok: true, ...catalogRefresh });
1397
+ }
1398
+
1399
+ if (url.pathname === "/api/codex-auth/accounts" && req.method === "DELETE") {
1400
+ const id = url.searchParams.get("id");
1401
+ if (!id) return jsonResponse({ error: "Missing id" }, 400);
1402
+ const runtimeConfig = getRuntimeConfig(config);
1403
+ const isLegacyPoolAccount = CODEX_ACCOUNT_ID_RE.test(id)
1404
+ && (runtimeConfig.codexAccounts ?? []).some(account => !account.isMain && account.id === id);
1405
+ if (!isValidCodexAccountId(id) && !isLegacyPoolAccount) {
1406
+ return jsonResponse({ error: "Invalid account id format" }, 400);
1407
+ }
1408
+ const pickerVisibilityChanged = deleteCodexAccount(runtimeConfig, id);
1409
+ saveRuntimeConfig(config, runtimeConfig);
1410
+ reconcileLiveStateStores();
1411
+ const catalogRefresh = await convergeAccountNamespaceCatalog(
1412
+ runtimeConfig,
1413
+ pickerVisibilityChanged,
1414
+ convergeCodexCatalog,
1415
+ );
1416
+ return jsonResponse({ ok: true, ...catalogRefresh });
1417
+ }
1418
+
1419
+ if (url.pathname === "/api/codex-auth/accounts/alias" && req.method === "PUT") {
1420
+ const body = await req.json().catch(() => ({})) as { id?: unknown; alias?: unknown };
1421
+ const id = typeof body.id === "string" ? body.id.trim() : "";
1422
+ const alias = typeof body.alias === "string" ? body.alias.trim() : "";
1423
+ if (id === MAIN_CODEX_ACCOUNT_ID) return jsonResponse({ error: "Main Codex account alias is not configurable" }, 400);
1424
+ if (!isValidCodexAccountId(id)) return jsonResponse({ error: "Invalid account id format" }, 400);
1425
+ if (typeof body.alias !== "string" || alias.length > 80 || /[\x00-\x1f\x7f]/.test(alias)) {
1426
+ return jsonResponse({ error: "Alias must be a string of at most 80 printable characters" }, 400);
1427
+ }
1428
+ const runtimeConfig = getRuntimeConfig(config);
1429
+ const account = (runtimeConfig.codexAccounts ?? []).find(candidate => candidate.id === id && !candidate.isMain);
1430
+ if (!account) return jsonResponse({ error: "Account not found" }, 404);
1431
+ if (alias) account.alias = alias;
1432
+ else delete account.alias;
1433
+ saveRuntimeConfig(config, runtimeConfig);
1434
+ return jsonResponse({ ok: true, id, alias: alias || null });
1435
+ }
1436
+
1437
+ if (url.pathname === "/api/codex-auth/accounts/pause" && req.method === "PUT") {
1438
+ const body = await req.json().catch(() => ({})) as { id?: unknown; paused?: unknown };
1439
+ const id = typeof body.id === "string" ? body.id.trim() : "";
1440
+ if (id !== MAIN_CODEX_ACCOUNT_ID && !isValidCodexAccountId(id)) {
1441
+ return jsonResponse({ error: "Invalid account id format" }, 400);
1442
+ }
1443
+ if (typeof body.paused !== "boolean") return jsonResponse({ error: "paused must be a boolean" }, 400);
1444
+
1445
+ const runtimeConfig = getRuntimeConfig(config);
1446
+ const exists = id === MAIN_CODEX_ACCOUNT_ID
1447
+ || (runtimeConfig.codexAccounts ?? []).some(account => isSelectableCodexPoolAccount(account) && account.id === id);
1448
+ if (!exists) return jsonResponse({ error: "Account not found" }, 404);
1449
+
1450
+ setCodexAccountPaused(runtimeConfig, id, body.paused);
1451
+ if (body.paused) {
1452
+ clearThreadAccountMapForAccount(id);
1453
+ selectFallbackAfterPause(runtimeConfig, id);
1454
+ }
1455
+ saveRuntimeConfig(config, runtimeConfig);
1456
+ return jsonResponse({
1457
+ ok: true,
1458
+ id,
1459
+ paused: body.paused,
1460
+ activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null,
1461
+ appliesImmediately: true,
1462
+ });
1463
+ }
1464
+
1465
+ // Deliberately a route of its own rather than a field on the alias PATCH: aliases
1466
+ // are display-only and reject __main__, while selection order is routing metadata
1467
+ // that the Desktop account must be able to carry. Re-ordering never kicks a live
1468
+ // thread, so there is no affinity clearing and no appliesImmediately here.
1469
+ if (url.pathname === "/api/codex-auth/accounts/priority" && req.method === "PUT") {
1470
+ let parsedBody: unknown;
1471
+ try { parsedBody = await req.json(); } catch { return jsonResponse({ error: "Invalid JSON" }, 400); }
1472
+ if (typeof parsedBody !== "object" || parsedBody === null || Array.isArray(parsedBody)) {
1473
+ return jsonResponse({ error: "body must be an object" }, 400);
1474
+ }
1475
+ const body = parsedBody as { id?: unknown; priority?: unknown };
1476
+ const id = typeof body.id === "string" ? body.id.trim() : "";
1477
+ if (!isCodexAccountPriorityKey(id)) {
1478
+ return jsonResponse({ error: "Invalid account id format" }, 400);
1479
+ }
1480
+
1481
+ let priority = DEFAULT_ACCOUNT_PRIORITY;
1482
+ if (body.priority !== null) {
1483
+ const parsed = parseAccountPriority(body.priority);
1484
+ if (parsed === null) {
1485
+ return jsonResponse({
1486
+ error: `priority must be null or an integer ${MIN_ACCOUNT_PRIORITY}-${MAX_ACCOUNT_PRIORITY}`,
1487
+ }, 400);
1488
+ }
1489
+ priority = parsed;
1490
+ }
1491
+
1492
+ const runtimeConfig = getRuntimeConfig(config);
1493
+ const exists = id === MAIN_CODEX_ACCOUNT_ID
1494
+ || (runtimeConfig.codexAccounts ?? []).some(account => isSelectableCodexPoolAccount(account) && account.id === id);
1495
+ if (!exists) return jsonResponse({ error: "Account not found" }, 404);
1496
+
1497
+ setCodexAccountPriority(runtimeConfig, id, priority);
1498
+ // Both a pin and an order are the operator saying which account to use, so the newer
1499
+ // statement wins. Without this a pin made before any order existed — an ordinary
1500
+ // account switch — would outrank the order forever: it blocks preemption and caps
1501
+ // every eligibility list at its own tier until that account drains or is paused.
1502
+ clearCodexAccountPin(runtimeConfig);
1503
+ saveRuntimeConfig(config, runtimeConfig);
1504
+ return jsonResponse({
1505
+ ok: true,
1506
+ id,
1507
+ priority,
1508
+ activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null,
1509
+ });
1510
+ }
1511
+
1512
+ if (url.pathname === "/api/codex-auth/accounts/pause-exhausted" && req.method === "PUT") {
1513
+ const runtimeConfig = getRuntimeConfig(config);
1514
+ const result = await pauseExhaustedCodexAccounts(
1515
+ runtimeConfig,
1516
+ () => saveRuntimeConfig(config, runtimeConfig),
1517
+ );
1518
+ const { pausedAccountIds, checkedAccountCount, failedAccountCount } = result;
1519
+ if (checkedAccountCount === 0 && failedAccountCount > 0) {
1520
+ return jsonResponse({
1521
+ ok: false,
1522
+ error: "Failed to refresh any Codex account quota",
1523
+ checkedAccountCount,
1524
+ failedAccountCount,
1525
+ }, 502);
1526
+ }
1527
+ return jsonResponse({
1528
+ ok: true,
1529
+ pausedAccountIds,
1530
+ pausedCount: pausedAccountIds.length,
1531
+ checkedAccountCount,
1532
+ failedAccountCount,
1533
+ complete: failedAccountCount === 0,
1534
+ activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null,
1535
+ appliesImmediately: true,
1536
+ });
1537
+ }
1538
+
1539
+ // Manual escape from a quota cooldown. Injected Codex routing makes this proxy the only
1540
+ // model path for Codex Desktop, so a cooldown that outlives the real upstream limit
1541
+ // otherwise leaves editing config.toml as the user's only recovery.
1542
+ //
1543
+ // Existence is deliberately NOT disclosed: an unknown id returns 200 with cleared:false
1544
+ // exactly like an account that simply had no live cooldown, so this route cannot be used
1545
+ // to enumerate configured accounts. Cooldown state is runtime-only and independent of the
1546
+ // account list, so 404 would carry no useful meaning anyway.
1547
+ if (url.pathname === "/api/codex-auth/accounts/clear-cooldown" && req.method === "POST") {
1548
+ const body = await req.json().catch(() => ({})) as { id?: unknown };
1549
+ const id = typeof body.id === "string" ? body.id.trim() : "";
1550
+ if (id !== MAIN_CODEX_ACCOUNT_ID && !isValidCodexAccountId(id)) {
1551
+ return jsonResponse({ error: "Invalid account id format" }, 400);
1552
+ }
1553
+ return jsonResponse({ ok: true, id, cleared: clearCodexAccountCooldown(id) });
1554
+ }
1555
+
1556
+ if (url.pathname === "/api/codex-auth/active" && req.method === "PUT") {
1557
+ let body: { accountId: string | null };
1558
+ try { body = (await req.json()) as typeof body; } catch { return jsonResponse({ error: "Invalid JSON" }, 400); }
1559
+ const runtimeConfig = getRuntimeConfig(config);
1560
+ const targetAccountId = body.accountId ?? MAIN_CODEX_ACCOUNT_ID;
1561
+ if (body.accountId === MAIN_CODEX_ACCOUNT_ID && hasLegacyMainCodexPoolAccount(runtimeConfig.codexAccounts)) {
1562
+ return jsonResponse({ error: "Remove the legacy __main__ pool row before selecting the Desktop account" }, 409);
1563
+ }
1564
+ if (isCodexAccountPaused(runtimeConfig, targetAccountId)) {
1565
+ return jsonResponse({ error: "Account is paused" }, 409);
1566
+ }
1567
+ if (body.accountId != null && body.accountId !== MAIN_CODEX_ACCOUNT_ID) {
1568
+ if (!isValidCodexAccountId(body.accountId)) return jsonResponse({ error: "Invalid account id format" }, 400);
1569
+ const exists = (runtimeConfig.codexAccounts ?? [])
1570
+ .some(account => isSelectableCodexPoolAccount(account) && account.id === body.accountId);
1571
+ if (!exists) return jsonResponse({ error: "Account not found" }, 400);
1572
+ }
1573
+ runtimeConfig.activeCodexAccountId = body.accountId ?? undefined;
1574
+ // "Use this account now" outranks selection order until the account is spent:
1575
+ // persisted here rather than in resetCodexRoutingForManualSelection, which is
1576
+ // runtime state only. A null id clears the selection instead of making one, so it
1577
+ // must release the pin rather than record one: pinning the `targetAccountId`
1578
+ // fallback would leave a pin that no effective active account matches, which
1579
+ // `isEffectiveCodexAccountPinned` reports as unpinned while the tier filter still
1580
+ // honours it as a ceiling — invisibly capping the pool at the main account's tier.
1581
+ if (body.accountId == null) clearCodexAccountPin(runtimeConfig);
1582
+ else setCodexAccountPin(runtimeConfig, targetAccountId);
1583
+ resetCodexRoutingForManualSelection(targetAccountId);
1584
+ saveRuntimeConfig(config, runtimeConfig);
1585
+ return jsonResponse({ ok: true, activeCodexAccountId: body.accountId, appliesImmediately: true });
1586
+ }
1587
+
1588
+ if (url.pathname === "/api/codex-auth/active" && req.method === "GET") {
1589
+ const runtimeConfig = getRuntimeConfig(config);
1590
+ return jsonResponse({
1591
+ activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null,
1592
+ pinned: isEffectiveCodexAccountPinned(runtimeConfig),
1593
+ // Which account carries the pin, not just whether the active one does. Under
1594
+ // round-robin or fill-first the pin caps the tier ceiling at its own tier while the
1595
+ // strategy cursor moves freely inside that tier, so `pinned` alone goes false on a
1596
+ // sibling's turn even though the pin is still suppressing every higher tier. The id
1597
+ // lets a surface mark the account the operator actually chose.
1598
+ pinnedAccountId: pinnedCodexAccountId(runtimeConfig) ?? null,
1599
+ autoSwitchThreshold: runtimeConfig.autoSwitchThreshold ?? 80,
1600
+ upstreamFailoverThreshold: runtimeConfig.upstreamFailoverThreshold ?? 3,
1601
+ accountPoolStrategy: normalizeAccountPoolStrategy(runtimeConfig.accountPoolStrategy),
1602
+ accountPoolStickyLimit: normalizeAccountPoolStickyLimit(runtimeConfig.accountPoolStickyLimit),
1603
+ });
1604
+ }
1605
+
1606
+ if (url.pathname === "/api/codex-auth/auto-switch" && req.method === "PUT") {
1607
+ let body: { threshold: number };
1608
+ try { body = (await req.json()) as typeof body; } catch { return jsonResponse({ error: "Invalid JSON" }, 400); }
1609
+ if (typeof body.threshold !== "number" || !Number.isInteger(body.threshold) || body.threshold < 0 || body.threshold > 100) {
1610
+ return jsonResponse({ error: "Threshold must be an integer 0-100" }, 400);
1611
+ }
1612
+ const runtimeConfig = getRuntimeConfig(config);
1613
+ runtimeConfig.autoSwitchThreshold = body.threshold;
1614
+ saveRuntimeConfig(config, runtimeConfig);
1615
+ return jsonResponse({ ok: true });
1616
+ }
1617
+
1618
+ if (
1619
+ url.pathname === "/api/codex-auth/pool-strategy"
1620
+ && (req.method === "PUT" || req.method === "PATCH")
1621
+ ) {
1622
+ let parsedBody: unknown;
1623
+ try { parsedBody = await req.json(); } catch { return jsonResponse({ error: "Invalid JSON" }, 400); }
1624
+ if (typeof parsedBody !== "object" || parsedBody === null || Array.isArray(parsedBody)) {
1625
+ return jsonResponse({ error: "body must be an object" }, 400);
1626
+ }
1627
+ const body = parsedBody as { strategy?: unknown; stickyLimit?: unknown };
1628
+ if (body.strategy === undefined && body.stickyLimit === undefined) {
1629
+ return jsonResponse({ error: "strategy or stickyLimit required" }, 400);
1630
+ }
1631
+ const runtimeConfig = getRuntimeConfig(config);
1632
+ let nextStrategy: NonNullable<ReturnType<typeof parseAccountPoolStrategy>> | undefined;
1633
+ let nextSticky: NonNullable<ReturnType<typeof parseAccountPoolStickyLimit>> | undefined;
1634
+ if (body.strategy !== undefined) {
1635
+ const parsed = parseAccountPoolStrategy(body.strategy);
1636
+ if (parsed === null) {
1637
+ return jsonResponse({ error: 'strategy must be one of: quota, round-robin, fill-first' }, 400);
1638
+ }
1639
+ nextStrategy = parsed;
1640
+ }
1641
+ if (body.stickyLimit !== undefined) {
1642
+ const parsed = parseAccountPoolStickyLimit(body.stickyLimit);
1643
+ if (parsed === null) {
1644
+ return jsonResponse({ error: "stickyLimit must be an integer 1-100" }, 400);
1645
+ }
1646
+ nextSticky = parsed;
1647
+ }
1648
+ if (nextStrategy !== undefined) runtimeConfig.accountPoolStrategy = nextStrategy;
1649
+ if (nextSticky !== undefined) runtimeConfig.accountPoolStickyLimit = nextSticky;
1650
+ saveRuntimeConfig(config, runtimeConfig);
1651
+ return jsonResponse({
1652
+ ok: true,
1653
+ accountPoolStrategy: normalizeAccountPoolStrategy(runtimeConfig.accountPoolStrategy),
1654
+ accountPoolStickyLimit: normalizeAccountPoolStickyLimit(runtimeConfig.accountPoolStickyLimit),
1655
+ });
1656
+ }
1657
+
1658
+ if (url.pathname === "/api/codex-auth/failover" && req.method === "PUT") {
1659
+ let body: { threshold: number };
1660
+ try { body = (await req.json()) as typeof body; } catch { return jsonResponse({ error: "Invalid JSON" }, 400); }
1661
+ if (typeof body.threshold !== "number" || !Number.isInteger(body.threshold) || body.threshold < 0 || body.threshold > 20) {
1662
+ return jsonResponse({ error: "Threshold must be an integer 0-20" }, 400);
1663
+ }
1664
+ const runtimeConfig = getRuntimeConfig(config);
1665
+ runtimeConfig.upstreamFailoverThreshold = body.threshold;
1666
+ saveRuntimeConfig(config, runtimeConfig);
1667
+ return jsonResponse({ ok: true });
1668
+ }
1669
+
1670
+ if (url.pathname === "/api/codex-auth/quota" && req.method === "GET") {
1671
+ const quotas: Record<string, unknown> = {};
1672
+ for (const [id, q] of listAccountQuotas()) quotas[id] = q;
1673
+ return jsonResponse({ quotas });
1674
+ }
1675
+
1676
+ if (url.pathname === "/api/codex-auth/reset-credits" && req.method === "GET") {
1677
+ const accountId = url.searchParams.get("accountId");
1678
+ if (!accountId) return jsonResponse({ error: "accountId required" }, 400);
1679
+
1680
+ try {
1681
+ const result = await withResetCreditAuth(getRuntimeConfig(config), accountId, async auth => {
1682
+ const resp = await fetch(
1683
+ "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits",
1684
+ {
1685
+ headers: {
1686
+ Authorization: `Bearer ${auth.accessToken}`,
1687
+ "ChatGPT-Account-Id": auth.chatgptAccountId,
1688
+ },
1689
+ signal: AbortSignal.timeout(8000),
1690
+ },
1691
+ );
1692
+ if (!resp.ok) {
1693
+ await resp.body?.cancel().catch(() => {});
1694
+ return jsonResponse({ error: `Upstream error ${resp.status}` }, resp.status);
1695
+ }
1696
+ return jsonResponse(safeResetCreditsDto(await resp.json()));
1697
+ });
1698
+ return result.ok ? result.value : result.response;
1699
+ } catch (e) {
1700
+ return jsonResponse({ error: e instanceof Error ? e.message : "Reset credit lookup failed" }, 500);
1701
+ }
1702
+ }
1703
+
1704
+ if (url.pathname === "/api/codex-auth/reset-credits/consume" && req.method === "POST") {
1705
+ const body = (await req.json().catch(() => ({}))) as { accountId?: string };
1706
+ if (!body.accountId) return jsonResponse({ error: "accountId required" }, 400);
1707
+ const accountId = body.accountId;
1708
+
1709
+ try {
1710
+ const operation = await withResetCreditAuth(getRuntimeConfig(config), accountId, async auth => {
1711
+ const idempotencyKey = crypto.randomUUID();
1712
+ const resp = await fetch(
1713
+ "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume",
1714
+ {
1715
+ method: "POST",
1716
+ headers: {
1717
+ Authorization: `Bearer ${auth.accessToken}`,
1718
+ "ChatGPT-Account-Id": auth.chatgptAccountId,
1719
+ "Content-Type": "application/json",
1720
+ },
1721
+ body: JSON.stringify({ redeem_request_id: idempotencyKey }),
1722
+ signal: AbortSignal.timeout(10_000),
1723
+ },
1724
+ );
1725
+ if (!resp.ok) {
1726
+ await resp.body?.cancel().catch(() => {});
1727
+ return jsonResponse({ error: `Upstream error ${resp.status}` }, resp.status);
1728
+ }
1729
+ const result = safeResetCreditConsumeDto(await resp.json());
1730
+ // After a successful redeem (or an idempotent already_redeemed), refresh WHAM usage
1731
+ // and return remaining only when that refresh freshly parsed available_count.
1732
+ // Do not fall back to a preserved cached resetCredits (failed/omitted refresh).
1733
+ if (result.code === "reset" || result.code === "already_redeemed") {
1734
+ let freshResetCredits: number | undefined;
1735
+ if (auth.isMain) {
1736
+ ({ freshResetCredits } = await fetchMainAccountInfoAttempt(
1737
+ true,
1738
+ 1,
1739
+ auth.nativeMainLease,
1740
+ auth.nativeMainSharedClaimHeld === true,
1741
+ ));
1742
+ } else {
1743
+ const account = configuredPoolAccount(getRuntimeConfig(config), accountId);
1744
+ ({ freshResetCredits } = await fetchPoolAccountQuota(accountId, true, account?.plan));
1745
+ }
1746
+ return jsonResponse({
1747
+ code: result.code,
1748
+ ...(typeof freshResetCredits === "number" && Number.isFinite(freshResetCredits)
1749
+ ? { remaining: freshResetCredits }
1750
+ : {}),
1751
+ });
1752
+ }
1753
+ return jsonResponse(result);
1754
+ });
1755
+ return operation.ok ? operation.value : operation.response;
1756
+ } catch (e) {
1757
+ if (e instanceof PoolQuotaProbeBusyError) {
1758
+ const response = jsonResponse({ error: "server_busy", code: "server_busy" }, 503);
1759
+ response.headers.set("Retry-After", "1");
1760
+ return response;
1761
+ }
1762
+ return jsonResponse({ error: e instanceof Error ? e.message : "Reset credit consume failed" }, 500);
1763
+ }
1764
+ }
1765
+
1766
+ if (url.pathname === "/api/codex-auth/login" && req.method === "POST") {
1767
+ const body = (await req.json().catch(() => ({}))) as { id?: string; reauth?: boolean };
1768
+ const requestedAccountId = body.id?.trim();
1769
+ const reauth = body.reauth === true;
1770
+ if (requestedAccountId && !isValidCodexAccountId(requestedAccountId)) {
1771
+ return jsonResponse({ error: "Invalid account id format" }, 400);
1772
+ }
1773
+ const accountId = requestedAccountId || `chatgpt-${Date.now()}`;
1774
+ const runtimeConfig = getRuntimeConfig(config);
1775
+ const preflightConflict = !reauth
1776
+ ? codexAccountPersistenceConflict(runtimeConfig, accountId, "create")
1777
+ : undefined;
1778
+ if (preflightConflict) return jsonResponse({ error: preflightConflict }, 400);
1779
+ if (reauth) {
1780
+ if (!requestedAccountId) return jsonResponse({ error: "id required for reauth" }, 400);
1781
+ if (!configuredPoolAccount(runtimeConfig, accountId)) {
1782
+ return jsonResponse({ error: "Unknown pool account for reauth" }, 404);
1783
+ }
1784
+ }
1785
+ pruneCodexLoginState();
1786
+ if (codexAuthLoginState.size >= MAX_CODEX_LOGIN_STATE_ROWS) {
1787
+ const busy = new CodexLoginStateBusyError();
1788
+ const response = jsonResponse({ error: busy.message, code: busy.code }, 503);
1789
+ response.headers.set("Retry-After", "1");
1790
+ return response;
1791
+ }
1792
+ const flowId = `flow-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1793
+ const loginOwner: CodexLoginStateRow = { status: "starting", startedAt: Date.now() };
1794
+ codexAuthLoginState.set(flowId, loginOwner);
1795
+ try {
1796
+ const { startLoginFlow, getLoginStatus } = await import("../oauth");
1797
+ const result = await startLoginFlow("chatgpt", { forceLogin: true });
1798
+
1799
+ // Open the browser server-side (same pattern as /api/oauth/login in management-api.ts).
1800
+ // The GUI's window.open is popup-blocked because it runs after an await, not a direct click.
1801
+ if (result.url) {
1802
+ const { openUrl } = await import("../lib/open-url");
1803
+ openUrl(result.url);
1804
+ }
1805
+
1806
+ (async () => {
1807
+ try {
1808
+ let completed = false;
1809
+ for (let i = 0; i < 150; i++) {
1810
+ await new Promise(r => setTimeout(r, 2000));
1811
+ const st = getLoginStatus("chatgpt");
1812
+ if (st.done && st.loggedIn) {
1813
+ const { getCredential } = await import("../oauth/store");
1814
+ const cred = getCredential("chatgpt");
1815
+ if (cred) {
1816
+ const oauthAccountId = cred.accountId;
1817
+ if (!oauthAccountId) {
1818
+ setCodexLoginState(flowId, {
1819
+ status: "error",
1820
+ error: "Could not determine account identity from OAuth tokens. Please retry OAuth login.",
1821
+ doneAt: Date.now(),
1822
+ });
1823
+ completed = true;
1824
+ break;
1825
+ }
1826
+
1827
+ let email = cred.email || accountId;
1828
+ let plan: string | undefined;
1829
+ let quota: Omit<StoredAccountQuota, "updatedAt"> | null = null;
1830
+ try {
1831
+ const tokens = { access_token: cred.access, account_id: oauthAccountId };
1832
+ const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", {
1833
+ headers: { Authorization: `Bearer ${tokens.access_token}`, "ChatGPT-Account-Id": tokens.account_id },
1834
+ signal: AbortSignal.timeout(8000),
1835
+ });
1836
+ if (resp.ok) {
1837
+ const data = (await resp.json()) as WhamUsageResponse;
1838
+ email = data.email ?? email;
1839
+ plan = nonEmptyPlan(data.plan_type) ?? undefined;
1840
+ quota = parseUsageQuota(data);
1841
+ }
1842
+ } catch { /* wham fetch is non-blocking */ }
1843
+ // Reauth must refresh the same ChatGPT identity already bound to this pool slot.
1844
+ // Otherwise a different login would silently overwrite credentials under a trusted id.
1845
+ if (reauth) {
1846
+ const existingCred = getCodexAccountCredential(accountId);
1847
+ const poolAccount = configuredPoolAccount(getRuntimeConfig(config), accountId);
1848
+ const expectedChatgptId = existingCred?.chatgptAccountId?.trim();
1849
+ const expectedEmail = poolAccount?.email?.trim().toLowerCase();
1850
+ const gotEmail = email.trim().toLowerCase();
1851
+ if (expectedChatgptId) {
1852
+ if (expectedChatgptId !== oauthAccountId) {
1853
+ setCodexLoginState(flowId, {
1854
+ status: "error",
1855
+ error: "Signed-in ChatGPT account does not match this pool account. Sign in with the same account, or remove it and add a new one.",
1856
+ doneAt: Date.now(),
1857
+ });
1858
+ completed = true;
1859
+ break;
1860
+ }
1861
+ } else if (expectedEmail) {
1862
+ if (!gotEmail || gotEmail !== expectedEmail) {
1863
+ setCodexLoginState(flowId, {
1864
+ status: "error",
1865
+ error: "Signed-in ChatGPT account does not match this pool account. Sign in with the same account, or remove it and add a new one.",
1866
+ doneAt: Date.now(),
1867
+ });
1868
+ completed = true;
1869
+ break;
1870
+ }
1871
+ } else {
1872
+ // No chatgptAccountId and no pool email — refuse silent identity replacement
1873
+ // (including empty credential slots that still have a pool row).
1874
+ setCodexLoginState(flowId, {
1875
+ status: "error",
1876
+ error: "Cannot verify account identity for reauth. Remove this account and add it again.",
1877
+ doneAt: Date.now(),
1878
+ });
1879
+ completed = true;
1880
+ break;
1881
+ }
1882
+ }
1883
+
1884
+ // 1.2: Duplicate check is scoped by personal vs workspace plan bucket.
1885
+ const collision = checkAccountIdCollision(oauthAccountId, email, plan, reauth ? accountId : undefined);
1886
+ if (collision.collision) {
1887
+ setCodexLoginState(flowId, {
1888
+ status: "error", error: collision.reason, doneAt: Date.now(),
1889
+ });
1890
+ completed = true;
1891
+ break;
1892
+ }
1893
+
1894
+ const warmup = await verifyCodexAccountWarmup(accountId, cred.access, oauthAccountId);
1895
+ if (!warmup.ok) {
1896
+ const body = await warmup.response.json().catch(() => ({})) as { error?: string; reason?: string };
1897
+ setCodexLoginState(flowId, {
1898
+ status: "error",
1899
+ error: body.reason ? `${body.error ?? "Codex account warmup failed"} (${body.reason})` : body.error ?? "Codex account warmup failed",
1900
+ doneAt: Date.now(),
1901
+ });
1902
+ completed = true;
1903
+ break;
1904
+ }
1905
+
1906
+ const latestConfig = getRuntimeConfig(config);
1907
+ const accounts = latestConfig.codexAccounts ?? [];
1908
+ const existingIdx = accounts.findIndex(account => account.id === accountId);
1909
+ let pickerVisibilityChanged = false;
1910
+ let newAccountPersistence: PersistNewCodexAccountOutcome | null = null;
1911
+ const commitConflict = codexAccountPersistenceConflict(
1912
+ latestConfig,
1913
+ accountId,
1914
+ reauth ? "reauth" : "create",
1915
+ );
1916
+ if (commitConflict) {
1917
+ setCodexLoginState(flowId, {
1918
+ status: "error",
1919
+ error: commitConflict,
1920
+ doneAt: Date.now(),
1921
+ });
1922
+ completed = true;
1923
+ break;
1924
+ }
1925
+
1926
+ const credential: CodexAccountCredentials = {
1927
+ accessToken: cred.access,
1928
+ refreshToken: cred.refresh,
1929
+ expiresAt: cred.expires,
1930
+ chatgptAccountId: oauthAccountId,
1931
+ };
1932
+
1933
+ if (existingIdx >= 0) {
1934
+ saveCodexAccountCredential(accountId, credential);
1935
+ // A successful reauthentication replaces the credential generation. Do not let a
1936
+ // failed optional WHAM probe make the replacement inherit quota from the old record.
1937
+ if (reauth) clearAccountQuota(accountId);
1938
+ markCodexAccountValidated(accountId, warmup.validatedAt);
1939
+ clearAccountNeedsReauth(accountId);
1940
+ if (quota) setAccountQuotaFromParsed(accountId, quota);
1941
+ // Keep the pool id stable; refresh display metadata after a successful login/reauth.
1942
+ accounts[existingIdx] = withCodexAccountLogLabel({
1943
+ ...accounts[existingIdx],
1944
+ email,
1945
+ plan: plan ?? accounts[existingIdx].plan,
1946
+ isMain: false,
1947
+ }, accounts);
1948
+ latestConfig.codexAccounts = accounts;
1949
+ saveRuntimeConfig(config, latestConfig);
1950
+ } else {
1951
+ const addedAccount = withCodexAccountLogLabel({ id: accountId, email, plan, isMain: false }, accounts);
1952
+ newAccountPersistence = persistNewCodexAccount(
1953
+ config,
1954
+ latestConfig,
1955
+ addedAccount,
1956
+ {
1957
+ credential,
1958
+ validatedAt: warmup.validatedAt,
1959
+ },
1960
+ );
1961
+ pickerVisibilityChanged = newAccountPersistence.pickerVisibilityChanged;
1962
+ }
1963
+ reconcileLiveStateStores();
1964
+ if (newAccountPersistence?.status === "publication-failed") {
1965
+ markAccountNeedsReauth(accountId);
1966
+ }
1967
+ // A new quota row is generation-gated by live account ownership. Reconcile the
1968
+ // durable config owner first so a partial prior sweep cannot reject this write.
1969
+ if (newAccountPersistence?.status === "committed" && quota) {
1970
+ setAccountQuotaFromParsed(accountId, quota);
1971
+ }
1972
+ const { catalogRefreshPending } = await convergeAccountNamespaceCatalog(
1973
+ latestConfig,
1974
+ pickerVisibilityChanged,
1975
+ convergeCodexCatalog,
1976
+ );
1977
+ if (newAccountPersistence?.status === "publication-failed") {
1978
+ setCodexLoginState(flowId, {
1979
+ status: "error",
1980
+ ...codexCredentialPersistenceFailure(accountId, catalogRefreshPending),
1981
+ doneAt: Date.now(),
1982
+ });
1983
+ completed = true;
1984
+ } else {
1985
+ setCodexLoginState(flowId, {
1986
+ status: "done",
1987
+ accountId,
1988
+ email,
1989
+ ...(catalogRefreshPending ? { catalogRefreshPending: true } : {}),
1990
+ doneAt: Date.now(),
1991
+ });
1992
+ completed = true;
1993
+ }
1994
+ }
1995
+ break;
1996
+ }
1997
+ if (st.done && st.error) {
1998
+ setCodexLoginState(flowId, { status: "error", error: st.error, doneAt: Date.now() });
1999
+ completed = true;
2000
+ break;
2001
+ }
2002
+ }
2003
+ if (!completed) {
2004
+ setCodexLoginState(flowId, {
2005
+ status: "error",
2006
+ error: "Login timed out before OAuth completed.",
2007
+ doneAt: Date.now(),
2008
+ });
2009
+ }
2010
+ } catch (error) {
2011
+ const message = error instanceof ConfigMutationLockError
2012
+ || error instanceof CodexCredentialRefreshLockTimeoutError
2013
+ ? "Configuration is busy; retry login shortly."
2014
+ : error instanceof CodexCredentialRefreshBusyError || error instanceof CodexCredentialRefreshStaleError
2015
+ ? "Credential refresh is busy; retry login shortly."
2016
+ : error instanceof Error ? error.message : String(error);
2017
+ setCodexLoginState(flowId, {
2018
+ status: "error",
2019
+ error: message,
2020
+ doneAt: Date.now(),
2021
+ });
2022
+ } finally {
2023
+ // TTL: keep completed flow state available for clients that miss a short polling window.
2024
+ setTimeout(() => { if (codexAuthLoginState.get(flowId) === loginOwner) codexAuthLoginState.delete(flowId); }, CODEX_LOGIN_TERMINAL_TTL_MS);
2025
+ }
2026
+ })();
2027
+
2028
+ setCodexLoginState(flowId, { status: "pending" });
2029
+ return jsonResponse({ ok: true, flowId, url: result.url, instructions: result.instructions });
2030
+ } catch (e) {
2031
+ if (codexAuthLoginState.get(flowId) === loginOwner) codexAuthLoginState.delete(flowId);
2032
+ const msg = e instanceof Error ? e.message : String(e);
2033
+ if (msg.includes("already in progress")) {
2034
+ return jsonResponse({ error: msg, status: "pending" }, 409);
2035
+ }
2036
+ if (e instanceof CodexCredentialRefreshBusyError || e instanceof CodexCredentialRefreshStaleError) {
2037
+ const response = jsonResponse({ error: "server_busy", code: "server_busy" }, 503);
2038
+ response.headers.set("Retry-After", "1");
2039
+ return response;
2040
+ }
2041
+ return jsonResponse({ error: msg }, 500);
2042
+ }
2043
+ }
2044
+
2045
+ if (url.pathname === "/api/codex-auth/login/code" && req.method === "POST") {
2046
+ const body = (await req.json().catch(() => ({}))) as { flowId?: unknown; input?: unknown };
2047
+ const flowId = typeof body.flowId === "string" ? body.flowId.trim() : "";
2048
+ const input = typeof body.input === "string" ? body.input : "";
2049
+ if (!flowId) return jsonResponse({ error: "flowId required" }, 400);
2050
+ if (input.length > 4096) return jsonResponse({ error: "input too long" }, 400);
2051
+
2052
+ // Import may yield; validate afterwards so cancel/replace cannot race a stale flow through.
2053
+ const { submitManualLoginCode } = await import("../oauth");
2054
+ const flow = codexAuthLoginState.get(flowId);
2055
+ if (!flow) return jsonResponse({ error: "login flow expired or unknown" }, 400);
2056
+ if (flow.status !== "pending") return jsonResponse({ error: "login flow is not pending" }, 400);
2057
+
2058
+ const result = submitManualLoginCode("chatgpt", input);
2059
+ if (!result.ok) return jsonResponse({ error: result.error }, 400);
2060
+ return jsonResponse({ ok: true }, 202);
2061
+ }
2062
+
2063
+ if (url.pathname === "/api/codex-auth/login/cancel" && req.method === "POST") {
2064
+ const body = (await req.json().catch(() => ({}))) as { flowId?: string };
2065
+ const { cancelLoginFlow } = await import("../oauth");
2066
+ const cancelled = cancelLoginFlow("chatgpt");
2067
+ expireCodexAuthFlow(body.flowId ?? null);
2068
+ return jsonResponse({ ok: true, cancelled });
2069
+ }
2070
+
2071
+ if (url.pathname === "/api/codex-auth/login-status" && req.method === "GET") {
2072
+ const flowId = url.searchParams.get("flowId");
2073
+ const accountId = url.searchParams.get("accountId")?.trim();
2074
+ // Reauth always has a pre-existing credential; never treat "credential exists" as success
2075
+ // when the flow map entry is gone (would false-complete on lost/expired flow state).
2076
+ const reauthStatus = url.searchParams.get("reauth") === "1";
2077
+ if (flowId) {
2078
+ const st = codexAuthLoginState.get(flowId);
2079
+ if (
2080
+ !st
2081
+ && accountId
2082
+ && !reauthStatus
2083
+ && !isAccountNeedsReauth(accountId)
2084
+ && getCodexAccountCredential(accountId)
2085
+ ) {
2086
+ return jsonResponse({ status: "done", accountId });
2087
+ }
2088
+ return jsonResponse(st ? { ...st, email: maskEmail(st.email) ?? undefined } : { status: "expired" });
2089
+ }
2090
+ // Legacy fallback: return latest pending flow
2091
+ for (const [, st] of codexAuthLoginState) {
2092
+ if (st.status === "pending") return jsonResponse({ ...st, email: maskEmail(st.email) ?? undefined });
2093
+ }
2094
+ return jsonResponse({ status: "idle" });
2095
+ }
2096
+
2097
+ return null;
2098
+ }