@iislee/opencodex 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (476) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +225 -0
  3. package/assets/architecture.png +0 -0
  4. package/assets/banner.png +0 -0
  5. package/assets/claude-code-models.gif +0 -0
  6. package/assets/codex-app-picker.png +0 -0
  7. package/bin/ocx.mjs +451 -0
  8. package/bin/package-main.mjs +9 -0
  9. package/gui/dist/assets/index-DTpMHS4F.js +67 -0
  10. package/gui/dist/assets/index-ZNVDE3C7.css +1 -0
  11. package/gui/dist/favicon.png +0 -0
  12. package/gui/dist/icons.svg +24 -0
  13. package/gui/dist/index.html +25 -0
  14. package/gui/dist/logo.png +0 -0
  15. package/gui/dist/provider-icons/alibaba-color.svg +1 -0
  16. package/gui/dist/provider-icons/antigravity-color.svg +1 -0
  17. package/gui/dist/provider-icons/antigravity.svg +1 -0
  18. package/gui/dist/provider-icons/claude-color.svg +1 -0
  19. package/gui/dist/provider-icons/claude.svg +1 -0
  20. package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -0
  21. package/gui/dist/provider-icons/copilot-color.svg +1 -0
  22. package/gui/dist/provider-icons/copilot.svg +1 -0
  23. package/gui/dist/provider-icons/cursor-color.svg +2 -0
  24. package/gui/dist/provider-icons/cursor.svg +2 -0
  25. package/gui/dist/provider-icons/deepseek-color.svg +1 -0
  26. package/gui/dist/provider-icons/discord.svg +1 -0
  27. package/gui/dist/provider-icons/firepass-color.svg +1 -0
  28. package/gui/dist/provider-icons/fireworks-color.svg +1 -0
  29. package/gui/dist/provider-icons/gemini-color.svg +1 -0
  30. package/gui/dist/provider-icons/gemini.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-color.svg +1 -0
  34. package/gui/dist/provider-icons/grok.svg +1 -0
  35. package/gui/dist/provider-icons/groq-color.svg +1 -0
  36. package/gui/dist/provider-icons/huggingface-color.svg +1 -0
  37. package/gui/dist/provider-icons/kimi-color.svg +1 -0
  38. package/gui/dist/provider-icons/kiro-color.svg +15 -0
  39. package/gui/dist/provider-icons/kiro.svg +14 -0
  40. package/gui/dist/provider-icons/lm-studio-color.svg +1 -0
  41. package/gui/dist/provider-icons/mistral-color.svg +1 -0
  42. package/gui/dist/provider-icons/moonshot-color.svg +1 -0
  43. package/gui/dist/provider-icons/nvidia-color.svg +1 -0
  44. package/gui/dist/provider-icons/ollama-color.svg +1 -0
  45. package/gui/dist/provider-icons/openai.svg +1 -0
  46. package/gui/dist/provider-icons/opencode.svg +1 -0
  47. package/gui/dist/provider-icons/openrouter-color.svg +1 -0
  48. package/gui/dist/provider-icons/pi.svg +21 -0
  49. package/gui/dist/provider-icons/qianfan-color.svg +1 -0
  50. package/gui/dist/provider-icons/qwen-portal-color.svg +1 -0
  51. package/gui/dist/provider-icons/telegram.svg +1 -0
  52. package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -0
  53. package/gui/dist/provider-icons/vllm-color.svg +1 -0
  54. package/gui/dist/provider-icons/xiaomi-color.svg +1 -0
  55. package/package.json +102 -0
  56. package/src/AGENTS.md +28 -0
  57. package/src/adapters/anthropic-image-guard.ts +251 -0
  58. package/src/adapters/anthropic-image-normalize.ts +518 -0
  59. package/src/adapters/anthropic.ts +1003 -0
  60. package/src/adapters/azure.ts +36 -0
  61. package/src/adapters/base.ts +72 -0
  62. package/src/adapters/client-fingerprint.ts +59 -0
  63. package/src/adapters/cursor/arg-codec.ts +38 -0
  64. package/src/adapters/cursor/arg-normalize.ts +104 -0
  65. package/src/adapters/cursor/cursor-errors.ts +165 -0
  66. package/src/adapters/cursor/discovery.ts +276 -0
  67. package/src/adapters/cursor/effort-map.ts +127 -0
  68. package/src/adapters/cursor/exec-policy.ts +88 -0
  69. package/src/adapters/cursor/framing.ts +211 -0
  70. package/src/adapters/cursor/gen/agent_pb.ts +15274 -0
  71. package/src/adapters/cursor/kv-store.ts +52 -0
  72. package/src/adapters/cursor/live-models.ts +153 -0
  73. package/src/adapters/cursor/live-smoke-gate.ts +41 -0
  74. package/src/adapters/cursor/live-transport.ts +1214 -0
  75. package/src/adapters/cursor/mcp-config.ts +42 -0
  76. package/src/adapters/cursor/mcp-manager.ts +333 -0
  77. package/src/adapters/cursor/message-mapper.ts +49 -0
  78. package/src/adapters/cursor/native-exec-common.ts +55 -0
  79. package/src/adapters/cursor/native-exec-desktop.ts +184 -0
  80. package/src/adapters/cursor/native-exec-fs.ts +329 -0
  81. package/src/adapters/cursor/native-exec-mcp.ts +153 -0
  82. package/src/adapters/cursor/native-exec-network.ts +43 -0
  83. package/src/adapters/cursor/native-exec-shell.ts +548 -0
  84. package/src/adapters/cursor/native-exec-tools.ts +118 -0
  85. package/src/adapters/cursor/native-exec.ts +576 -0
  86. package/src/adapters/cursor/protobuf-events.ts +563 -0
  87. package/src/adapters/cursor/protobuf-request.ts +714 -0
  88. package/src/adapters/cursor/request-builder.ts +255 -0
  89. package/src/adapters/cursor/thread-continuity.ts +67 -0
  90. package/src/adapters/cursor/tool-definitions.ts +505 -0
  91. package/src/adapters/cursor/transport-retry.ts +132 -0
  92. package/src/adapters/cursor/transport.ts +57 -0
  93. package/src/adapters/cursor/types.ts +52 -0
  94. package/src/adapters/cursor.ts +196 -0
  95. package/src/adapters/google-antigravity-replay.ts +303 -0
  96. package/src/adapters/google-antigravity-wire.ts +108 -0
  97. package/src/adapters/google-errors.ts +85 -0
  98. package/src/adapters/google-http.ts +100 -0
  99. package/src/adapters/google-tool-schema.ts +173 -0
  100. package/src/adapters/google-truncation.ts +13 -0
  101. package/src/adapters/google-wire-compiler.ts +232 -0
  102. package/src/adapters/google.ts +758 -0
  103. package/src/adapters/identity.ts +44 -0
  104. package/src/adapters/image.ts +23 -0
  105. package/src/adapters/kiro-constants.ts +16 -0
  106. package/src/adapters/kiro-errors.ts +197 -0
  107. package/src/adapters/kiro-events.ts +179 -0
  108. package/src/adapters/kiro-images.ts +129 -0
  109. package/src/adapters/kiro-retry.ts +312 -0
  110. package/src/adapters/kiro-thinking.ts +96 -0
  111. package/src/adapters/kiro-tool-fallback.ts +36 -0
  112. package/src/adapters/kiro-tools.ts +215 -0
  113. package/src/adapters/kiro-truncation.ts +33 -0
  114. package/src/adapters/kiro-wire.ts +129 -0
  115. package/src/adapters/kiro.ts +1898 -0
  116. package/src/adapters/mimo-free.ts +263 -0
  117. package/src/adapters/openai-chat.ts +1005 -0
  118. package/src/adapters/openai-responses.ts +1137 -0
  119. package/src/adapters/run-turn-queue.ts +114 -0
  120. package/src/adapters/tool-catalog-nudge.ts +71 -0
  121. package/src/adapters/upstream-http-error.ts +48 -0
  122. package/src/bridge.ts +1619 -0
  123. package/src/chat/inbound.ts +295 -0
  124. package/src/chat/outbound.ts +765 -0
  125. package/src/claude/agents-inject.ts +243 -0
  126. package/src/claude/alias.ts +149 -0
  127. package/src/claude/auth-detect.ts +229 -0
  128. package/src/claude/auth-mode-migration.ts +32 -0
  129. package/src/claude/auth-mode.ts +62 -0
  130. package/src/claude/context-windows.ts +189 -0
  131. package/src/claude/desktop-3p-guard.ts +35 -0
  132. package/src/claude/desktop-3p-paths.ts +84 -0
  133. package/src/claude/desktop-3p.ts +381 -0
  134. package/src/claude/desktop-health.ts +26 -0
  135. package/src/claude/desktop-profile.ts +263 -0
  136. package/src/claude/gateway-cache.ts +70 -0
  137. package/src/claude/inbound-debug.ts +163 -0
  138. package/src/claude/inbound.ts +509 -0
  139. package/src/claude/model-info.ts +151 -0
  140. package/src/claude/outbound.ts +872 -0
  141. package/src/cli/access.ts +108 -0
  142. package/src/cli/account-api.ts +268 -0
  143. package/src/cli/account-auth.ts +223 -0
  144. package/src/cli/account-extended.ts +350 -0
  145. package/src/cli/account.ts +275 -0
  146. package/src/cli/agent-driven.ts +70 -0
  147. package/src/cli/agent.ts +184 -0
  148. package/src/cli/catalog-prewarm.ts +27 -0
  149. package/src/cli/claude-desktop.ts +188 -0
  150. package/src/cli/claude.ts +286 -0
  151. package/src/cli/codex-shim-autorestore.ts +45 -0
  152. package/src/cli/combo.ts +119 -0
  153. package/src/cli/config-command.ts +145 -0
  154. package/src/cli/debug.ts +228 -0
  155. package/src/cli/doctor.ts +930 -0
  156. package/src/cli/export-command.ts +187 -0
  157. package/src/cli/help.ts +354 -0
  158. package/src/cli/index.ts +1113 -0
  159. package/src/cli/init.ts +224 -0
  160. package/src/cli/integrations.ts +142 -0
  161. package/src/cli/interactive-confirm.ts +133 -0
  162. package/src/cli/internal-dispatch.ts +20 -0
  163. package/src/cli/models-runtime.ts +212 -0
  164. package/src/cli/models.ts +336 -0
  165. package/src/cli/observe.ts +117 -0
  166. package/src/cli/opencode.ts +586 -0
  167. package/src/cli/pi.ts +188 -0
  168. package/src/cli/provider-runtime.ts +162 -0
  169. package/src/cli/provider.ts +463 -0
  170. package/src/cli/runtime-api.ts +325 -0
  171. package/src/cli/star-prompt.ts +155 -0
  172. package/src/cli/status-oauth.ts +78 -0
  173. package/src/cli/status.ts +321 -0
  174. package/src/cli/sync-cloud.ts +283 -0
  175. package/src/cli/system-command.ts +112 -0
  176. package/src/cli/tray-proxy.ts +52 -0
  177. package/src/cli/v2.ts +173 -0
  178. package/src/cli.ts +10 -0
  179. package/src/clients/config-export.ts +377 -0
  180. package/src/clients/effective-status.ts +385 -0
  181. package/src/clients/probes/agy.ts +55 -0
  182. package/src/clients/probes/cc-switch.ts +110 -0
  183. package/src/clients/probes/claude.ts +90 -0
  184. package/src/clients/probes/codex.ts +125 -0
  185. package/src/clients/probes/grok.ts +29 -0
  186. package/src/clients/probes/opencode.ts +109 -0
  187. package/src/clients/probes/paseo.ts +55 -0
  188. package/src/clients/probes/pi.ts +55 -0
  189. package/src/cloud/onedrive-auth.ts +666 -0
  190. package/src/cloud/onedrive-graph.ts +108 -0
  191. package/src/cloud/settings.ts +75 -0
  192. package/src/cloud/sync.ts +212 -0
  193. package/src/cloud/types.ts +56 -0
  194. package/src/cloud/vault.ts +89 -0
  195. package/src/codex/account-id.ts +34 -0
  196. package/src/codex/account-label.ts +34 -0
  197. package/src/codex/account-lifecycle.ts +55 -0
  198. package/src/codex/account-namespace-match.ts +63 -0
  199. package/src/codex/account-namespaces.ts +149 -0
  200. package/src/codex/account-pause.ts +20 -0
  201. package/src/codex/account-runtime-state.ts +31 -0
  202. package/src/codex/account-store.ts +517 -0
  203. package/src/codex/account-usability.ts +20 -0
  204. package/src/codex/app-server-processes.ts +756 -0
  205. package/src/codex/auth-api.ts +1540 -0
  206. package/src/codex/auth-collision.ts +107 -0
  207. package/src/codex/auth-context.ts +352 -0
  208. package/src/codex/autostart-health.ts +149 -0
  209. package/src/codex/catalog/aggregation.ts +378 -0
  210. package/src/codex/catalog/bundled.ts +251 -0
  211. package/src/codex/catalog/effort.ts +355 -0
  212. package/src/codex/catalog/metadata.ts +180 -0
  213. package/src/codex/catalog/parsing.ts +456 -0
  214. package/src/codex/catalog/provider-fetch.ts +902 -0
  215. package/src/codex/catalog/sync.ts +620 -0
  216. package/src/codex/catalog.ts +12 -0
  217. package/src/codex/data/upstream-models.json +830 -0
  218. package/src/codex/exec-invocation.ts +22 -0
  219. package/src/codex/features.ts +969 -0
  220. package/src/codex/history-migration-guardian.ts +102 -0
  221. package/src/codex/history-provider.ts +776 -0
  222. package/src/codex/home.ts +206 -0
  223. package/src/codex/inject.ts +799 -0
  224. package/src/codex/injected-marker.ts +72 -0
  225. package/src/codex/journal.ts +163 -0
  226. package/src/codex/main-account-cache.ts +32 -0
  227. package/src/codex/main-account.ts +40 -0
  228. package/src/codex/model-cache.ts +227 -0
  229. package/src/codex/paths.ts +65 -0
  230. package/src/codex/plugins-doctor.ts +242 -0
  231. package/src/codex/pool-rotation.ts +225 -0
  232. package/src/codex/project-config-warnings.ts +411 -0
  233. package/src/codex/quota.ts +411 -0
  234. package/src/codex/refresh.ts +53 -0
  235. package/src/codex/routing.ts +1477 -0
  236. package/src/codex/runtime.ts +538 -0
  237. package/src/codex/shim.ts +1189 -0
  238. package/src/codex/subagent-defaults.ts +550 -0
  239. package/src/codex/subagent-model-fallback.ts +469 -0
  240. package/src/codex/sync.ts +130 -0
  241. package/src/codex/warmup.ts +192 -0
  242. package/src/codex/websocket-registry.ts +100 -0
  243. package/src/combos/failover.ts +140 -0
  244. package/src/combos/index.ts +41 -0
  245. package/src/combos/request.ts +62 -0
  246. package/src/combos/resolve.ts +232 -0
  247. package/src/combos/types.ts +326 -0
  248. package/src/config.ts +2356 -0
  249. package/src/generated/jawcode-model-metadata.ts +104 -0
  250. package/src/github/star-state.ts +203 -0
  251. package/src/grok/inject.ts +545 -0
  252. package/src/grok/status.ts +121 -0
  253. package/src/grok/sync.ts +103 -0
  254. package/src/grok/usage-hook/report.mjs +348 -0
  255. package/src/grok/usage-hook.ts +278 -0
  256. package/src/images/artifacts.ts +516 -0
  257. package/src/images/fulfill-video.ts +163 -0
  258. package/src/images/fulfill.ts +149 -0
  259. package/src/images/index.ts +4 -0
  260. package/src/images/loop.ts +829 -0
  261. package/src/images/plan.ts +133 -0
  262. package/src/images/synthetic-tool.ts +133 -0
  263. package/src/images/types.ts +41 -0
  264. package/src/images/xai-client.ts +141 -0
  265. package/src/images/xai-video-client.ts +163 -0
  266. package/src/index.ts +22 -0
  267. package/src/lib/abort.ts +146 -0
  268. package/src/lib/admin-secrets.ts +25 -0
  269. package/src/lib/admission.ts +83 -0
  270. package/src/lib/app-owned-memory-stores.ts +173 -0
  271. package/src/lib/app-owned-memory.ts +265 -0
  272. package/src/lib/bounded-body.ts +202 -0
  273. package/src/lib/bun-binary-validator.d.mts +3 -0
  274. package/src/lib/bun-binary-validator.mjs +18 -0
  275. package/src/lib/bun-runtime.ts +71 -0
  276. package/src/lib/bun-stream-caps.ts +126 -0
  277. package/src/lib/config-ownership.ts +360 -0
  278. package/src/lib/crash-guard.ts +344 -0
  279. package/src/lib/debug-log-buffer.ts +83 -0
  280. package/src/lib/debug-settings.ts +108 -0
  281. package/src/lib/debug.ts +31 -0
  282. package/src/lib/destination-policy.ts +316 -0
  283. package/src/lib/errors.ts +364 -0
  284. package/src/lib/eventstream-decoder.ts +253 -0
  285. package/src/lib/gcp-adc.ts +341 -0
  286. package/src/lib/injection-debug-log.ts +58 -0
  287. package/src/lib/open-url.ts +25 -0
  288. package/src/lib/pinned-http.ts +151 -0
  289. package/src/lib/privacy.ts +20 -0
  290. package/src/lib/process-control.ts +165 -0
  291. package/src/lib/provider-outbound.ts +170 -0
  292. package/src/lib/provider-url.ts +14 -0
  293. package/src/lib/proxy-env.ts +18 -0
  294. package/src/lib/redact.ts +105 -0
  295. package/src/lib/retry-after.ts +55 -0
  296. package/src/lib/service-secrets.ts +25 -0
  297. package/src/lib/shadow-call.ts +30 -0
  298. package/src/lib/sidecar-tracker.ts +52 -0
  299. package/src/lib/sse-decoder.ts +323 -0
  300. package/src/lib/state-store-registrations.ts +109 -0
  301. package/src/lib/state-store-sweeper.ts +184 -0
  302. package/src/lib/test-home-guard.ts +90 -0
  303. package/src/lib/token-estimate.ts +69 -0
  304. package/src/lib/translator-budget.ts +356 -0
  305. package/src/lib/upstream-retry.ts +239 -0
  306. package/src/lib/win-exec.ts +115 -0
  307. package/src/lib/win-paths.ts +68 -0
  308. package/src/lib/windows-elevation.ts +705 -0
  309. package/src/lib/windows-secret-acl.ts +514 -0
  310. package/src/lib/winsw.ts +375 -0
  311. package/src/oauth/anthropic-routing.ts +594 -0
  312. package/src/oauth/anthropic.ts +177 -0
  313. package/src/oauth/callback-server.ts +294 -0
  314. package/src/oauth/chatgpt.ts +150 -0
  315. package/src/oauth/cursor.ts +211 -0
  316. package/src/oauth/github-copilot.ts +428 -0
  317. package/src/oauth/google-antigravity.ts +230 -0
  318. package/src/oauth/health.ts +399 -0
  319. package/src/oauth/index.ts +1174 -0
  320. package/src/oauth/key-providers.ts +108 -0
  321. package/src/oauth/kimi.ts +213 -0
  322. package/src/oauth/kiro-credentials.ts +726 -0
  323. package/src/oauth/kiro.ts +577 -0
  324. package/src/oauth/local-token-detect.ts +121 -0
  325. package/src/oauth/log.ts +48 -0
  326. package/src/oauth/login-cli.ts +163 -0
  327. package/src/oauth/pkce.ts +15 -0
  328. package/src/oauth/store.ts +630 -0
  329. package/src/oauth/token-guardian.ts +303 -0
  330. package/src/oauth/types.ts +62 -0
  331. package/src/oauth/xai.ts +241 -0
  332. package/src/pi/extensions.ts +72 -0
  333. package/src/pi/home.ts +42 -0
  334. package/src/pi/index.ts +40 -0
  335. package/src/pi/models.ts +278 -0
  336. package/src/pi/packages.ts +219 -0
  337. package/src/pi/settings.ts +365 -0
  338. package/src/pi/status.ts +68 -0
  339. package/src/pi/sync.ts +75 -0
  340. package/src/providers/alibaba-region-backup.ts +75 -0
  341. package/src/providers/alibaba-region-migration.ts +156 -0
  342. package/src/providers/alibaba-region-startup.ts +36 -0
  343. package/src/providers/antigravity-models.ts +205 -0
  344. package/src/providers/api-keys.ts +140 -0
  345. package/src/providers/base-url-choices.ts +64 -0
  346. package/src/providers/context-cap.ts +65 -0
  347. package/src/providers/derive.ts +339 -0
  348. package/src/providers/free-directory.ts +184 -0
  349. package/src/providers/github-copilot-transport.ts +56 -0
  350. package/src/providers/key-failover.ts +203 -0
  351. package/src/providers/kiro-models.ts +67 -0
  352. package/src/providers/label.ts +19 -0
  353. package/src/providers/model-discovery.ts +356 -0
  354. package/src/providers/openai-sidecar.ts +175 -0
  355. package/src/providers/openai-tier-startup.ts +27 -0
  356. package/src/providers/openai-tiers.ts +301 -0
  357. package/src/providers/openai-virtual-models.ts +82 -0
  358. package/src/providers/openrouter-routing.ts +102 -0
  359. package/src/providers/provider-id-rewrite.ts +150 -0
  360. package/src/providers/quota.ts +1260 -0
  361. package/src/providers/registry.ts +1600 -0
  362. package/src/providers/slug-codec.ts +67 -0
  363. package/src/providers/xai-transport.ts +141 -0
  364. package/src/reasoning-effort.ts +135 -0
  365. package/src/responses/compaction.ts +117 -0
  366. package/src/responses/parser.ts +656 -0
  367. package/src/responses/reasoning-envelope.ts +52 -0
  368. package/src/responses/schema.ts +159 -0
  369. package/src/responses/spill-store.ts +394 -0
  370. package/src/responses/state.ts +895 -0
  371. package/src/responses/tool-groups.ts +19 -0
  372. package/src/router.ts +425 -0
  373. package/src/server/adapter-resolve.ts +80 -0
  374. package/src/server/auth-cors.ts +530 -0
  375. package/src/server/chat-completions.ts +368 -0
  376. package/src/server/claude-messages.ts +914 -0
  377. package/src/server/effort-policy.ts +172 -0
  378. package/src/server/gui-static.ts +123 -0
  379. package/src/server/image-retry.ts +42 -0
  380. package/src/server/images.ts +476 -0
  381. package/src/server/index.ts +1126 -0
  382. package/src/server/lifecycle.ts +227 -0
  383. package/src/server/live.ts +598 -0
  384. package/src/server/management/agent-settings-routes.ts +1169 -0
  385. package/src/server/management/api-access.ts +141 -0
  386. package/src/server/management/api-key-usage.ts +167 -0
  387. package/src/server/management/body.ts +35 -0
  388. package/src/server/management/clients-routes.ts +63 -0
  389. package/src/server/management/cloud-sync-routes.ts +266 -0
  390. package/src/server/management/combo-routes.ts +220 -0
  391. package/src/server/management/config-routes.ts +422 -0
  392. package/src/server/management/context.ts +31 -0
  393. package/src/server/management/logs-usage-routes.ts +707 -0
  394. package/src/server/management/model-routes.ts +525 -0
  395. package/src/server/management/oauth-account-routes.ts +563 -0
  396. package/src/server/management/provider-routes.ts +556 -0
  397. package/src/server/management/shared.ts +277 -0
  398. package/src/server/management/sidebar-routes.ts +90 -0
  399. package/src/server/management/system-restart.ts +179 -0
  400. package/src/server/management/system-routes.ts +117 -0
  401. package/src/server/management/usage-summary-cache.ts +86 -0
  402. package/src/server/management-api.ts +215 -0
  403. package/src/server/management-auth.ts +267 -0
  404. package/src/server/memory-watchdog.ts +156 -0
  405. package/src/server/port-reclaim.ts +307 -0
  406. package/src/server/ports.ts +116 -0
  407. package/src/server/proxy-liveness.ts +201 -0
  408. package/src/server/relay-eager.ts +313 -0
  409. package/src/server/relay.ts +1049 -0
  410. package/src/server/request-decompress.ts +132 -0
  411. package/src/server/request-log-conversation.ts +168 -0
  412. package/src/server/request-log.ts +1046 -0
  413. package/src/server/responses/collaboration.ts +354 -0
  414. package/src/server/responses/compact.ts +384 -0
  415. package/src/server/responses/core.ts +2758 -0
  416. package/src/server/responses/encrypted-payload.ts +308 -0
  417. package/src/server/responses/fetch-helpers.ts +157 -0
  418. package/src/server/responses/passthrough-error.ts +78 -0
  419. package/src/server/responses/terminal-guard.ts +230 -0
  420. package/src/server/responses/upstream-error.ts +48 -0
  421. package/src/server/responses-image-gen-repair.ts +132 -0
  422. package/src/server/responses-item-id-repair.ts +224 -0
  423. package/src/server/responses.ts +9 -0
  424. package/src/server/search.ts +136 -0
  425. package/src/server/sse-payload-rewrite.ts +175 -0
  426. package/src/server/startup-action-control.ts +308 -0
  427. package/src/server/startup-health-cache.ts +113 -0
  428. package/src/server/system-env.ts +413 -0
  429. package/src/server/windows-tcp-drop.ts +184 -0
  430. package/src/server/windows-tray-control.ts +41 -0
  431. package/src/server/ws-bridge.ts +471 -0
  432. package/src/service.ts +2554 -0
  433. package/src/stall-timeout.ts +20 -0
  434. package/src/storage/cleanup-job.ts +57 -0
  435. package/src/storage/cleanup.ts +3085 -0
  436. package/src/storage/policy-job.ts +457 -0
  437. package/src/storage/policy-scheduler.ts +40 -0
  438. package/src/storage/policy-worker.ts +59 -0
  439. package/src/storage/policy.ts +527 -0
  440. package/src/storage/restore-job.ts +299 -0
  441. package/src/storage/restore-worker.ts +58 -0
  442. package/src/storage/scanner.ts +238 -0
  443. package/src/storage/storage-mutation-coordinator.ts +139 -0
  444. package/src/storage/worker-lifecycle.ts +215 -0
  445. package/src/tray/assets/opencodex-tray-offline.ico +0 -0
  446. package/src/tray/assets/opencodex-tray-online.ico +0 -0
  447. package/src/tray/assets/opencodex-tray-warning.ico +0 -0
  448. package/src/tray/assets/opencodex-tray.png +0 -0
  449. package/src/tray/windows-tray.ps1 +290 -0
  450. package/src/tray/windows.ts +730 -0
  451. package/src/types.ts +1237 -0
  452. package/src/update/badge.ts +72 -0
  453. package/src/update/index.ts +407 -0
  454. package/src/update/job.ts +1520 -0
  455. package/src/update/notify.ts +257 -0
  456. package/src/update/npm-invocation.d.mts +23 -0
  457. package/src/update/npm-invocation.mjs +94 -0
  458. package/src/update/tray-update-plan.d.mts +18 -0
  459. package/src/update/tray-update-plan.mjs +38 -0
  460. package/src/usage/cost.ts +0 -0
  461. package/src/usage/debug.ts +97 -0
  462. package/src/usage/expected-prices.ts +164 -0
  463. package/src/usage/log.ts +658 -0
  464. package/src/usage/summary.ts +585 -0
  465. package/src/usage/totals.ts +14 -0
  466. package/src/vision/anthropic-describe.ts +185 -0
  467. package/src/vision/describe.ts +125 -0
  468. package/src/vision/index.ts +467 -0
  469. package/src/web-search/anthropic-executor.ts +189 -0
  470. package/src/web-search/executor.ts +105 -0
  471. package/src/web-search/format-result.ts +89 -0
  472. package/src/web-search/index.ts +196 -0
  473. package/src/web-search/loop.ts +664 -0
  474. package/src/web-search/parse.ts +220 -0
  475. package/src/web-search/progress-stream.ts +342 -0
  476. package/src/web-search/synthetic-tool.ts +47 -0
@@ -0,0 +1,1174 @@
1
+ import type { KiroOAuthMetadata, OAuthController, OAuthCredentials } from "./types";
2
+ import { parseCallbackInput } from "./callback-server";
3
+ import type { OcxConfig, OcxProviderConfig, RefreshPolicy } from "../types";
4
+ import { loadConfig, resolveEnvValue, saveConfig } from "../config";
5
+ import { KiroTokenRefreshError, environmentKiroRoutingMetadata, loginKiro, refreshKiroToken, settleKiroLoginTransaction } from "./kiro";
6
+ import { getAccountCredential, getAccountSet, removeAccount, saveAccountCredential, saveCredential, setActiveAccount, getCredential, credentialGeneration, createOAuthRefreshIntentLock, mergeAccountCredential, markAccountNeedsReauthIfGeneration, readOAuthRefreshIntent, writeOAuthRefreshIntent, markOAuthRefreshIntentStaleOwner, clearOAuthRefreshIntent, OAuthMutationBusyError } from "./store";
7
+ import { loginXai, refreshXaiToken, XAI_LOCAL_CLI_DETACH_WARNING, XaiTokenRequestError } from "./xai";
8
+ import { ANTHROPIC_OAUTH_BETA, AnthropicTokenError, loginAnthropic, refreshAnthropicToken } from "./anthropic";
9
+ import { loginKimi, refreshKimiToken } from "./kimi";
10
+ import { loginChatGPT, refreshChatGPTToken } from "./chatgpt";
11
+ import { loginAntigravity, refreshAntigravityToken } from "./google-antigravity";
12
+ import { loginCursor, refreshCursorToken } from "./cursor";
13
+ import { loginGithubCopilot, refreshGithubCopilotToken, validateCopilotApiBaseUrl } from "./github-copilot";
14
+ import { deriveOAuthDefaultModel, deriveOAuthProviderConfig } from "../providers/derive";
15
+ import { apiKeyPoolEntryId, sanitizeApiKeyValue } from "../providers/api-keys";
16
+ import { effectiveGoogleMode, getProviderRegistryEntry, providerMatchesRegistryTransport } from "../providers/registry";
17
+ import { resolveProviderModelDiscoveryUrl } from "../providers/model-discovery";
18
+ import { resolveProviderTransport } from "../providers/xai-transport";
19
+ import { detectClaudeCodeToken, detectGrokCliToken, hasComparableGrokIdentity, isSameGrokIdentity, shouldAdoptGrokGeneration } from "./local-token-detect";
20
+ import { logOAuthEvent } from "./log";
21
+ import { captureConfigGeneration, sweepExpiredOnWrite, type GenerationContext } from "../lib/state-store-sweeper";
22
+ import { retainedUtf8Bytes } from "../lib/admission";
23
+ import { randomUUID } from "node:crypto";
24
+ export {
25
+ CODEX_HEALTH_AUTH_FAILED_NOTE,
26
+ CODEX_HEALTH_MANAGEMENT_API_UNAVAILABLE_NOTE,
27
+ CODEX_HEALTH_UNAVAILABLE_NOTE,
28
+ MASKED_ACCOUNT_FALLBACK,
29
+ collectOAuthHealthEntries,
30
+ collectOAuthHealthEntriesForCli,
31
+ detectOAuthWarning,
32
+ oauthAccountHealthFields,
33
+ oauthHealthLabel,
34
+ oauthHealthSummary,
35
+ projectCodexAccountHealth,
36
+ projectOAuthAccountHealth,
37
+ projectStoredOAuthAccountHealth,
38
+ type CodexHealthSource,
39
+ type OAuthAccountHealth,
40
+ type OAuthAccountHealthFields,
41
+ type OAuthCliHealthReport,
42
+ type OAuthHealthEntry,
43
+ type OAuthHealthLabel,
44
+ } from "./health";
45
+ export { OAUTH_REFRESH_LOCK_WAIT_MS, peekAuthStore, peekOAuthRefreshIntent } from "./store";
46
+ import { codexAccountNamespaceProviderCollisionError } from "../codex/account-namespace-match";
47
+
48
+ const REFRESH_SKEW_MS = 60_000;
49
+ export interface OAuthAccessSnapshot {
50
+ provider: string;
51
+ accountId: string;
52
+ generation: string;
53
+ accessToken: string;
54
+ /** Safe request-routing subset; refresh-only Kiro client secrets never leave the credential store. */
55
+ kiro?: Pick<KiroOAuthMetadata, "profileArn" | "apiRegion" | "ssoRegion">;
56
+ }
57
+
58
+ const MAX_OAUTH_TOKEN_REFRESH_FLIGHTS = 32;
59
+ const OAUTH_TOKEN_REFRESH_FLIGHT_STALE_MS = 120_000;
60
+ interface OAuthRefreshFlightEvidence { flightId: string; dispatched: boolean }
61
+ interface OAuthTokenRefreshFlight extends OAuthRefreshFlightEvidence { promise: Promise<OAuthAccessSnapshot>; startedAt: number; abort: AbortController }
62
+ const tokenRefreshes = new Map<string, OAuthTokenRefreshFlight>();
63
+ export class OAuthTokenRefreshBusyError extends Error {
64
+ readonly code = "OAUTH_TOKEN_REFRESH_BUSY";
65
+ readonly retryable = true;
66
+ constructor() { super("OAuth token refresh capacity reached"); this.name = "OAuthTokenRefreshBusyError"; }
67
+ }
68
+ export class OAuthTokenRefreshStaleError extends Error {
69
+ readonly code = "OAUTH_TOKEN_REFRESH_STALE";
70
+ readonly retryable = true;
71
+ constructor() { super("OAuth token refresh owner became stale"); this.name = "OAuthTokenRefreshStaleError"; }
72
+ }
73
+
74
+ /** Focused owner-identity tests only. Synthetic owners retain no account data. */
75
+ export function seedOAuthTokenRefreshFlightsForTests(rows: Array<{ key: string; startedAt?: number; flightId?: string; dispatched?: boolean }>): {
76
+ promises: Promise<OAuthAccessSnapshot>[];
77
+ cleanup: () => void;
78
+ } {
79
+ const inserted: OAuthTokenRefreshFlight[] = [];
80
+ const promises = rows.map(({ key, startedAt, flightId, dispatched }) => {
81
+ const abort = new AbortController();
82
+ const promise = new Promise<OAuthAccessSnapshot>((_resolve, reject) => {
83
+ abort.signal.addEventListener("abort", () => reject(abort.signal.reason), { once: true });
84
+ });
85
+ const flight = { promise, startedAt: startedAt ?? Date.now(), abort, flightId: flightId ?? randomUUID(), dispatched: dispatched ?? false };
86
+ tokenRefreshes.set(key, flight);
87
+ inserted.push(flight);
88
+ return promise;
89
+ });
90
+ return {
91
+ promises,
92
+ cleanup() {
93
+ for (const [key, flight] of tokenRefreshes) {
94
+ if (!inserted.includes(flight)) continue;
95
+ tokenRefreshes.delete(key);
96
+ flight.abort.abort(new Error("test cleanup"));
97
+ }
98
+ },
99
+ };
100
+ }
101
+ const XAI_PERMANENT_FAILURE_TTL_MS=30_000;
102
+ const permanentRefreshFailures=new Map<string,number>();
103
+ interface XaiRefreshDeps { intentLock?:ReturnType<typeof createOAuthRefreshIntentLock>; now?:()=>number; afterPrePersistRead?:()=>void|Promise<void>; signal?: AbortSignal }
104
+ interface AnthropicRefreshDeps { intentLock?:ReturnType<typeof createOAuthRefreshIntentLock>; now?:()=>number; afterPrePersistRead?:()=>void|Promise<void>; signal?: AbortSignal; flight?: OAuthRefreshFlightEvidence; replacedStaleFlight?: OAuthRefreshFlightEvidence }
105
+ interface GenericRefreshDeps { intentLock?:ReturnType<typeof createOAuthRefreshIntentLock>; afterPrePersistRead?:()=>void|Promise<void>; signal?: AbortSignal }
106
+ function verdictKey(p:string,a:string,c:OAuthCredentials){return `${p}\0${a}\0${credentialGeneration(c)}`;}
107
+ function cached(p:string,a:string,c:OAuthCredentials,now:()=>number){const k=verdictKey(p,a,c),u=permanentRefreshFailures.get(k);if(u===undefined)return false;if(u<=now()){permanentRefreshFailures.delete(k);return false;}return true;}
108
+ export function sweepExpiredXaiPermanentFailureVerdicts(now=Date.now()):number{let removed=0;for(const[key,until]of permanentRefreshFailures){if(until>now)continue;permanentRefreshFailures.delete(key);removed+=1;}return removed;}
109
+
110
+ export interface LoginOpts { forceLogin?: boolean; /** When set, persist into this account slot and require matching identity. */ reauthAccountId?: string }
111
+
112
+ export interface LoginFlowLifecycle {
113
+ /** Runs after background credential/config persistence settles, before status becomes done. */
114
+ onSettled?: () => void | Promise<void>;
115
+ }
116
+
117
+ interface OAuthProviderDef {
118
+ login(ctrl: OAuthController, opts?: LoginOpts): Promise<OAuthCredentials>;
119
+ refresh(
120
+ refreshToken: string,
121
+ signal?: AbortSignal,
122
+ credential?: OAuthCredentials,
123
+ ): Promise<OAuthCredentials>;
124
+ /** provider entry written into config.json on first login. */
125
+ providerConfig: OcxProviderConfig;
126
+ defaultModel: string;
127
+ /**
128
+ * Built-in proactive-refresh policy, risk-tiered by the provider's ToS exposure (devlog
129
+ * 260703_oauth-multi-account-refresh-and-tos). A user's per-provider `config.providers[x].refreshPolicy`
130
+ * overrides this. Default when unset here: "lazy-only".
131
+ */
132
+ defaultRefreshPolicy?: RefreshPolicy;
133
+ }
134
+
135
+ function oauthConfig(id: string): OcxProviderConfig {
136
+ const config = deriveOAuthProviderConfig(id);
137
+ if (!config) throw new Error(`OAuth provider missing from registry: ${id}`);
138
+ return config;
139
+ }
140
+
141
+ function oauthDefaultModel(id: string): string {
142
+ const model = deriveOAuthDefaultModel(id);
143
+ if (!model) throw new Error(`OAuth provider missing default model in registry: ${id}`);
144
+ return model;
145
+ }
146
+
147
+ export const OAUTH_PROVIDERS: Record<string, OAuthProviderDef> = {
148
+ xai: {
149
+ // forceLogin skips the local grok-cli import so a SECOND account can be chosen in the browser.
150
+ login: (ctrl, opts) => loginXai(ctrl, { importLocal: opts?.forceLogin ? "off" : "fallback" }),
151
+ refresh: refreshXaiToken,
152
+ providerConfig: oauthConfig("xai"),
153
+ defaultModel: oauthDefaultModel("xai"),
154
+ },
155
+ anthropic: {
156
+ login: (ctrl, opts) => loginAnthropic(ctrl, { importLocal: opts?.forceLogin ? "off" : "fallback" }),
157
+ refresh: refreshAnthropicToken,
158
+ providerConfig: oauthConfig("anthropic"),
159
+ defaultModel: oauthDefaultModel("anthropic"),
160
+ // Anthropic actively server-side-blocks subscription OAuth outside its own clients (Feb 2026).
161
+ // Never generate background refresh traffic for it — grade 20, highest ToS risk.
162
+ defaultRefreshPolicy: "disabled",
163
+ },
164
+ kimi: {
165
+ login: (ctrl) => loginKimi(ctrl),
166
+ refresh: refreshKimiToken,
167
+ providerConfig: oauthConfig("kimi"),
168
+ defaultModel: oauthDefaultModel("kimi"),
169
+ },
170
+ kiro: {
171
+ login: (ctrl, opts) => loginKiro(ctrl, { forceLogin: opts?.forceLogin }),
172
+ refresh: (rt, signal, credential) => refreshKiroToken(rt, signal, credential),
173
+ providerConfig: oauthConfig("kiro"),
174
+ defaultModel: oauthDefaultModel("kiro"),
175
+ },
176
+ "google-antigravity": {
177
+ login: (ctrl, opts) => loginAntigravity(ctrl, { forceAccountSelect: opts?.forceLogin === true }),
178
+ refresh: refreshAntigravityToken,
179
+ providerConfig: oauthConfig("google-antigravity"),
180
+ defaultModel: oauthDefaultModel("google-antigravity"),
181
+ },
182
+ cursor: {
183
+ login: (ctrl) => loginCursor(ctrl),
184
+ refresh: refreshCursorToken,
185
+ providerConfig: oauthConfig("cursor"),
186
+ defaultModel: oauthDefaultModel("cursor"),
187
+ },
188
+ "github-copilot": {
189
+ login: (ctrl) => loginGithubCopilot(ctrl),
190
+ refresh: (rt, signal) => refreshGithubCopilotToken(rt, signal),
191
+ providerConfig: oauthConfig("github-copilot"),
192
+ defaultModel: oauthDefaultModel("github-copilot"),
193
+ // Unofficial Copilot bridge — keep proactive traffic lazy-only (no background guardian spam).
194
+ defaultRefreshPolicy: "lazy-only",
195
+ },
196
+ chatgpt: {
197
+ login: loginChatGPT,
198
+ refresh: (rt) => refreshChatGPTToken(rt),
199
+ providerConfig: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" as const },
200
+ defaultModel: "gpt-5.4",
201
+ },
202
+ };
203
+
204
+ export function isOAuthProvider(name: string): boolean {
205
+ return name in OAUTH_PROVIDERS;
206
+ }
207
+
208
+ export function isPublicOAuthProvider(name: string): boolean {
209
+ return name !== "chatgpt" && isOAuthProvider(name);
210
+ }
211
+
212
+ function isRefreshPolicy(value: unknown): value is RefreshPolicy {
213
+ return value === "proactive" || value === "lazy-only" || value === "disabled";
214
+ }
215
+
216
+ /**
217
+ * The effective proactive-refresh policy for a provider: the user's per-provider
218
+ * `config.providers[provider].refreshPolicy` if set, else the provider def's risk-tiered default,
219
+ * else "lazy-only". The guardian acts only when this resolves to "proactive".
220
+ */
221
+ export function resolveRefreshPolicy(provider: string, config: OcxConfig): RefreshPolicy {
222
+ const override = config.providers[provider]?.refreshPolicy;
223
+ if (isRefreshPolicy(override)) return override;
224
+ const def = OAUTH_PROVIDERS[provider];
225
+ return def?.defaultRefreshPolicy ?? "lazy-only";
226
+ }
227
+
228
+ /** The discovered project id stored on an OAuth credential (Antigravity CCA), if any. */
229
+ export function getOAuthCredentialProjectId(provider: string): string | undefined {
230
+ return getCredential(provider)?.projectId;
231
+ }
232
+
233
+ /** Allowlisted Copilot API origin from the active credential, if still valid. */
234
+ export function getOAuthCredentialApiBaseUrl(provider: string): string | undefined {
235
+ return validateCopilotApiBaseUrl(getCredential(provider)?.apiBaseUrl);
236
+ }
237
+
238
+ /** Provider ids that support real OAuth login (drives the GUI's "Log in with …" buttons). */
239
+ export function listOAuthProviders(): string[] {
240
+ return Object.keys(OAUTH_PROVIDERS).filter(isPublicOAuthProvider);
241
+ }
242
+
243
+ export class UnsupportedOAuthProviderError extends Error {
244
+ constructor(provider: string) {
245
+ super(`Unsupported OAuth provider in config: ${provider}`);
246
+ this.name = "UnsupportedOAuthProviderError";
247
+ }
248
+ }
249
+
250
+ export class OAuthLoginRequiredError extends Error {
251
+ constructor(provider: string) {
252
+ super(`Not logged in to ${provider}. Run: ocx login ${provider}`);
253
+ this.name = "OAuthLoginRequiredError";
254
+ }
255
+ }
256
+
257
+ function accessSnapshot(provider: string, accountId: string, cred: OAuthCredentials): OAuthAccessSnapshot {
258
+ const storedKiroRouting = {
259
+ ...(cred.kiro?.profileArn ? { profileArn: cred.kiro.profileArn } : {}),
260
+ ...(cred.kiro?.apiRegion ? { apiRegion: cred.kiro.apiRegion } : {}),
261
+ ...(cred.kiro?.ssoRegion ? { ssoRegion: cred.kiro.ssoRegion } : {}),
262
+ };
263
+ return {
264
+ provider,
265
+ accountId,
266
+ generation: credentialGeneration(cred),
267
+ accessToken: cred.access,
268
+ // Stored account metadata remains authoritative. Metadata-less legacy/environment credentials
269
+ // may use explicit environment routing, but never borrow the currently signed-in local CLI account.
270
+ ...(provider === "kiro"
271
+ ? {
272
+ kiro: Object.keys(storedKiroRouting).length > 0
273
+ ? storedKiroRouting
274
+ : environmentKiroRoutingMetadata() ?? {},
275
+ }
276
+ : {}),
277
+ };
278
+ }
279
+
280
+ async function resolveAccessSnapshotForAccount(
281
+ provider: string,
282
+ accountId: string,
283
+ rejectedGeneration?: string,
284
+ ): Promise<OAuthAccessSnapshot> {
285
+ const def = OAUTH_PROVIDERS[provider];
286
+ if (!def) throw new UnsupportedOAuthProviderError(provider);
287
+ const cred = getAccountCredential(provider, accountId);
288
+ if (!cred) throw new OAuthLoginRequiredError(provider);
289
+ const current = accessSnapshot(provider, accountId, cred);
290
+ if (rejectedGeneration !== undefined && current.generation !== rejectedGeneration) return current;
291
+ if (rejectedGeneration === undefined && cred.expires > Date.now() + REFRESH_SKEW_MS) return current;
292
+
293
+ const key = `${provider}\u0000${accountId}`;
294
+ let existing = tokenRefreshes.get(key);
295
+ let replacedStaleFlight: OAuthRefreshFlightEvidence | undefined;
296
+ if (existing && Date.now() - existing.startedAt <= OAUTH_TOKEN_REFRESH_FLIGHT_STALE_MS) {
297
+ logOAuthEvent("OAuth refresh joined existing operation", { provider, accountId });
298
+ return existing.promise;
299
+ }
300
+ if (existing) {
301
+ replacedStaleFlight = { flightId: existing.flightId, dispatched: existing.dispatched };
302
+ existing.abort.abort(new OAuthTokenRefreshStaleError());
303
+ if (tokenRefreshes.get(key) === existing) tokenRefreshes.delete(key);
304
+ existing = undefined;
305
+ }
306
+ if (tokenRefreshes.size >= MAX_OAUTH_TOKEN_REFRESH_FLIGHTS) throw new OAuthTokenRefreshBusyError();
307
+
308
+ const abort = new AbortController();
309
+ const flight: OAuthTokenRefreshFlight = {
310
+ promise: undefined as unknown as Promise<OAuthAccessSnapshot>,
311
+ startedAt: Date.now(),
312
+ abort,
313
+ flightId: randomUUID(),
314
+ dispatched: false,
315
+ };
316
+ const refresh = (async (): Promise<OAuthAccessSnapshot> => {
317
+ const accessToken = await refreshAndPersistAccessToken(provider, accountId, def, cred, abort.signal, flight, replacedStaleFlight);
318
+ const persisted = getAccountCredential(provider, accountId);
319
+ if (!persisted) throw new OAuthLoginRequiredError(provider);
320
+ if (persisted.access !== accessToken) {
321
+ throw new Error(`OAuth refresh persisted an unexpected access token for ${provider}`);
322
+ }
323
+ return accessSnapshot(provider, accountId, persisted);
324
+ })().catch(error => {
325
+ if (abort.signal.reason instanceof OAuthTokenRefreshStaleError) throw abort.signal.reason;
326
+ throw error;
327
+ }).finally(() => {
328
+ if (tokenRefreshes.get(key) === flight) tokenRefreshes.delete(key);
329
+ });
330
+ flight.promise = refresh;
331
+ tokenRefreshes.set(key, flight);
332
+ return refresh;
333
+ }
334
+
335
+ export async function getValidAccessTokenSnapshot(provider: string): Promise<OAuthAccessSnapshot> {
336
+ const set = getAccountSet(provider);
337
+ if (!set) throw new OAuthLoginRequiredError(provider);
338
+ return resolveAccessSnapshotForAccount(provider, set.activeAccountId);
339
+ }
340
+
341
+ /** Providers whose upstream-401 replay path may force a snapshot refresh. */
342
+ const FORCE_REFRESH_PROVIDERS = new Set(["xai", "github-copilot", "kiro"]);
343
+
344
+ export async function forceRefreshOAuthAccessSnapshot(
345
+ rejected: OAuthAccessSnapshot,
346
+ ): Promise<OAuthAccessSnapshot> {
347
+ if (!FORCE_REFRESH_PROVIDERS.has(rejected.provider)) throw new UnsupportedOAuthProviderError(rejected.provider);
348
+ return resolveAccessSnapshotForAccount(rejected.provider, rejected.accountId, rejected.generation);
349
+ }
350
+
351
+ /** Return a valid access token for the ACTIVE account, refreshing + persisting if expired. */
352
+ export async function getValidAccessToken(provider: string): Promise<string> {
353
+ return (await getValidAccessTokenSnapshot(provider)).accessToken;
354
+ }
355
+
356
+ /**
357
+ * Account-scoped token resolver (multiauth): refresh is single-flighted per
358
+ * (provider, account), and the rotated credential is persisted for THAT account only —
359
+ * a guardian refresh of a background account never switches the active account.
360
+ */
361
+ export async function getValidAccessTokenForAccount(provider: string, accountId: string): Promise<string> {
362
+ return (await resolveAccessSnapshotForAccount(provider, accountId)).accessToken;
363
+ }
364
+
365
+ /** Terminal refresh failures (revoked/rotated-away grants) — retrying cannot succeed. */
366
+ function isTerminalRefreshError(err: unknown): boolean {
367
+ const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
368
+ return msg.includes("invalid_grant")
369
+ || msg.includes("refresh_token_reused")
370
+ || msg.includes("revoked")
371
+ // GitHub Copilot refresh surfaces allowlisted OAuth codes (github-copilot.ts):
372
+ || msg.includes("access_denied")
373
+ || msg.includes("expired_token");
374
+ }
375
+ function terminal(error:unknown):boolean{
376
+ if(error instanceof XaiTokenRequestError)return ["invalid_grant","refresh_token_reused","revoked_token"].includes(error.oauthError??"");
377
+ if(error instanceof AnthropicTokenError)return (error.httpStatus===400||error.httpStatus===401)&&["invalid_grant","refresh_token_reused","revoked","revoked_token","refresh_token_revoked"].includes(error.oauthError??"");
378
+ if(error instanceof KiroTokenRefreshError)return (error.httpStatus===400||error.httpStatus===401)&&error.oauthError!==undefined;
379
+ return isTerminalRefreshError(error);
380
+ }
381
+ function authoritative(stored:OAuthCredentials,active:boolean,now:()=>number):OAuthCredentials{if(stored.source!=="local-cli")return stored;const disk=detectGrokCliToken();if(!disk)return stored;const allowed=isSameGrokIdentity(stored,disk)||(active&&!hasComparableGrokIdentity(stored,disk));return allowed&&shouldAdoptGrokGeneration(stored,disk,now(),REFRESH_SKEW_MS)?disk:stored;}
382
+ function merged(fresh: OAuthCredentials, previous: OAuthCredentials): OAuthCredentials {
383
+ return {
384
+ ...fresh,
385
+ source: previous.source === "local-cli" ? "oauth" : fresh.source ?? previous.source ?? "oauth",
386
+ ...(fresh.projectId === undefined && previous.projectId ? { projectId: previous.projectId } : {}),
387
+ ...(fresh.apiBaseUrl === undefined && previous.apiBaseUrl ? { apiBaseUrl: previous.apiBaseUrl } : {}),
388
+ ...(fresh.email === undefined && previous.email ? { email: previous.email } : {}),
389
+ ...(fresh.accountId === undefined && previous.accountId ? { accountId: previous.accountId } : {}),
390
+ ...(fresh.kiro === undefined && previous.kiro ? { kiro: previous.kiro } : {}),
391
+ };
392
+ }
393
+ export async function refreshXaiAccountWithLock(provider:string,accountId:string,def:OAuthProviderDef,callerCredential:OAuthCredentials,deps:XaiRefreshDeps={}):Promise<string>{const writerGeneration=captureConfigGeneration();const now=deps.now??Date.now;const guard=await(deps.intentLock??createOAuthRefreshIntentLock(provider,accountId)).acquire();try{const stored=getAccountCredential(provider,accountId);if(!stored)throw new OAuthLoginRequiredError(provider);const active=getAccountSet(provider)?.activeAccountId===accountId,candidate=authoritative(stored,active,now);if(credentialGeneration(candidate)!==credentialGeneration(callerCredential)&&candidate.expires>now()+REFRESH_SKEW_MS){if(credentialGeneration(candidate)!==credentialGeneration(stored)){const o=await mergeAccountCredential(provider,accountId,candidate,{expectedGeneration:credentialGeneration(stored),afterPrePersistRead:deps.afterPrePersistRead});if(o.superseded){if(o.stored.expires>now()+REFRESH_SKEW_MS)return o.stored.access;throw new OAuthLoginRequiredError(provider);}}return candidate.access;}if(cached(provider,accountId,candidate,now))throw new OAuthLoginRequiredError(provider);const generation=credentialGeneration(candidate);try{const fresh=merged(await def.refresh(candidate.refresh,deps.signal),candidate);const o=await mergeAccountCredential(provider,accountId,fresh,{expectedGeneration:generation,afterPrePersistRead:deps.afterPrePersistRead});if(o.superseded){if(o.stored.expires>now()+REFRESH_SKEW_MS)return o.stored.access;throw new OAuthLoginRequiredError(provider);}permanentRefreshFailures.delete(verdictKey(provider,accountId,candidate));if(candidate.source==="local-cli")console.warn(XAI_LOCAL_CLI_DETACH_WARNING);return fresh.access;}catch(error){if(error instanceof OAuthMutationBusyError){permanentRefreshFailures.delete(verdictKey(provider,accountId,candidate));throw error;}if(!terminal(error))throw error;const failedAt=now();permanentRefreshFailures.set(verdictKey(provider,accountId,candidate),failedAt+XAI_PERMANENT_FAILURE_TTL_MS);sweepExpiredOnWrite(failedAt);await markAccountNeedsReauthIfGeneration(provider,accountId,generation,writerGeneration);throw new OAuthLoginRequiredError(provider);}}finally{guard.release();}}
394
+
395
+ function newerClaudeCredential(stored: OAuthCredentials, now: number): OAuthCredentials | undefined {
396
+ if (stored.source !== "local-cli") return undefined;
397
+ const disk = detectClaudeCodeToken();
398
+ if (!disk || disk.expires <= now + REFRESH_SKEW_MS) return undefined;
399
+ return credentialGeneration(disk) !== credentialGeneration(stored) ? disk : undefined;
400
+ }
401
+
402
+ export async function refreshAnthropicAccountWithLock(
403
+ provider: string,
404
+ accountId: string,
405
+ def: OAuthProviderDef,
406
+ callerCredential: OAuthCredentials,
407
+ deps: AnthropicRefreshDeps = {},
408
+ ): Promise<string> {
409
+ const writerGeneration = captureConfigGeneration();
410
+ const now = deps.now ?? Date.now;
411
+ const guard = await (deps.intentLock ?? createOAuthRefreshIntentLock(provider, accountId)).acquire();
412
+ try {
413
+ const stored = getAccountCredential(provider, accountId);
414
+ if (!stored) throw new OAuthLoginRequiredError(provider);
415
+ const account = getAccountSet(provider)?.accounts.find(candidate => candidate.id === accountId);
416
+ const generation = credentialGeneration(stored);
417
+ let pendingIntent = readOAuthRefreshIntent(provider, accountId);
418
+ const disk = newerClaudeCredential(stored, now());
419
+ if (disk) {
420
+ const outcome = await mergeAccountCredential(provider, accountId, disk, {
421
+ expectedGeneration: credentialGeneration(stored),
422
+ afterPrePersistRead: deps.afterPrePersistRead,
423
+ });
424
+ if (outcome.superseded) {
425
+ if (pendingIntent) clearOAuthRefreshIntent(provider, accountId, pendingIntent.generation);
426
+ if (outcome.stored.expires > now() + REFRESH_SKEW_MS) return outcome.stored.access;
427
+ throw new OAuthLoginRequiredError(provider);
428
+ }
429
+ if (pendingIntent) clearOAuthRefreshIntent(provider, accountId, pendingIntent.generation);
430
+ return disk.access;
431
+ }
432
+ if (!pendingIntent?.uncertain && pendingIntent?.generation === generation) {
433
+ if (pendingIntent.staleOwner) throw new OAuthTokenRefreshStaleError();
434
+ if (deps.replacedStaleFlight && pendingIntent.flightId === deps.replacedStaleFlight.flightId) {
435
+ if (deps.replacedStaleFlight.dispatched) {
436
+ markOAuthRefreshIntentStaleOwner(provider, accountId, generation, deps.replacedStaleFlight.flightId);
437
+ throw new OAuthTokenRefreshStaleError();
438
+ }
439
+ clearOAuthRefreshIntent(provider, accountId, generation);
440
+ pendingIntent = undefined;
441
+ }
442
+ }
443
+ if (pendingIntent?.uncertain || pendingIntent?.generation === generation) {
444
+ await markAccountNeedsReauthIfGeneration(provider, accountId, generation, writerGeneration);
445
+ throw new OAuthLoginRequiredError(provider);
446
+ }
447
+ if (pendingIntent) clearOAuthRefreshIntent(provider, accountId, pendingIntent.generation);
448
+ if (account?.needsReauth) {
449
+ throw new OAuthLoginRequiredError(provider);
450
+ }
451
+ if (credentialGeneration(stored) !== credentialGeneration(callerCredential) && stored.expires > now() + REFRESH_SKEW_MS) {
452
+ return stored.access;
453
+ }
454
+
455
+ try {
456
+ writeOAuthRefreshIntent(provider, accountId, generation, now(), deps.flight?.flightId);
457
+ if (deps.signal?.aborted) throw deps.signal.reason;
458
+ if (deps.flight) deps.flight.dispatched = true;
459
+ const fresh = merged(await def.refresh(stored.refresh, deps.signal), stored);
460
+ const outcome = await mergeAccountCredential(provider, accountId, fresh, {
461
+ expectedGeneration: generation,
462
+ afterPrePersistRead: deps.afterPrePersistRead,
463
+ });
464
+ if (outcome.superseded) {
465
+ clearOAuthRefreshIntent(provider, accountId, generation);
466
+ if (outcome.stored.expires > now() + REFRESH_SKEW_MS) return outcome.stored.access;
467
+ throw new OAuthLoginRequiredError(provider);
468
+ }
469
+ clearOAuthRefreshIntent(provider, accountId, generation);
470
+ return fresh.access;
471
+ } catch (error) {
472
+ if (error instanceof OAuthMutationBusyError) throw error;
473
+ if (!terminal(error)) throw error;
474
+ await markAccountNeedsReauthIfGeneration(provider, accountId, generation, writerGeneration);
475
+ clearOAuthRefreshIntent(provider, accountId, generation);
476
+ throw new OAuthLoginRequiredError(provider);
477
+ }
478
+ } finally {
479
+ guard.release();
480
+ }
481
+ }
482
+
483
+ export async function refreshGenericAccountWithLock(
484
+ provider: string,
485
+ accountId: string,
486
+ def: OAuthProviderDef,
487
+ callerCredential: OAuthCredentials,
488
+ deps: GenericRefreshDeps = {},
489
+ ): Promise<string> {
490
+ const writerGeneration = captureConfigGeneration();
491
+ logOAuthEvent("OAuth refresh started", { provider, accountId });
492
+ const guard = await (deps.intentLock ?? createOAuthRefreshIntentLock(provider, accountId)).acquire();
493
+ try {
494
+ const stored = getAccountCredential(provider, accountId);
495
+ if (!stored) throw new OAuthLoginRequiredError(provider);
496
+ if (
497
+ credentialGeneration(stored) !== credentialGeneration(callerCredential)
498
+ && stored.expires > Date.now() + REFRESH_SKEW_MS
499
+ ) {
500
+ logOAuthEvent("OAuth refresh joined existing operation", { provider, accountId });
501
+ return stored.access;
502
+ }
503
+ const generation = credentialGeneration(stored);
504
+ try {
505
+ const fresh = merged(await def.refresh(stored.refresh, deps.signal, stored), stored);
506
+ const outcome = await mergeAccountCredential(provider, accountId, fresh, {
507
+ expectedGeneration: generation,
508
+ afterPrePersistRead: deps.afterPrePersistRead,
509
+ });
510
+ if (outcome.superseded) {
511
+ if (outcome.stored.expires > Date.now() + REFRESH_SKEW_MS) return outcome.stored.access;
512
+ throw new OAuthLoginRequiredError(provider);
513
+ }
514
+ logOAuthEvent("OAuth credentials rotated and persisted", { provider, accountId });
515
+ return fresh.access;
516
+ } catch (error) {
517
+ if (error instanceof OAuthMutationBusyError) throw error;
518
+ if (!terminal(error)) throw error;
519
+ await markAccountNeedsReauthIfGeneration(provider, accountId, generation, writerGeneration);
520
+ throw new OAuthLoginRequiredError(provider);
521
+ }
522
+ } finally {
523
+ guard.release();
524
+ }
525
+ }
526
+
527
+ async function refreshAndPersistAccessToken(
528
+ provider: string,
529
+ accountId: string,
530
+ def: OAuthProviderDef,
531
+ cred: OAuthCredentials,
532
+ signal?: AbortSignal,
533
+ flight?: OAuthRefreshFlightEvidence,
534
+ replacedStaleFlight?: OAuthRefreshFlightEvidence,
535
+ ): Promise<string> {
536
+ if (provider === "xai") return refreshXaiAccountWithLock(provider, accountId, def, cred, { signal });
537
+ if (provider === "anthropic") return refreshAnthropicAccountWithLock(provider, accountId, def, cred, { signal, flight, replacedStaleFlight });
538
+ return refreshGenericAccountWithLock(provider, accountId, def, cred, { signal });
539
+ }
540
+
541
+ /**
542
+ * Shared bearer-token resolver for /models listing — used by BOTH server.ts:fetchAllModels and
543
+ * codex-catalog.ts:fetchProviderModels so OAuth providers' models are listed once logged in.
544
+ * Returns undefined for forward-mode or oauth-not-logged-in (caller skips).
545
+ */
546
+ export async function resolveModelsAuthToken(name: string, prov: OcxProviderConfig): Promise<string | undefined> {
547
+ if (prov.authMode === "forward") return undefined;
548
+ if (prov.authMode === "oauth") {
549
+ try {
550
+ return await getValidAccessToken(name);
551
+ } catch {
552
+ return undefined;
553
+ }
554
+ }
555
+ return resolveEnvValue(prov.apiKey);
556
+ }
557
+
558
+ function modelDiscoveryTransportSeed(providerName: string, prov: OcxProviderConfig): OcxProviderConfig {
559
+ const entry = getProviderRegistryEntry(providerName);
560
+ if (
561
+ prov.authMode !== "oauth"
562
+ || entry?.authKind !== "oauth"
563
+ || entry.allowBaseUrlOverride === true
564
+ || /\{[^}]*\}/.test(entry.baseUrl)
565
+ || !providerMatchesRegistryTransport(providerName, prov)
566
+ ) {
567
+ return prov;
568
+ }
569
+ // Normal routing pins fixed OAuth presets before adapter-specific transport resolution.
570
+ // Discovery must do the same so a stale or modified config baseUrl never receives a token.
571
+ return { ...prov, adapter: entry.adapter, baseUrl: entry.baseUrl };
572
+ }
573
+
574
+ /**
575
+ * Provider-correct `GET /models` request (URL + headers), so both model-listing paths fetch the
576
+ * LIVE catalog correctly per adapter. Anthropic is the special case: its endpoint is `/v1/models`
577
+ * (not `/models`), it needs `anthropic-version`, and it authenticates with `x-api-key` by default
578
+ * (or `Authorization: Bearer` when `apiKeyTransport = "bearer"`), plus the OAuth beta for oauth
579
+ * mode — not a bare Bearer. Google (ai-studio mode)
580
+ * is the other special case: `x-goog-api-key` + `/v1beta/models`, returning `{ models: [...] }`.
581
+ * The catalog authority gate intentionally degrades that non-OpenAI shape to stale/static data.
582
+ * Everyone else uses the OpenAI-style `/models` + Bearer with a `{ data: [{ id, owned_by? }] }`
583
+ * response.
584
+ */
585
+ export function buildModelsRequest(prov: OcxProviderConfig, apiKey: string | undefined, providerName = ""): { url: string; headers: Record<string, string> } {
586
+ const transportSeed = modelDiscoveryTransportSeed(providerName, prov);
587
+ const effectiveProvider = resolveProviderTransport(
588
+ providerName,
589
+ transportSeed,
590
+ undefined,
591
+ providerName === "github-copilot" ? getOAuthCredentialApiBaseUrl(providerName) : undefined,
592
+ );
593
+ const headers: Record<string, string> = { ...(effectiveProvider.headers ?? {}) };
594
+ const discoveryUrl = (defaultUrl: string): string => resolveProviderModelDiscoveryUrl(
595
+ providerName,
596
+ prov,
597
+ effectiveProvider.baseUrl,
598
+ defaultUrl,
599
+ );
600
+ if (effectiveGoogleMode(providerName, effectiveProvider) === "ai-studio") {
601
+ // Generative Language API: API key goes in x-goog-api-key (never Authorization: Bearer),
602
+ // models live under /v1beta (v1 misses preview models), and pageSize maxes at 1000 —
603
+ // enough to list everything without a pageToken loop. Vertex/antigravity keep the
604
+ // generic branch (they fall back to their static model lists).
605
+ if (apiKey) headers["x-goog-api-key"] = apiKey;
606
+ return { url: discoveryUrl(`${effectiveProvider.baseUrl}/v1beta/models?pageSize=1000`), headers };
607
+ }
608
+ if (effectiveProvider.adapter === "anthropic") {
609
+ const base = effectiveProvider.baseUrl.replace(/\/v1\/?$/, "");
610
+ headers["anthropic-version"] = "2023-06-01";
611
+ if (effectiveProvider.authMode === "oauth") {
612
+ headers["anthropic-beta"] = ANTHROPIC_OAUTH_BETA;
613
+ if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
614
+ } else if (apiKey) {
615
+ if (effectiveProvider.apiKeyTransport === "bearer") headers["Authorization"] = `Bearer ${apiKey}`;
616
+ else headers["x-api-key"] = apiKey;
617
+ }
618
+ return { url: discoveryUrl(`${base}/v1/models?limit=1000`), headers };
619
+ }
620
+ if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
621
+ return { url: discoveryUrl(`${effectiveProvider.baseUrl}/models`), headers };
622
+ }
623
+
624
+ /**
625
+ * Refresh OAuth-managed provider presets (`models`, `noReasoningModels`, and a stale `defaultModel`)
626
+ * from the registry so a proxy update that revises a provider's models — e.g. dropping deprecated
627
+ * Claude snapshots or adding a new grok endpoint not in the live `/models` — reaches EXISTING
628
+ * configs on the next `ocx start`, instead of only fresh installs. The live `/models` fetch stays
629
+ * the primary source; this keeps the static fallback (and models-not-in-/models) current.
630
+ *
631
+ * Only touches providers that are registry-managed AND still `authMode: "oauth"`. Preset fields
632
+ * are refreshed, while the registry's `liveModels` default is normally filled only when no value
633
+ * is stored. Antigravity has one versioned exception below because its old GUI-generated `true`
634
+ * cannot be distinguished from a hand-written pre-migration `true`. Persists + returns true when
635
+ * anything changed.
636
+ */
637
+ function cloneProviderField(value: unknown): unknown {
638
+ if (Array.isArray(value)) return [...value];
639
+ if (value && typeof value === "object") return JSON.parse(JSON.stringify(value));
640
+ return value;
641
+ }
642
+
643
+ const OAUTH_RECONCILE_FIELDS: (keyof OcxProviderConfig)[] = [
644
+ "models",
645
+ "contextWindow",
646
+ "modelContextWindows",
647
+ "defaultMaxOutputTokens",
648
+ "modelMaxOutputTokens",
649
+ "modelInputModalities",
650
+ "noReasoningModels",
651
+ "noVisionModels",
652
+ "reasoningEfforts",
653
+ "modelReasoningEfforts",
654
+ "reasoningEffortMap",
655
+ "modelReasoningEffortMap",
656
+ "noTemperatureModels",
657
+ "noTopPModels",
658
+ "noPenaltyModels",
659
+ "autoToolChoiceOnlyModels",
660
+ "preserveReasoningContentModels",
661
+ ];
662
+
663
+ const GOOGLE_ANTIGRAVITY_PROVIDER = "google-antigravity";
664
+ const GOOGLE_ANTIGRAVITY_STATIC_CATALOG_VERSION = 1 as const;
665
+
666
+ export function reconcileOAuthProviders(config: OcxConfig): boolean {
667
+ let changed = false;
668
+ const migrateAntigravityStaticCatalog =
669
+ config.googleAntigravityStaticCatalogVersion !== GOOGLE_ANTIGRAVITY_STATIC_CATALOG_VERSION;
670
+ for (const [name, prov] of Object.entries(config.providers)) {
671
+ const def = OAUTH_PROVIDERS[name];
672
+ // Normalize the canonical row before the OAuth-only reconciliation guard. The old GUI and a
673
+ // manual edit both persist the same bare `true`, with no source metadata, so every ambiguous
674
+ // pre-marker value is reset once. A deliberate live-discovery choice can be re-enabled after
675
+ // the marker and is then preserved. Do this before the guard so omitted/non-OAuth authMode
676
+ // rows do not get stamped without actually receiving the new static default.
677
+ if (name === GOOGLE_ANTIGRAVITY_PROVIDER && migrateAntigravityStaticCatalog && prov.liveModels !== false) {
678
+ prov.liveModels = false;
679
+ changed = true;
680
+ }
681
+ // During the one-time Antigravity static-catalog migration, also refresh preset catalog
682
+ // fields when authMode is omitted or non-oauth. Otherwise liveModels flips to static while
683
+ // a stale models[] remains the published catalog forever.
684
+ const migrateAntigravityCatalogFields =
685
+ name === GOOGLE_ANTIGRAVITY_PROVIDER && migrateAntigravityStaticCatalog;
686
+ if (!def || (prov.authMode !== "oauth" && !migrateAntigravityCatalogFields)) continue;
687
+ const preset = def.providerConfig;
688
+ for (const field of OAUTH_RECONCILE_FIELDS) {
689
+ if (JSON.stringify(prov[field]) === JSON.stringify(preset[field])) continue;
690
+ if (preset[field] !== undefined) {
691
+ prov[field] = cloneProviderField(preset[field]) as never;
692
+ } else {
693
+ delete prov[field];
694
+ }
695
+ changed = true;
696
+ }
697
+ // Before this marker existed, the GUI materialized an omitted `liveModels` as `true` on any
698
+ // settings save. Since persisted values have no provenance, the pre-guard normalization above
699
+ // intentionally resets all pre-marker `true` values once. Later choices are version-bounded.
700
+ if (prov.liveModels === undefined && preset.liveModels !== undefined) {
701
+ prov.liveModels = preset.liveModels;
702
+ changed = true;
703
+ }
704
+ // Heal a defaultModel that no longer exists in the refreshed list (e.g. a deprecated snapshot).
705
+ if (prov.defaultModel && preset.defaultModel && !(prov.models ?? []).includes(prov.defaultModel)) {
706
+ prov.defaultModel = preset.defaultModel;
707
+ changed = true;
708
+ }
709
+ }
710
+ if (migrateAntigravityStaticCatalog) {
711
+ config.googleAntigravityStaticCatalogVersion = GOOGLE_ANTIGRAVITY_STATIC_CATALOG_VERSION;
712
+ changed = true;
713
+ }
714
+ if (changed) saveConfig(config);
715
+ return changed;
716
+ }
717
+
718
+ /** Runtime guards: provider config is intentionally passthrough, so persisted fields may be malformed. */
719
+ function preservableApiKeyPool(value: unknown): NonNullable<OcxProviderConfig["apiKeyPool"]> | undefined {
720
+ if (!Array.isArray(value)) return undefined;
721
+ const pool: NonNullable<OcxProviderConfig["apiKeyPool"]> = [];
722
+ const ids = new Set<string>();
723
+ const keys = new Set<string>();
724
+ for (const entry of value as unknown[]) {
725
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
726
+ const candidate = entry as Record<string, unknown>;
727
+ const id = typeof candidate.id === "string" ? candidate.id.trim() : "";
728
+ const key = sanitizeApiKeyValue(candidate.key);
729
+ if (!id || !key || ids.has(id) || keys.has(key)) continue;
730
+ const label = typeof candidate.label === "string" ? candidate.label : undefined;
731
+ const addedAt = typeof candidate.addedAt === "number" && Number.isFinite(candidate.addedAt)
732
+ ? candidate.addedAt
733
+ : undefined;
734
+ ids.add(id);
735
+ keys.add(key);
736
+ pool.push({
737
+ id,
738
+ key,
739
+ ...(label !== undefined ? { label } : {}),
740
+ ...(addedAt !== undefined ? { addedAt } : {}),
741
+ });
742
+ }
743
+ // `apiKey` remains the routing source of truth. Keep valid alternate slots even when a
744
+ // hand-edited config left the pool out of sync, rather than deleting usable credentials.
745
+ return pool.length > 0 ? pool : undefined;
746
+ }
747
+
748
+ /**
749
+ * Add/refresh an OAuth provider's config entry on a config object (does not persist).
750
+ *
751
+ * Providers whose registry entry sets `allowKeyAuthOverride` (xai, github-copilot) can be
752
+ * billed through a stored API key instead of the OAuth login (router.ts honors
753
+ * `authMode: "key"` for them). A blind preset overwrite here deletes `apiKey`/`apiKeyPool`
754
+ * on every OAuth login, silently destroying the stored key and forcing a re-paste — and it
755
+ * flips billing back to the subscription without the user asking. Carry the key fields over
756
+ * and keep key billing while usable key material remains and the user was not explicitly on
757
+ * oauth. If the final key was removed and only the old key mode remains, let the OAuth
758
+ * preset restore `authMode: "oauth"` so the newly saved OAuth credential can be used.
759
+ *
760
+ * After preservation, `apiKey` always has exactly one matching pool entry (inserting via the
761
+ * same content-derived id as the API-key manager when the active key was missing from the
762
+ * pool). Key mode reflects stored user intent (explicit `"key"` or omitted mode with safe
763
+ * key material) — never whether the login CLI process can resolve an env reference. Env-backed
764
+ * availability is decided at proxy routing time in `router.ts`.
765
+ */
766
+ export function upsertOAuthProvider(config: OcxConfig, provider: string): void {
767
+ if (provider === "chatgpt") return;
768
+ const def = OAUTH_PROVIDERS[provider];
769
+ if (!def) return;
770
+ const namespaceCollision = codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, provider);
771
+ if (namespaceCollision) throw new Error(namespaceCollision);
772
+ const existing = config.providers[provider];
773
+ const next: OcxProviderConfig = { ...def.providerConfig };
774
+ // `liveModels` is a user-facing provider toggle. A registry default seeds new rows, but an
775
+ // explicit post-migration choice must survive re-login and the latest-config upsert. Old GUI
776
+ // saves and manual edits left identical pre-marker `true` values, so that ambiguous state is
777
+ // reset once; users who deliberately forced discovery can re-enable it after migration.
778
+ const preserveExistingLiveModels = provider !== GOOGLE_ANTIGRAVITY_PROVIDER
779
+ || config.googleAntigravityStaticCatalogVersion === GOOGLE_ANTIGRAVITY_STATIC_CATALOG_VERSION;
780
+ if (preserveExistingLiveModels && typeof existing?.liveModels === "boolean") {
781
+ next.liveModels = existing.liveModels;
782
+ }
783
+ if (existing && getProviderRegistryEntry(provider)?.allowKeyAuthOverride === true) {
784
+ // Shared sanitizeApiKeyValue trim / no-CRLF checks from api-key pool writes.
785
+ let storedApiKey = sanitizeApiKeyValue(existing.apiKey);
786
+ const storedApiKeyPool = preservableApiKeyPool(existing.apiKeyPool);
787
+ // Unsafe/blank active key with a usable pool: promote the first safe pool entry so
788
+ // key billing keeps working instead of falling back to oauth while pool keys remain.
789
+ if (storedApiKey === undefined && storedApiKeyPool && storedApiKeyPool.length > 0) {
790
+ storedApiKey = storedApiKeyPool[0]!.key;
791
+ }
792
+ if (storedApiKey !== undefined) {
793
+ const pool = storedApiKeyPool ? [...storedApiKeyPool] : [];
794
+ // Keep routing and listProviderApiKeys in sync: never leave a hidden active key that
795
+ // is absent from the pool (listing would fall back to pool[0] as "active").
796
+ if (!pool.some(entry => entry.key === storedApiKey)) {
797
+ pool.push({ id: apiKeyPoolEntryId(storedApiKey), key: storedApiKey });
798
+ }
799
+ next.apiKey = storedApiKey;
800
+ next.apiKeyPool = pool;
801
+ const previousModeAllowsKey = existing.authMode === "key" || existing.authMode === undefined;
802
+ if (previousModeAllowsKey) next.authMode = "key";
803
+ }
804
+ }
805
+ config.providers[provider] = next;
806
+ if (provider === GOOGLE_ANTIGRAVITY_PROVIDER) {
807
+ config.googleAntigravityStaticCatalogVersion = GOOGLE_ANTIGRAVITY_STATIC_CATALOG_VERSION;
808
+ }
809
+ }
810
+
811
+ interface RunLoginDeps {
812
+ saveCredential?: typeof saveCredential;
813
+ saveAccountCredential?: typeof saveAccountCredential;
814
+ loadConfig?: typeof loadConfig;
815
+ saveConfig?: typeof saveConfig;
816
+ settleKiroLoginTransaction?: typeof settleKiroLoginTransaction;
817
+ removeAccount?: typeof removeAccount;
818
+ setActiveAccount?: typeof setActiveAccount;
819
+ }
820
+
821
+ /** Roll back only accounts created by this forced login, preserving concurrent refreshes of others. */
822
+ async function rollbackForcedKiroAccountWrite(
823
+ provider: string,
824
+ previousActiveId: string | undefined,
825
+ previousAccountIds: ReadonlySet<string>,
826
+ deps: Pick<RunLoginDeps, "removeAccount" | "setActiveAccount">,
827
+ ): Promise<void> {
828
+ const set = getAccountSet(provider);
829
+ if (!set) return;
830
+ for (const account of [...set.accounts]) {
831
+ if (previousAccountIds.has(account.id)) continue;
832
+ await (deps.removeAccount ?? removeAccount)(provider, account.id);
833
+ }
834
+ if (previousActiveId && getAccountCredential(provider, previousActiveId)) {
835
+ await (deps.setActiveAccount ?? setActiveAccount)(provider, previousActiveId);
836
+ }
837
+ }
838
+
839
+ /** Run the login flow, persist the credential + upsert the provider entry to disk, return cred. */
840
+ export async function runLogin(
841
+ provider: string,
842
+ ctrl: OAuthController,
843
+ opts?: LoginOpts,
844
+ deps: RunLoginDeps = {},
845
+ ): Promise<OAuthCredentials> {
846
+ const def = OAUTH_PROVIDERS[provider];
847
+ if (!def) throw new UnsupportedOAuthProviderError(provider);
848
+ const loadLatestConfig = deps.loadConfig ?? loadConfig;
849
+ const saveLatestConfig = deps.saveConfig ?? saveConfig;
850
+ if (provider !== "chatgpt") {
851
+ const preflightConfig = loadLatestConfig();
852
+ const namespaceCollision = codexAccountNamespaceProviderCollisionError(
853
+ preflightConfig.codexAccountNamespaces,
854
+ provider,
855
+ );
856
+ if (namespaceCollision) throw new Error(namespaceCollision);
857
+ }
858
+ // loginKiro keys its pending CLI-session transaction by object identity. Keep this exact object
859
+ // for settlement even when source normalization below creates a derived credential object.
860
+ const shouldRollbackKiroAccounts = provider === "kiro" && opts?.forceLogin === true;
861
+ const previousKiroAccounts = shouldRollbackKiroAccounts ? getAccountSet(provider) : undefined;
862
+ const previousKiroActiveId = previousKiroAccounts?.activeAccountId;
863
+ const previousKiroAccountIds = new Set(previousKiroAccounts?.accounts.map(account => account.id) ?? []);
864
+ const rawCred = await def.login(ctrl, opts);
865
+ const cred: OAuthCredentials = rawCred.source ? rawCred : { ...rawCred, source: "oauth" };
866
+ const settleKiroTransaction = deps.settleKiroLoginTransaction ?? settleKiroLoginTransaction;
867
+ try {
868
+ // Validate the provider row before credential persistence. A namespace claimed during the
869
+ // credential write is handled again below before the latest row is re-upserted.
870
+ if (provider !== "chatgpt") {
871
+ const preCommitConfig = loadLatestConfig();
872
+ upsertOAuthProvider(preCommitConfig, provider);
873
+ }
874
+ if (opts?.reauthAccountId) {
875
+ const existing = getAccountCredential(provider, opts.reauthAccountId);
876
+ if (!existing) throw new Error(`Unknown account for reauth: ${opts.reauthAccountId}`);
877
+ if (!existing.accountId && !existing.email) {
878
+ throw new Error("Could not verify signed-in account identity for reauth.");
879
+ }
880
+ const identityMatches = existing.accountId && cred.accountId
881
+ ? existing.accountId === cred.accountId
882
+ : existing.email && cred.email
883
+ ? existing.email.toLowerCase() === cred.email.toLowerCase()
884
+ : false;
885
+ if (!identityMatches) {
886
+ throw new Error("Signed-in account does not match the selected account. Sign in with the same account.");
887
+ }
888
+ await (deps.saveAccountCredential ?? saveAccountCredential)(provider, opts.reauthAccountId, cred);
889
+ } else {
890
+ await (deps.saveCredential ?? saveCredential)(provider, cred, {
891
+ preserveIdentityless: provider === "kiro" && opts?.forceLogin === true,
892
+ });
893
+ }
894
+ if (provider !== "chatgpt") {
895
+ // Re-run against post-credential state so same-provider API-key additions, removals,
896
+ // and active-key switches survive. A late namespace claim wins over provider creation.
897
+ const latestConfig = loadLatestConfig();
898
+ const lateCollision = codexAccountNamespaceProviderCollisionError(
899
+ latestConfig.codexAccountNamespaces,
900
+ provider,
901
+ );
902
+ if (lateCollision) {
903
+ throw new Error(
904
+ `${lateCollision}. The credential for "${provider}" was saved, but the provider entry was not written. `
905
+ + "Rename the account selector, then re-run the login.",
906
+ );
907
+ }
908
+ upsertOAuthProvider(latestConfig, provider);
909
+ saveLatestConfig(latestConfig);
910
+ }
911
+ } catch (error) {
912
+ const errors: unknown[] = [error];
913
+ if (shouldRollbackKiroAccounts) {
914
+ try {
915
+ await rollbackForcedKiroAccountWrite(provider, previousKiroActiveId, previousKiroAccountIds, deps);
916
+ } catch (rollbackError) {
917
+ errors.push(rollbackError);
918
+ }
919
+ }
920
+ try {
921
+ settleKiroTransaction(rawCred, false);
922
+ } catch (restoreError) {
923
+ errors.push(restoreError);
924
+ }
925
+ if (errors.length > 1) {
926
+ throw new AggregateError(
927
+ errors,
928
+ "Kiro login persistence failed and the previous Kiro CLI session could not be restored.",
929
+ );
930
+ }
931
+ throw error;
932
+ }
933
+ settleKiroTransaction(rawCred, true);
934
+ if (provider !== "chatgpt") {
935
+ try {
936
+ const { clearAccountQuotaCache, clearProviderQuotaCache } = await import("../providers/quota");
937
+ clearProviderQuotaCache();
938
+ clearAccountQuotaCache(provider);
939
+ } catch {
940
+ // Quota module may be unavailable in tightly scoped unit tests.
941
+ }
942
+ }
943
+ return cred;
944
+ }
945
+
946
+ /**
947
+ * GUI async login: start the flow, return the auth URL EARLY (the flow keeps running in the
948
+ * background until the callback server captures the redirect), with a concurrency guard and an
949
+ * error surfaced via getLoginStatus().
950
+ *
951
+ * Manual fallback: when the browser cannot reach the loopback callback (remote GUI, SSH, blocked
952
+ * localhost), the GUI can POST the final redirect URL or authorization code via
953
+ * submitManualLoginCode(), which feeds OAuthController.onManualCodeInput.
954
+ */
955
+ const loginState = new Map<string, { error?: string; done: boolean }>();
956
+ const loginAbort = new Map<string, AbortController>();
957
+
958
+ /** Pending paste for a login in progress: either a waiter or a stashed early submission. */
959
+ interface ManualCodeSlot {
960
+ pendingInput?: string;
961
+ resolve?: (value: string) => void;
962
+ /** Registered by the callback flow so submits can validate state synchronously. */
963
+ expectedState?: string;
964
+ }
965
+ const loginManual = new Map<string, ManualCodeSlot>();
966
+ const OAUTH_PENDING_CODE_MAX_BYTES = 4 * 1024;
967
+ let lastOAuthFlowReconciledGeneration = 0;
968
+
969
+ export function reconcileOAuthFlowState(context: GenerationContext): number {
970
+ if (context.generation <= lastOAuthFlowReconciledGeneration) return 0;
971
+ let removed = 0;
972
+ for (const [provider, state] of loginState) {
973
+ if (context.providerNames.has(provider) || !state.done || loginAbort.has(provider)) continue;
974
+ if (loginState.delete(provider)) removed += 1;
975
+ if (loginManual.delete(provider)) removed += 1;
976
+ if (loginAbort.delete(provider)) removed += 1;
977
+ }
978
+ lastOAuthFlowReconciledGeneration = context.generation;
979
+ return removed;
980
+ }
981
+
982
+ function clearManualCodeSlot(provider: string): void {
983
+ loginManual.delete(provider);
984
+ }
985
+
986
+ function ensureManualCodeSlot(provider: string): ManualCodeSlot {
987
+ let slot = loginManual.get(provider);
988
+ if (!slot) {
989
+ slot = {};
990
+ loginManual.set(provider, slot);
991
+ }
992
+ return slot;
993
+ }
994
+
995
+ /** Wait for a GUI/CLI paste of the OAuth redirect URL or code (or return a stashed early submit). */
996
+ function waitForManualLoginCode(provider: string, signal: AbortSignal, expectedState?: string): Promise<string> {
997
+ if (signal.aborted) {
998
+ return Promise.reject(new Error(`OAuth callback cancelled: ${signal.reason}`));
999
+ }
1000
+ const slot = ensureManualCodeSlot(provider);
1001
+ if (expectedState !== undefined) slot.expectedState = expectedState;
1002
+ if (slot.pendingInput !== undefined) {
1003
+ const value = slot.pendingInput;
1004
+ slot.pendingInput = undefined;
1005
+ return Promise.resolve(value);
1006
+ }
1007
+ return new Promise<string>((resolve, reject) => {
1008
+ const onAbort = () => {
1009
+ if (slot.resolve === resolve) slot.resolve = undefined;
1010
+ reject(new Error(`OAuth callback cancelled: ${signal.reason}`));
1011
+ };
1012
+ signal.addEventListener("abort", onAbort, { once: true });
1013
+ slot.resolve = (value: string) => {
1014
+ signal.removeEventListener("abort", onAbort);
1015
+ if (slot.resolve === resolve) slot.resolve = undefined;
1016
+ resolve(value);
1017
+ };
1018
+ });
1019
+ }
1020
+
1021
+ /**
1022
+ * Feed a pasted redirect URL or authorization code into an in-progress GUI login.
1023
+ * Returns ok:false when no login is waiting (or input is empty). Invalid pastes are accepted
1024
+ * here and re-prompted by the OAuth callback loop if they cannot be parsed / fail state checks.
1025
+ */
1026
+ export function submitManualLoginCode(provider: string, input: string): { ok: true } | { ok: false; error: string } {
1027
+ const trimmed = input.trim();
1028
+ if (!trimmed) return { ok: false, error: "empty code" };
1029
+ if (retainedUtf8Bytes(trimmed) > OAUTH_PENDING_CODE_MAX_BYTES) return { ok: false, error: "code too large" };
1030
+ const st = loginState.get(provider);
1031
+ if (!st || st.done) return { ok: false, error: "no login in progress" };
1032
+ const slot = ensureManualCodeSlot(provider);
1033
+ // Synchronous validation (validated request/ack): reject un-parseable input and
1034
+ // authorization responses (url/query kind) whose state is missing or mismatched
1035
+ // once the flow has registered its expected state. Raw codes stay in-session-PKCE
1036
+ // protected. Early posts (flow not yet waiting, no expectedState) are stashed and
1037
+ // re-validated by the callback loop.
1038
+ const parsed = parseCallbackInput(trimmed);
1039
+ if (!parsed.code) return { ok: false, error: "no authorization code found in input" };
1040
+ if (parsed.kind !== "raw" && slot.expectedState !== undefined) {
1041
+ if (parsed.state === undefined) return { ok: false, error: "redirect URL is missing the state parameter" };
1042
+ if (parsed.state !== slot.expectedState) return { ok: false, error: "state mismatch — paste the redirect URL from THIS login attempt" };
1043
+ }
1044
+ if (slot.resolve) {
1045
+ const resolve = slot.resolve;
1046
+ slot.resolve = undefined;
1047
+ resolve(trimmed);
1048
+ } else {
1049
+ // Race: GUI may POST before the flow reaches onManualCodeInput — stash for the waiter.
1050
+ slot.pendingInput = trimmed;
1051
+ }
1052
+ return { ok: true };
1053
+ }
1054
+
1055
+ export interface OAuthAccountSummary { id: string; alias?: string; email?: string; active: boolean; needsReauth?: boolean; expiresAt?: number }
1056
+
1057
+ export function getLoginStatus(provider: string): { loggedIn: boolean; email?: string; source?: OAuthCredentials["source"]; error?: string; done: boolean; activeAccountId?: string; accounts?: OAuthAccountSummary[] } {
1058
+ const cred = getCredential(provider);
1059
+ const st = loginState.get(provider);
1060
+ const set = getAccountSet(provider);
1061
+ // Full email on purpose: local dashboard/CLI account management needs to distinguish
1062
+ // multiauth slots (masked local-parts collide). Tokens stay out of this surface.
1063
+ const accounts: OAuthAccountSummary[] | undefined = set?.accounts.map(a => ({
1064
+ id: a.id,
1065
+ ...(a.alias ? { alias: a.alias } : {}),
1066
+ email: a.credential.email ?? undefined,
1067
+ active: a.id === set.activeAccountId,
1068
+ ...(a.needsReauth ? { needsReauth: true } : {}),
1069
+ expiresAt: a.credential.expires,
1070
+ }));
1071
+ return {
1072
+ loggedIn: !!cred,
1073
+ email: cred?.email ?? undefined,
1074
+ source: cred?.source,
1075
+ error: st?.error,
1076
+ done: st?.done ?? false,
1077
+ ...(set ? { activeAccountId: set.activeAccountId, accounts } : {}),
1078
+ };
1079
+ }
1080
+
1081
+ /** Token-safe per-provider login state for the CLI `ocx status` logins section (no tokens). */
1082
+ export function oauthLoginSummary(): Array<{ provider: string; loggedIn: boolean; email?: string }> {
1083
+ return listOAuthProviders().map(provider => {
1084
+ const status = getLoginStatus(provider);
1085
+ return { provider, loggedIn: status.loggedIn, ...(status.email ? { email: status.email } : {}) };
1086
+ });
1087
+ }
1088
+
1089
+ export function clearLoginState(provider: string): void {
1090
+ loginAbort.get(provider)?.abort("cleared");
1091
+ loginAbort.delete(provider);
1092
+ clearManualCodeSlot(provider);
1093
+ loginState.delete(provider);
1094
+ }
1095
+
1096
+ export function cancelLoginFlow(provider: string): boolean {
1097
+ const ctrl = loginAbort.get(provider);
1098
+ const existing = loginState.get(provider);
1099
+ if (!ctrl && (!existing || existing.done)) return false;
1100
+ ctrl?.abort("cancelled");
1101
+ loginAbort.delete(provider);
1102
+ clearManualCodeSlot(provider);
1103
+ loginState.set(provider, { done: true, error: "Login cancelled" });
1104
+ return true;
1105
+ }
1106
+
1107
+ export async function startLoginFlow(
1108
+ provider: string,
1109
+ opts?: LoginOpts,
1110
+ lifecycle?: LoginFlowLifecycle,
1111
+ ): Promise<{ url: string; instructions?: string; deviceCode?: string }> {
1112
+ const def = OAUTH_PROVIDERS[provider];
1113
+ if (!def) throw new UnsupportedOAuthProviderError(provider);
1114
+ const existing = loginState.get(provider);
1115
+ if (existing && !existing.done) {
1116
+ throw new Error(`A login for ${provider} is already in progress`);
1117
+ }
1118
+ clearManualCodeSlot(provider);
1119
+ loginState.set(provider, { done: false });
1120
+ const abort = new AbortController();
1121
+ loginAbort.set(provider, abort);
1122
+ return new Promise((resolve, reject) => {
1123
+ let urlResolved = false;
1124
+ const ctrl: OAuthController = {
1125
+ onAuth: ({ url, instructions, deviceCode }) => {
1126
+ urlResolved = true;
1127
+ resolve({ url, instructions, deviceCode });
1128
+ },
1129
+ onProgress: () => {},
1130
+ // GUI fallback when the browser cannot hit the loopback callback server.
1131
+ onManualCodeInput: (expectedState?: string) => waitForManualLoginCode(provider, abort.signal, expectedState),
1132
+ signal: abort.signal,
1133
+ };
1134
+ const settle = async (error?: unknown): Promise<void> => {
1135
+ let finalError = error;
1136
+ try {
1137
+ await lifecycle?.onSettled?.();
1138
+ } catch (settleError) {
1139
+ // A successful credential/config commit is not fully live until its owner reconciles the
1140
+ // runtime config. For an already-failed login, keep the original recovery error.
1141
+ if (finalError === undefined) finalError = settleError;
1142
+ }
1143
+ if (finalError === undefined) {
1144
+ loginAbort.delete(provider);
1145
+ clearManualCodeSlot(provider);
1146
+ loginState.set(provider, { done: true });
1147
+ // Local-token import (grok-cli / Claude Code keychain) completes WITHOUT firing onAuth —
1148
+ // resolve so the GUI call returns instead of hanging.
1149
+ if (!urlResolved) resolve({ url: "", instructions: "Logged in via an existing local CLI/keychain token — no browser needed." });
1150
+ return;
1151
+ }
1152
+
1153
+ const e = finalError;
1154
+ loginAbort.delete(provider);
1155
+ clearManualCodeSlot(provider);
1156
+ const msg = e instanceof Error ? e.message : String(e);
1157
+ loginState.set(provider, { done: true, error: msg });
1158
+ if (!urlResolved) reject(e);
1159
+ };
1160
+ // Background: runLogin persists the credential + provider entry to disk. The lifecycle hook
1161
+ // lets a long-lived server config adopt that settled state before clients observe done=true.
1162
+ void runLogin(provider, ctrl, opts).then(
1163
+ () => settle(),
1164
+ (e: unknown) => settle(e),
1165
+ ).catch((e: unknown) => {
1166
+ // settle catches lifecycle failures, so this is only a defensive promise-boundary guard.
1167
+ loginAbort.delete(provider);
1168
+ clearManualCodeSlot(provider);
1169
+ const msg = e instanceof Error ? e.message : String(e);
1170
+ loginState.set(provider, { done: true, error: msg });
1171
+ if (!urlResolved) reject(e);
1172
+ });
1173
+ });
1174
+ }