@bitkyc08/opencodex 2.86.0 → 2.87.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 (378) hide show
  1. package/gui/dist/assets/App-ZfYbLHkB.js +58 -0
  2. package/gui/dist/assets/App-wT3mXqFz.css +1 -0
  3. package/gui/dist/assets/{Tray-DyR9yBee.js → Tray-DCd-A1az.js} +1 -1
  4. package/gui/dist/assets/index-CfYDMj34.js +94 -0
  5. package/gui/dist/assets/index-DPeB9HpV.css +1 -0
  6. package/gui/dist/assets/quota-summary-ChvmKGOk.js +1 -0
  7. package/gui/dist/index.html +2 -2
  8. package/package.json +1 -1
  9. package/src/adapters/anthropic/beta-allowlist.ts +9 -8
  10. package/src/adapters/anthropic/beta-body-pairs.ts +93 -0
  11. package/src/adapters/anthropic/passthrough.ts +7 -23
  12. package/src/adapters/anthropic-progress.ts +84 -0
  13. package/src/adapters/anthropic.ts +108 -21
  14. package/src/adapters/base.ts +2 -0
  15. package/src/adapters/claude-cli/adapter.ts +111 -23
  16. package/src/adapters/claude-cli/profiles.ts +2 -1
  17. package/src/adapters/claude-cli/stable-replay.ts +108 -0
  18. package/src/adapters/claude-cli/usage-admission.ts +230 -0
  19. package/src/adapters/coding-agent/protocol.ts +1 -1
  20. package/src/adapters/coding-agent/turn.ts +18 -2
  21. package/src/adapters/dsh-account.ts +97 -0
  22. package/src/adapters/google.ts +35 -8
  23. package/src/adapters/openai-chat/messages.ts +4 -3
  24. package/src/adapters/openai-chat/passthrough.ts +8 -2
  25. package/src/adapters/openai-chat/reasoning-replay.ts +52 -0
  26. package/src/adapters/openai-responses/compaction-search-history.ts +14 -7
  27. package/src/adapters/openai-responses/passthrough.ts +57 -125
  28. package/src/adapters/openai-responses/request-strips.ts +25 -3
  29. package/src/adapters/openai-responses/transport.ts +81 -0
  30. package/src/adapters/qoder/adapter.ts +4 -2
  31. package/src/adapters/registry.ts +5 -0
  32. package/src/adapters/zed.ts +26 -1
  33. package/src/advisor/activity.ts +55 -0
  34. package/src/advisor/budget.ts +62 -0
  35. package/src/advisor/consult.ts +4 -1
  36. package/src/advisor/context.ts +16 -4
  37. package/src/advisor/evidence-tool.ts +21 -0
  38. package/src/advisor/evidence.ts +117 -0
  39. package/src/advisor/runtime.ts +79 -31
  40. package/src/advisor/settings.ts +11 -2
  41. package/src/bridge/errors.ts +19 -1
  42. package/src/bridge/internal.ts +3 -1
  43. package/src/bridge/response-json.ts +18 -3
  44. package/src/bridge/sse.ts +58 -8
  45. package/src/bridge/terminal-refusal.ts +59 -0
  46. package/src/claude/desktop-3p-library.ts +32 -7
  47. package/src/claude/desktop-3p.ts +55 -13
  48. package/src/claude/desktop-first-party.ts +1 -1
  49. package/src/claude/desktop-picker-platform.ts +4 -0
  50. package/src/claude/desktop-picker.ts +15 -8
  51. package/src/claude/desktop-system-proxy.ts +50 -4
  52. package/src/claude/inbound-content-options.ts +11 -0
  53. package/src/claude/inbound.ts +33 -0
  54. package/src/claude/intercept/picker-ca-startup.ts +2 -1
  55. package/src/claude/intercept/picker-runtime.ts +6 -5
  56. package/src/claude/intercept/picker-trust-windows.ts +338 -0
  57. package/src/claude/intercept/picker-trust.ts +19 -0
  58. package/src/claude/intercept/port-policy.ts +30 -0
  59. package/src/claude/intercept/runtime.ts +97 -94
  60. package/src/claude/outbound.ts +38 -13
  61. package/src/cli/account-api.ts +1 -0
  62. package/src/cli/account-policy-dto.ts +6 -0
  63. package/src/cli/account-policy.ts +34 -5
  64. package/src/cli/account-preservation.ts +44 -0
  65. package/src/cli/account.ts +8 -2
  66. package/src/cli/advisor.ts +17 -3
  67. package/src/cli/capabilities-accounts.ts +20 -1
  68. package/src/cli/capabilities-agents-routing.ts +5 -2
  69. package/src/cli/capabilities-base.ts +38 -10
  70. package/src/cli/capabilities-observe-system.ts +63 -3
  71. package/src/cli/capabilities-provider-models.ts +11 -0
  72. package/src/cli/codex-cli-update.ts +151 -41
  73. package/src/cli/codex-provider.ts +44 -0
  74. package/src/cli/combo-input.ts +10 -1
  75. package/src/cli/combo-result.ts +1 -1
  76. package/src/cli/combo.ts +2 -1
  77. package/src/cli/cross-home-owner.ts +17 -14
  78. package/src/cli/dispatch.ts +25 -4
  79. package/src/cli/doctor.ts +6 -1
  80. package/src/cli/ensure-desktop-cold-start.ts +60 -7
  81. package/src/cli/help.ts +3 -2
  82. package/src/cli/index.ts +1 -1
  83. package/src/cli/integration-plan-dto.ts +1 -0
  84. package/src/cli/log-history.ts +172 -0
  85. package/src/cli/models-runtime-subcommands.ts +1 -0
  86. package/src/cli/models-runtime.ts +72 -8
  87. package/src/cli/observe-stream.ts +2 -0
  88. package/src/cli/observe.ts +16 -4
  89. package/src/cli/provider.ts +27 -5
  90. package/src/cli/registry.ts +28 -8
  91. package/src/cli/runtime-api.ts +5 -3
  92. package/src/cli/system-command.ts +40 -8
  93. package/src/cli/uninstall-client-state.ts +32 -0
  94. package/src/cli/usage-compare.ts +309 -0
  95. package/src/cli/usage-model-search.ts +4 -4
  96. package/src/cli/usage-report.ts +2 -2
  97. package/src/cli/v2.ts +1 -1
  98. package/src/client/machine-listener.ts +1 -3
  99. package/src/client/runtime.ts +1 -0
  100. package/src/clients/client-path.ts +51 -0
  101. package/src/clients/config-export/contracts.ts +2 -1
  102. package/src/clients/config-export.ts +18 -44
  103. package/src/clients/gentle-shell-path.ts +87 -0
  104. package/src/codex/account-auto-switch.ts +2 -0
  105. package/src/codex/account-identity.ts +29 -0
  106. package/src/codex/account-label.ts +1 -1
  107. package/src/codex/account-lifecycle.ts +21 -5
  108. package/src/codex/account-preservation.ts +28 -0
  109. package/src/codex/account-use-remaining.ts +23 -0
  110. package/src/codex/app-server-processes.ts +97 -7
  111. package/src/codex/app-server-restart-service.ts +55 -4
  112. package/src/codex/auth-api/account-list.ts +4 -0
  113. package/src/codex/auth-api/account-pause-group.ts +6 -50
  114. package/src/codex/auth-api/account-preservation.ts +57 -0
  115. package/src/codex/auth-api/routes.ts +8 -0
  116. package/src/codex/auth-api/use-remaining-route.ts +36 -0
  117. package/src/codex/auth-api.ts +0 -1
  118. package/src/codex/auth-context.ts +2 -2
  119. package/src/codex/catalog/auto-review.ts +20 -3
  120. package/src/codex/catalog/build-entries.ts +9 -1
  121. package/src/codex/catalog/combo-member.ts +14 -5
  122. package/src/codex/catalog/configured-tiers.ts +1 -1
  123. package/src/codex/catalog/gather-capture.ts +2 -0
  124. package/src/codex/catalog/metadata.ts +11 -9
  125. package/src/codex/catalog/model-hints.ts +21 -16
  126. package/src/codex/catalog/model-visibility.ts +3 -1
  127. package/src/codex/catalog/parsing.ts +30 -86
  128. package/src/codex/catalog/provider-models.ts +36 -34
  129. package/src/codex/catalog/routed-gather.ts +12 -13
  130. package/src/codex/cli-install-provenance.ts +105 -17
  131. package/src/codex/cli-update-plan.ts +545 -0
  132. package/src/codex/config-write-lock.ts +23 -2
  133. package/src/codex/desktop-app/picker-freshness.ts +96 -0
  134. package/src/codex/desktop-app-restart.ts +21 -0
  135. package/src/codex/desktop-compatibility/installed-build.ts +24 -6
  136. package/src/codex/desktop-compatibility/routing-preflight.ts +1 -1
  137. package/src/codex/desktop-compatibility/runtime.ts +12 -3
  138. package/src/codex/desktop-compatibility/windows-key-protection.ts +115 -9
  139. package/src/codex/features.ts +85 -45
  140. package/src/codex/inject/history-failure.ts +27 -0
  141. package/src/codex/inject/restore.ts +24 -2
  142. package/src/codex/inject/routing-classify.ts +4 -3
  143. package/src/codex/inject.ts +18 -27
  144. package/src/codex/main-account-hard-lock.ts +30 -1
  145. package/src/codex/native-profile-startup.ts +1 -1
  146. package/src/codex/ocx-compaction-history.ts +12 -10
  147. package/src/codex/paths.ts +86 -1
  148. package/src/codex/provider-routing.ts +174 -0
  149. package/src/codex/refresh.ts +2 -2
  150. package/src/codex/routing/health-store.ts +1 -1
  151. package/src/codex/routing/selection.ts +4 -3
  152. package/src/codex/routing/thread-affinity.ts +1 -1
  153. package/src/codex/signed-in-identity.ts +43 -0
  154. package/src/codex/subagent-model-fallback.ts +1 -1
  155. package/src/codex/sync.ts +6 -1
  156. package/src/codex/user-identity.ts +16 -5
  157. package/src/codex/windows-local-appdata.ts +84 -0
  158. package/src/combos/failover.ts +6 -1
  159. package/src/combos/index.ts +1 -0
  160. package/src/combos/resolve.ts +46 -10
  161. package/src/combos/types.ts +15 -0
  162. package/src/config/diagnostics.ts +33 -6
  163. package/src/config/live-reconcile.ts +38 -2
  164. package/src/config/load-degrade.ts +17 -0
  165. package/src/config/opencode-wire-upgrade.ts +54 -0
  166. package/src/config/opencode-zen-upgrade.ts +52 -0
  167. package/src/config/owner-registry.ts +22 -17
  168. package/src/config/provider-validation.ts +45 -2
  169. package/src/config/schema/config-schema.ts +22 -0
  170. package/src/config/schema/leaf-validators.ts +38 -9
  171. package/src/config/schema/main-account-hard-lock-fallback.ts +26 -0
  172. package/src/config/schema/native-reasoning-retention.ts +26 -0
  173. package/src/config.ts +2 -0
  174. package/src/generated/compatibility-version.json +608 -284
  175. package/src/grok/sync.ts +1 -1
  176. package/src/images/loop.ts +46 -8
  177. package/src/integrations/catalog-refresh.ts +1 -1
  178. package/src/integrations/mutation-plan.ts +1 -0
  179. package/src/integrations/registry.ts +8 -0
  180. package/src/lib/codex-restart-contract.ts +15 -0
  181. package/src/lib/desktop-upstream-tunnel.ts +18 -53
  182. package/src/lib/empty-tool-catalog.ts +62 -0
  183. package/src/lib/errors.ts +19 -3
  184. package/src/lib/gcp-adc.ts +96 -9
  185. package/src/lib/gcp-credential-input.ts +12 -0
  186. package/src/lib/lab-live-route-production.ts +5 -0
  187. package/src/lib/opaque-proxy-tunnel.ts +217 -0
  188. package/src/lib/redact.ts +2 -2
  189. package/src/oauth/account-quota-rank.ts +69 -2
  190. package/src/oauth/anthropic-account-refusal.ts +20 -2
  191. package/src/oauth/anthropic-oauth-definitions.ts +3 -1
  192. package/src/oauth/anthropic-routing.ts +1 -21
  193. package/src/oauth/anthropic.ts +38 -7
  194. package/src/oauth/callback-server.ts +27 -1
  195. package/src/oauth/chatgpt-device.ts +1 -1
  196. package/src/oauth/chatgpt-shared.ts +83 -0
  197. package/src/oauth/chatgpt.ts +8 -72
  198. package/src/oauth/dsh.ts +203 -0
  199. package/src/oauth/generic-account-failover.ts +33 -7
  200. package/src/oauth/generic-session-affinity.ts +125 -0
  201. package/src/oauth/index.ts +184 -109
  202. package/src/oauth/local-token-detect.ts +21 -1
  203. package/src/oauth/login-cli-errors.ts +44 -0
  204. package/src/oauth/login-cli.ts +69 -7
  205. package/src/oauth/login-flow-state.ts +371 -23
  206. package/src/oauth/meta-muse-errors.ts +41 -0
  207. package/src/oauth/meta-muse-keychain.ts +52 -0
  208. package/src/oauth/meta-muse.ts +51 -84
  209. package/src/oauth/model-discovery-auth.ts +3 -0
  210. package/src/oauth/pool-settings-capability.ts +4 -2
  211. package/src/oauth/refresh-flight-retirement.ts +48 -0
  212. package/src/oauth/session-affinity-key.ts +23 -0
  213. package/src/oauth/store-anthropic-instance.ts +9 -2
  214. package/src/oauth/store.ts +12 -5
  215. package/src/protocols/encoders/adapter-events.ts +5 -4
  216. package/src/protocols/encoders/messages.ts +9 -6
  217. package/src/protocols/provider-summary.ts +2 -1
  218. package/src/providers/api-key-cooldowns.ts +49 -0
  219. package/src/providers/api-key-resolve.ts +11 -6
  220. package/src/providers/api-key-response-ownership.ts +57 -0
  221. package/src/providers/api-key-selection.ts +31 -6
  222. package/src/providers/api-keys.ts +214 -3
  223. package/src/providers/claude-cli-usage.ts +96 -0
  224. package/src/providers/dsh-account-destination.ts +31 -0
  225. package/src/providers/dsh-account-models.ts +41 -0
  226. package/src/providers/gcp-credential-cleanup.ts +59 -0
  227. package/src/providers/key-failover.ts +54 -50
  228. package/src/providers/key-store.ts +7 -2
  229. package/src/providers/model-rename-fields.ts +3 -0
  230. package/src/providers/model-rename-migration.ts +2 -2
  231. package/src/providers/openai-tiers-destination.ts +11 -2
  232. package/src/providers/openai-tiers.ts +5 -12
  233. package/src/providers/opencode-go-transport.ts +3 -1
  234. package/src/providers/quota/account-cache.ts +6 -5
  235. package/src/providers/quota/account-row.ts +34 -0
  236. package/src/providers/quota/antigravity.ts +28 -7
  237. package/src/providers/quota/dsh-account.ts +70 -0
  238. package/src/providers/quota/vendor-probes-oauth.ts +1 -93
  239. package/src/providers/quota-types.ts +2 -0
  240. package/src/providers/quota.ts +14 -20
  241. package/src/providers/registry/entries-core.ts +37 -8
  242. package/src/providers/registry/types.ts +1 -1
  243. package/src/providers/request-pacing.ts +77 -19
  244. package/src/providers/routed-config.ts +339 -0
  245. package/src/providers/service-tier.ts +2 -1
  246. package/src/quota/window-mapping.ts +5 -2
  247. package/src/responses/anthropic-progress.ts +34 -0
  248. package/src/responses/assistant-replay.ts +35 -0
  249. package/src/responses/compaction-history-framing.ts +65 -0
  250. package/src/responses/compaction.ts +6 -1
  251. package/src/responses/incomplete-reason.ts +31 -0
  252. package/src/responses/parser.ts +50 -5
  253. package/src/responses/reasoning-envelope.ts +4 -0
  254. package/src/responses/reasoning-replay-cache.ts +24 -2
  255. package/src/responses/reasoning-retention.ts +173 -0
  256. package/src/responses/retained-compaction.ts +46 -0
  257. package/src/responses/schema.ts +23 -0
  258. package/src/router.ts +8 -321
  259. package/src/routing/health.ts +37 -22
  260. package/src/routing/history/indexer.ts +6 -5
  261. package/src/routing/history/ingest.ts +6 -2
  262. package/src/routing/profile.ts +1 -1
  263. package/src/server/admission-model-scope.ts +17 -0
  264. package/src/server/auth-cors.ts +11 -0
  265. package/src/server/chat-completions.ts +7 -1
  266. package/src/server/chat-native-reasoning.ts +123 -0
  267. package/src/server/chat-native-sse.ts +110 -7
  268. package/src/server/chat-native.ts +76 -17
  269. package/src/server/claude-messages.ts +55 -414
  270. package/src/server/effort-policy.ts +1 -1
  271. package/src/server/images.ts +15 -2
  272. package/src/server/index/model-alias-id.ts +36 -0
  273. package/src/server/index/optional-listeners.ts +2 -0
  274. package/src/server/index/serve-options.ts +9 -1
  275. package/src/server/index/startup-warnings.ts +2 -0
  276. package/src/server/index.ts +1 -1
  277. package/src/server/inference/advisor-diagnostics.ts +31 -0
  278. package/src/server/management/advisor-routes.ts +15 -4
  279. package/src/server/management/claude-desktop-picker-routes.ts +1 -1
  280. package/src/server/management/combo-routes.ts +176 -50
  281. package/src/server/management/companion-routes.ts +1 -1
  282. package/src/server/management/config-routes.ts +75 -4
  283. package/src/server/management/cursor-integration-routes.ts +1 -1
  284. package/src/server/management/model-routes.ts +11 -6
  285. package/src/server/management/model-rows.ts +14 -0
  286. package/src/server/management/native-integration-routes.ts +2 -7
  287. package/src/server/management/native-reasoning-retention-routes.ts +41 -0
  288. package/src/server/management/oauth-account-routes.ts +87 -14
  289. package/src/server/management/provider-gcp-credential-routes.ts +29 -0
  290. package/src/server/management/provider-key-pacing-routes.ts +31 -0
  291. package/src/server/management/provider-overwrite-carry.ts +7 -0
  292. package/src/server/management/provider-routes.ts +25 -30
  293. package/src/server/management/route-registry.ts +10 -1
  294. package/src/server/management-api.ts +3 -1
  295. package/src/server/messages-native-tool-guard.ts +185 -0
  296. package/src/server/messages-native.ts +101 -21
  297. package/src/server/messages-shared.ts +431 -0
  298. package/src/server/relay.ts +1 -1
  299. package/src/server/request-log-terminal-status.ts +1 -1
  300. package/src/server/request-log.ts +6 -1
  301. package/src/server/responses/account-change-state.ts +8 -2
  302. package/src/server/responses/adapter-continuation.ts +13 -1
  303. package/src/server/responses/adapter-delivery.ts +19 -6
  304. package/src/server/responses/adapter-dispatch.ts +19 -2
  305. package/src/server/responses/advisor-retention.ts +117 -0
  306. package/src/server/responses/advisor-slot.ts +73 -41
  307. package/src/server/responses/codex-ws-wire.ts +15 -8
  308. package/src/server/responses/compact.ts +303 -125
  309. package/src/server/responses/compaction-recovery.ts +18 -10
  310. package/src/server/responses/compaction-retention.ts +61 -0
  311. package/src/server/responses/context-overflow.ts +11 -6
  312. package/src/server/responses/core-auth.ts +6 -0
  313. package/src/server/responses/core-codex-account.ts +11 -1
  314. package/src/server/responses/core-combo-failure.ts +2 -0
  315. package/src/server/responses/core-combo.ts +120 -36
  316. package/src/server/responses/core-errors.ts +3 -3
  317. package/src/server/responses/core-lifetime.ts +5 -2
  318. package/src/server/responses/core-opaque-recovery.ts +35 -0
  319. package/src/server/responses/core-options.ts +5 -0
  320. package/src/server/responses/core-pipeline.ts +141 -0
  321. package/src/server/responses/core-replay.ts +18 -2
  322. package/src/server/responses/core.ts +2 -115
  323. package/src/server/responses/fetch-helpers.ts +73 -19
  324. package/src/server/responses/main-hard-lock-routing.ts +109 -0
  325. package/src/server/responses/native-response-control.ts +4 -2
  326. package/src/server/responses/passthrough-delivery.ts +3 -1
  327. package/src/server/responses/passthrough-dispatch.ts +15 -13
  328. package/src/server/responses/request-prepare.ts +76 -12
  329. package/src/server/responses/request-sidecar-auth.ts +2 -2
  330. package/src/server/responses/request-transport.ts +104 -27
  331. package/src/server/responses/response-effects.ts +1 -0
  332. package/src/server/responses/run-turn-execution.ts +96 -30
  333. package/src/server/responses/sidecar-execution.ts +20 -2
  334. package/src/server/responses/terminal-guard.ts +2 -16
  335. package/src/service/auth-preflight.ts +71 -0
  336. package/src/service/cli.ts +4 -2
  337. package/src/service/definition.ts +97 -0
  338. package/src/service/guards.ts +1 -61
  339. package/src/service/health-sentinel-linux.ts +112 -0
  340. package/src/service/health-sentinel-main.ts +76 -57
  341. package/src/service/health.ts +5 -89
  342. package/src/service/launchd.ts +27 -7
  343. package/src/service/logging.ts +72 -9
  344. package/src/service/repair.ts +2 -1
  345. package/src/service/state.ts +7 -8
  346. package/src/service/systemd.ts +1 -1
  347. package/src/service/windows-guarded-manager.ts +2 -1
  348. package/src/service/windows-ops.ts +1 -1
  349. package/src/service/windows-scheduler.ts +2 -7
  350. package/src/service/windows-taskxml.ts +2 -2
  351. package/src/service/windows-wscript.ts +14 -0
  352. package/src/service.ts +4 -2
  353. package/src/sidecar/auth.ts +2 -0
  354. package/src/types/config.ts +64 -3
  355. package/src/types/opencode-go.ts +61 -0
  356. package/src/types/opencode-zen.ts +29 -0
  357. package/src/types/provider.ts +32 -6
  358. package/src/types/request.ts +23 -2
  359. package/src/types/wire.ts +29 -37
  360. package/src/types.ts +1 -0
  361. package/src/update/npm-invocation.mjs +12 -2
  362. package/src/usage/cost.ts +48 -12
  363. package/src/usage/expected-prices.ts +3 -0
  364. package/src/usage/ledger-retention.ts +2 -1
  365. package/src/usage/ledger-scanner.ts +6 -5
  366. package/src/usage/log.ts +17 -8
  367. package/src/usage/summary.ts +22 -9
  368. package/src/usage/user-cost-overlays.ts +14 -19
  369. package/src/usage/user-cost-validation.ts +38 -0
  370. package/src/vision/eligibility.ts +13 -0
  371. package/src/web-search/loop.ts +55 -5
  372. package/src/web-search/progress-stream.ts +4 -1
  373. package/src/web-search/run-turn-loop.ts +9 -1
  374. package/gui/dist/assets/App-BjlttVLy.js +0 -58
  375. package/gui/dist/assets/App-CSJvmjNs.css +0 -1
  376. package/gui/dist/assets/index-BB6uK6tU.css +0 -1
  377. package/gui/dist/assets/index-BMssP4k3.js +0 -94
  378. package/gui/dist/assets/quota-summary-D8M1bc_R.js +0 -1
@@ -9,12 +9,14 @@ import {
9
9
  requestBoundLocalProviderReload,
10
10
  type LocalProviderReloadResult,
11
11
  } from "../server/local-provider-reload-client";
12
- import { DEPRECATED_OAUTH_PROVIDER_ALIASES, isPublicOAuthProvider, listOAuthProviders, runLogin } from "./index";
12
+ import { DEPRECATED_OAUTH_PROVIDER_ALIASES, isPublicOAuthProvider, listOAuthProviders, publicOAuthAuthenticationErrorMessage, runLogin, supportsCodeLoginMode } from "./index";
13
+ import { classifyLoginCodeError } from "./login-flow-state";
13
14
  import { KEY_LOGIN_PROVIDERS, isKeyLoginProvider, validateApiKey, type KeyLoginProvider } from "./key-providers";
14
15
  import type { OcxConfig, OcxProviderConfig } from "../types";
15
16
  import { configuredAdminToken } from "../lib/admin-secrets";
16
17
  import { codexAccountNamespaceProviderCollisionError } from "../codex/account-namespace-match";
17
18
  import { assertAnthropicInstanceLoginConfig } from "./store-anthropic-instance";
19
+ import { reportOAuthLoginError } from "./login-cli-errors";
18
20
 
19
21
  /**
20
22
  * Seams a test drives in place of a browser, a terminal and a real provider. Production passes
@@ -113,37 +115,97 @@ export function warnIfLiveReloadSkipped(result: LocalProviderReloadResult | null
113
115
  * that used to be their only pointer to it.
114
116
  */
115
117
  export function loginUsageMessage(): string {
116
- return `Usage: ocx login <provider>\n`
118
+ return `Usage: ocx login <provider> [--headless]\n`
119
+ + ` --headless: no browser and no local port — approve in any browser, paste the code\n`
120
+ + ` (${listCodeLoginProviders().join(", ")})\n`
117
121
  + ` Codex / ChatGPT: ocx login codex (account pool, needs a running proxy; 'chatgpt' and\n`
118
122
  + ` 'openai' are the same route. An OpenAI platform key is 'openai-apikey'.)\n`
119
123
  + ` OAuth login: ${listOAuthProviders().join(", ")}\n`
120
124
  + ` API-key login: ${Object.keys(KEY_LOGIN_PROVIDERS).join(", ")}`;
121
125
  }
122
126
 
123
- export async function handleLogin(provider?: string, deps: LoginCliDeps = {}): Promise<void> {
127
+ export interface LoginCliOptions {
128
+ /** `--headless`: no callback server, no browser launch — print the URL and read back the code. */
129
+ codeMode?: boolean;
130
+ }
131
+
132
+ export async function handleLogin(provider?: string, deps: LoginCliDeps = {}, opts: LoginCliOptions = {}): Promise<void> {
124
133
  const name = (provider ?? "").trim().toLowerCase();
134
+ try {
135
+ // Check raw ownership before tolerant loading can print diagnostics or fall back to defaults.
136
+ if (name === "anthropic2") assertAnthropicInstanceLoginConfig({ providers: {} }, name);
137
+ } catch (error) {
138
+ reportOAuthLoginError(error);
139
+ }
125
140
  // A removed provider id reached through its alias still logs in — the merged
126
141
  // successor owns the flow. Warn rather than silently reroute so scripts and
127
142
  // docs that still say `devin-cli` surface the rename to whoever runs them.
128
143
  const alias = DEPRECATED_OAUTH_PROVIDER_ALIASES[name];
129
144
  if (alias) {
130
145
  console.error(`${name} is deprecated; logging in as ${alias}`);
131
- return handleOAuthLogin(alias, deps);
146
+ return handleOAuthLogin(alias, deps, opts);
147
+ }
148
+ if (isPublicOAuthProvider(name)) return handleOAuthLogin(name, deps, opts);
149
+ if (opts.codeMode) {
150
+ console.error(`--headless needs a provider with a code-display login: ${listCodeLoginProviders().join(", ")}`);
151
+ process.exit(1);
132
152
  }
133
- if (isPublicOAuthProvider(name)) return handleOAuthLogin(name, deps);
134
153
  if (isKeyLoginProvider(name)) return handleKeyLogin(name, deps);
135
154
  console.error(loginUsageMessage());
136
155
  process.exit(1);
137
156
  }
138
157
 
139
- export async function handleOAuthLogin(name: string, deps: LoginCliDeps = {}): Promise<void> {
158
+ /** OAuth providers that accept `ocx login <provider> --headless`. */
159
+ export function listCodeLoginProviders(): string[] {
160
+ return listOAuthProviders().filter(supportsCodeLoginMode);
161
+ }
162
+
163
+ /**
164
+ * `--headless`: the provider displays the authorization code instead of redirecting anywhere, so
165
+ * nothing is launched and nothing listens. Used where the browser is on another machine.
166
+ */
167
+ async function handleCodeLogin(name: string, deps: LoginCliDeps): Promise<void> {
168
+ const login = deps.runLogin ?? runLogin;
169
+ if (!supportsCodeLoginMode(name)) {
170
+ console.error(`${name} has no code-display login. Providers that do: ${listCodeLoginProviders().join(", ")}`);
171
+ process.exit(1);
172
+ }
173
+ try {
174
+ await withPrompt(deps.ask, async (ask) => {
175
+ await login(name, {
176
+ onAuth: ({ url, instructions }) => {
177
+ console.log(`\n🔐 Open this URL in any browser to approve ${name}:\n${url}\n`);
178
+ if (instructions) console.log(instructions);
179
+ },
180
+ onProgress: (m) => console.log(` ${m}`),
181
+ onManualCodeInput: async () => await ask("Paste the code shown after approval: "),
182
+ }, { codeMode: true });
183
+ });
184
+ } catch (error) {
185
+ // Never let the raw error reach stderr: a token endpoint's error body can quote the pasted
186
+ // code back. Only fixed, allowlisted text and the machine-readable reason are printed.
187
+ console.error(`\n❌ ${name} login failed (${classifyLoginCodeError(error)}): ${publicOAuthAuthenticationErrorMessage(error)}`);
188
+ process.exit(1);
189
+ }
190
+ const reload = await notifyRunningProxyAfterOAuthLogin(name);
191
+ console.log(`\n✅ Logged in to ${name}. Try: ocx sync`);
192
+ for (const line of modelSelectionGuidance(name)) console.log(line);
193
+ warnIfLiveReloadSkipped(reload);
194
+ }
195
+
196
+ export async function handleOAuthLogin(name: string, deps: LoginCliDeps = {}, opts: LoginCliOptions = {}): Promise<void> {
140
197
  if (name === "anthropic2") assertAnthropicInstanceLoginConfig(loadConfig(), name);
198
+ if (opts.codeMode) return handleCodeLogin(name, deps);
141
199
  const login = deps.runLogin ?? runLogin;
142
200
  const launch = deps.openUrl ?? openUrl;
143
201
  const browser = createBrowserLaunchReport(deps.warn);
144
202
  await withPrompt(deps.ask, async (ask) => {
145
203
  await login(name, {
146
204
  onAuth: ({ url, instructions }) => {
205
+ if (!url) {
206
+ if (instructions) console.log(instructions);
207
+ return;
208
+ }
147
209
  console.log(`\n🔐 Opening browser for ${name} login...\n${url}\n`);
148
210
  if (instructions) console.log(instructions);
149
211
  // The controller does not await onAuth, so the launcher's answer cannot be reported from
@@ -159,7 +221,7 @@ export async function handleOAuthLogin(name: string, deps: LoginCliDeps = {}): P
159
221
  return await ask("Paste redirect URL or code (or wait for browser): ");
160
222
  },
161
223
  });
162
- });
224
+ }).catch(reportOAuthLoginError);
163
225
  // A device or polling provider never prompts, so nothing above waited on the launcher. It is
164
226
  // still owed an answer before this claims the login worked.
165
227
  await browser.settled();
@@ -1,3 +1,4 @@
1
+ import { AnthropicTokenError } from "./anthropic";
1
2
  import { parseCallbackInput } from "./callback-server";
2
3
  import { retainedUtf8Bytes } from "../lib/admission";
3
4
  import type { GenerationContext } from "../lib/state-store-sweeper";
@@ -9,6 +10,12 @@ import type { GenerationContext } from "../lib/state-store-sweeper";
9
10
  * lines. The cut follows a seam rather than a line count: everything here is bookkeeping for
10
11
  * a login that has started and not yet settled, and none of it reads or writes a stored
11
12
  * credential. `index.ts` re-exports the two public names, so existing importers are unaffected.
13
+ *
14
+ * Every map here is keyed by FLOW ID, not by provider. A code-display flow binds no socket, so
15
+ * a hosted login host can have several in flight for one provider at once; each must have its
16
+ * own paste slot and its own abort scope, or one user's paste settles another's login. The
17
+ * provider-only public APIs (`getLoginStatus`, `submitManualLoginCode`, `cancelLoginFlow`)
18
+ * resolve through `latestLoginFlowKey`, which is the one place that rule is written down.
12
19
  */
13
20
  /** Human-facing continuation only: never retain credentials or provider response objects. */
14
21
  export interface OAuthLoginHint {
@@ -16,16 +23,301 @@ export interface OAuthLoginHint {
16
23
  instructions?: string;
17
24
  deviceCode?: string;
18
25
  }
19
- export const loginState = new Map<string, { error?: string; done: boolean; hint?: OAuthLoginHint }>();
20
- export const loginAbort = new Map<string, { controller: AbortController; flowId?: string }>();
26
+
27
+ /**
28
+ * Machine-readable outcome of a code-display login, so a caller that only ever sees one HTTP
29
+ * response can tell a bad paste from a dead flow from an unreachable provider. Never carries
30
+ * any part of the code, verifier, state or token.
31
+ */
32
+ export type LoginCodeError =
33
+ | "state_mismatch"
34
+ | "invalid_or_expired_code"
35
+ | "no_pending_login"
36
+ | "provider_unreachable"
37
+ | "malformed_input"
38
+ | "code_mode_unsupported";
39
+
40
+ export interface LoginFlowState {
41
+ error?: string;
42
+ done: boolean;
43
+ hint?: OAuthLoginHint;
44
+ /** Absent means the default localhost-callback flow; "code" is the code-display flow. */
45
+ mode?: "callback" | "code";
46
+ /**
47
+ * Which provider this flow logs into, since the key no longer says. Absent on a row written
48
+ * straight into this map by a caller that predates flow-id keying, where the key IS the
49
+ * provider name — `loginFlowProvider` resolves both.
50
+ */
51
+ provider?: string;
52
+ flowId?: string;
53
+ /** Epoch ms after which a paste is refused as `no_pending_login`. */
54
+ expiresAt?: number;
55
+ errorCode?: LoginCodeError;
56
+ /** Account row this flow persisted, once it settled successfully. An id, never a credential. */
57
+ accountId?: string;
58
+ }
59
+
60
+ /** Keyed by flow id. */
61
+ export const loginState = new Map<string, LoginFlowState>();
62
+ /** Keyed by flow id; `provider` is what a provider-scoped caller is checked against. */
63
+ export const loginAbort = new Map<string, { controller: AbortController; provider?: string }>();
21
64
  export const kiroLoginSettling = new Set<string>();
22
65
 
66
+ /** The provider a flow belongs to, or undefined when no such flow is recorded. */
67
+ export function loginFlowProvider(flowKey: string): string | undefined {
68
+ const state = loginState.get(flowKey);
69
+ return state === undefined ? undefined : state.provider ?? flowKey;
70
+ }
71
+
72
+ /** Flow ids recorded for a provider, oldest first (Map iteration is insertion order). */
73
+ export function loginFlowKeys(provider: string): string[] {
74
+ // ponytail: linear scan over in-flight logins (bounded by MAX_LOGIN_FLOWS_PER_PROVIDER);
75
+ // add a provider -> keys index if that bound ever grows.
76
+ const keys: string[] = [];
77
+ // `(state.provider ?? key)` must be parenthesised: `===` binds tighter than `??`, so the
78
+ // unparenthesised form reads as `state.provider ?? (key === provider)` and returns the truthy
79
+ // provider string for every flow -- matching every provider against every other.
80
+ for (const [key, state] of loginState) if ((state.provider ?? key) === provider) keys.push(key);
81
+ return keys;
82
+ }
83
+
84
+ /**
85
+ * The flow that a provider-only API acts on: the NEWEST flow still in progress, or — when none
86
+ * is — the newest flow overall, so a login that just settled still reports its outcome. With a
87
+ * single flow (every callback login, and every GUI/CLI login) this is that flow, unchanged.
88
+ */
89
+ export function latestLoginFlowKey(provider: string): string | undefined {
90
+ const keys = loginFlowKeys(provider);
91
+ return keys.findLast(key => loginState.get(key)?.done === false) ?? keys.at(-1);
92
+ }
93
+
94
+ /**
95
+ * Rows kept per provider. Concurrent code flows mean an authenticated caller can start logins
96
+ * nobody ever finishes, each holding a PKCE verifier, an AbortController and an open paste
97
+ * promise. Expired flows are reaped first; this is the backstop that bounds the rest.
98
+ */
99
+ const MAX_LOGIN_FLOWS_PER_PROVIDER = 64;
100
+
101
+ /** Abort a flow, drop its paste slot, and record a terminal outcome for anything awaiting it. */
102
+ export function abandonLoginFlow(flowKey: string, outcome: { error: string; errorCode: LoginCodeError }): void {
103
+ loginAbort.get(flowKey)?.controller.abort("cancelled");
104
+ loginAbort.delete(flowKey);
105
+ clearManualCodeSlot(flowKey);
106
+ settleLoginFlow(flowKey, outcome);
107
+ }
108
+
109
+ /**
110
+ * Refuse a login that cannot safely share this process with what is already in flight, after
111
+ * reaping dead rows. A code-display flow binds nothing, so several can run at once for one
112
+ * provider — that is what lets a hosted login host serve more than one user. The callback flow
113
+ * binds the fixed loopback port and genuinely cannot run twice, so any overlap involving one is
114
+ * refused, as is anything past the per-provider row cap.
115
+ */
116
+ export function admitLoginFlow(provider: string, codeMode: boolean): void {
117
+ reapLoginFlows(provider);
118
+ const inFlight = loginFlowKeys(provider)
119
+ .map(key => loginState.get(key))
120
+ .filter(state => state?.done === false);
121
+ const overlapAllowed = codeMode && inFlight.every(state => state?.mode === "code");
122
+ if ((inFlight.length > 0 && !overlapAllowed) || (provider === "kiro" && kiroLoginSettling.has(provider))) {
123
+ throw new Error(`A login for ${provider} is already in progress`);
124
+ }
125
+ if (loginFlowKeys(provider).length >= MAX_LOGIN_FLOWS_PER_PROVIDER) {
126
+ throw new Error(`Too many logins in progress for ${provider}`);
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Drop a provider's dead rows: code flows past their paste window (they can never complete),
132
+ * then the oldest settled rows once the cap is reached. Called when a flow starts, which is
133
+ * the only moment the count can grow. Never evicts a flow still in progress.
134
+ */
135
+ function reapLoginFlows(provider: string): void {
136
+ const now = Date.now();
137
+ for (const key of loginFlowKeys(provider)) {
138
+ const state = loginState.get(key);
139
+ if (state && !state.done && state.expiresAt !== undefined && now > state.expiresAt) {
140
+ abandonLoginFlow(key, { error: "Login expired", errorCode: "no_pending_login" });
141
+ }
142
+ }
143
+ const keys = loginFlowKeys(provider);
144
+ // +1 leaves room for the flow about to be recorded.
145
+ const excess = keys.length - MAX_LOGIN_FLOWS_PER_PROVIDER + 1;
146
+ if (excess <= 0) return;
147
+ for (const key of keys.filter(key => loginState.get(key)?.done === true).slice(0, excess)) {
148
+ clearManualCodeSlot(key);
149
+ dropLoginFlow(key);
150
+ }
151
+ }
152
+
153
+ /** A code-display flow the user never completes must stop accepting pastes. */
154
+ export const CODE_LOGIN_TTL_MS = 10 * 60_000;
155
+
156
+ /**
157
+ * Record a just-admitted flow under its id and return its paste deadline (code mode only).
158
+ * Lives here rather than in `startLoginFlow` to keep `src/oauth/index.ts` under the size ratchet.
159
+ */
160
+ export function recordStartedLoginFlow(provider: string, flowId: string, codeMode: boolean): number | undefined {
161
+ clearManualCodeSlot(flowId);
162
+ const expiresAt = codeMode ? Date.now() + CODE_LOGIN_TTL_MS : undefined;
163
+ loginState.set(flowId, { done: false, provider, flowId, ...(codeMode ? { mode: "code" as const, expiresAt } : {}) });
164
+ return expiresAt;
165
+ }
166
+
167
+ export class CodeLoginUnsupportedError extends Error {
168
+ constructor(provider: string) {
169
+ super(`${provider} has no code-display login`);
170
+ this.name = "CodeLoginUnsupportedError";
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Map a login failure onto the code a code-mode caller receives. The error itself is never
176
+ * forwarded: a provider body can quote the submitted code back, and this response is the one
177
+ * surface a hosted caller renders to a user.
178
+ *
179
+ * `AnthropicTokenError` is named because anthropic is the only provider with a code-display
180
+ * redirect today; every other failure falls through the generic branches.
181
+ */
182
+ export function classifyLoginCodeError(error: unknown): LoginCodeError {
183
+ const message = error instanceof Error ? error.message : "";
184
+ if (message.startsWith("OAuth callback cancelled")) return "no_pending_login";
185
+ if (error instanceof AnthropicTokenError) {
186
+ return error.httpStatus !== undefined && error.httpStatus >= 500
187
+ ? "provider_unreachable"
188
+ : "invalid_or_expired_code";
189
+ }
190
+ const name = error instanceof Error ? error.name : "";
191
+ if (name === "TimeoutError" || name === "AbortError") return "provider_unreachable";
192
+ // fetch() reports a dead network as a TypeError, but so does any bug in this code. Bun tags
193
+ // the network kind with a string `code` (`ConnectionRefused`, `ENOTFOUND`, ...) and Node puts
194
+ // one on `cause`; a plain programming TypeError carries neither, and must not be reported to
195
+ // the user as "Anthropic is unreachable".
196
+ if (error instanceof TypeError) {
197
+ const code = (error as { code?: unknown }).code ?? (error as { cause?: { code?: unknown } }).cause?.code;
198
+ if (typeof code === "string" && code !== "") return "provider_unreachable";
199
+ }
200
+ return "invalid_or_expired_code";
201
+ }
202
+
203
+ /** Awaiting a terminal outcome for one flow (code mode answers its POST from this). */
204
+ const loginSettleWaiters = new Map<string, Set<() => void>>();
205
+
206
+ function wakeLoginWaiters(flowKey: string): void {
207
+ const waiters = loginSettleWaiters.get(flowKey);
208
+ if (!waiters) return;
209
+ loginSettleWaiters.delete(flowKey);
210
+ for (const wake of waiters) wake();
211
+ }
212
+
213
+ /**
214
+ * Record a login's terminal outcome and wake anything awaiting it. Flow identity
215
+ * (mode/flowId/expiresAt) is preserved so a settled flow still reports what it was. The hint is
216
+ * dropped: `getLoginStatus` already hides it once done, and a settled flow has no next step.
217
+ */
218
+ export function settleLoginFlow(
219
+ flowKey: string,
220
+ outcome: { error?: string; errorCode?: LoginCodeError; accountId?: string } = {},
221
+ ): void {
222
+ const current = loginState.get(flowKey);
223
+ // A flow already dropped (logout, reap) has nothing left to report; releasing its waiters is
224
+ // still owed, and fabricating a row here would be a flow belonging to no provider.
225
+ if (current) {
226
+ loginState.set(flowKey, {
227
+ ...current,
228
+ done: true,
229
+ hint: undefined,
230
+ error: outcome.error,
231
+ errorCode: outcome.errorCode,
232
+ accountId: outcome.accountId,
233
+ });
234
+ }
235
+ wakeLoginWaiters(flowKey);
236
+ }
237
+
238
+ /** True when the named flow (or, without one, the provider's newest flow) is this provider's code flow. */
239
+ export function isCodeLoginFlow(provider: string, flowId?: string): boolean {
240
+ const flowKey = flowId ?? latestLoginFlowKey(provider);
241
+ return flowKey !== undefined && loginFlowProvider(flowKey) === provider && loginState.get(flowKey)?.mode === "code";
242
+ }
243
+
244
+ /** The account a settled, successful flow persisted; undefined for any other flow or provider. */
245
+ export function loginFlowAccountId(provider: string, flowId: string): string | undefined {
246
+ const state = loginState.get(flowId);
247
+ if (!state?.done || state.error || loginFlowProvider(flowId) !== provider) return undefined;
248
+ return state.accountId;
249
+ }
250
+
251
+ /** Drop one flow's state entirely; waiters are released rather than left hanging. */
252
+ export function dropLoginFlow(flowKey: string): void {
253
+ loginState.delete(flowKey);
254
+ wakeLoginWaiters(flowKey);
255
+ }
256
+
257
+ /**
258
+ * Resolve true once the login is terminal, false if `timeoutMs` elapses first. `flowId` picks
259
+ * one of several concurrent flows; without it the provider's newest flow is used.
260
+ */
261
+ export function waitForLoginSettled(provider: string, timeoutMs: number, flowId?: string): Promise<boolean> {
262
+ const flowKey = flowId ?? latestLoginFlowKey(provider);
263
+ const current = flowKey === undefined ? undefined : loginState.get(flowKey);
264
+ if (!current || current.done || loginFlowProvider(flowKey!) !== provider) return Promise.resolve(true);
265
+ return new Promise<boolean>(resolve => {
266
+ let waiters = loginSettleWaiters.get(flowKey!);
267
+ if (!waiters) {
268
+ waiters = new Set();
269
+ loginSettleWaiters.set(flowKey!, waiters);
270
+ }
271
+ let timer: ReturnType<typeof setTimeout>;
272
+ const wake = (): void => {
273
+ clearTimeout(timer);
274
+ resolve(true);
275
+ };
276
+ timer = setTimeout(() => {
277
+ waiters.delete(wake);
278
+ resolve(false);
279
+ }, timeoutMs);
280
+ waiters.add(wake);
281
+ });
282
+ }
283
+
284
+ /**
285
+ * Forget every login flow for a provider (logout): it invalidates all of them, not just the
286
+ * newest. Any code-mode POST awaiting one is released rather than left to time out.
287
+ */
288
+ export function clearLoginState(provider: string): void {
289
+ for (const flowKey of loginFlowKeys(provider)) {
290
+ loginAbort.get(flowKey)?.controller.abort("cleared");
291
+ loginAbort.delete(flowKey);
292
+ clearManualCodeSlot(flowKey);
293
+ dropLoginFlow(flowKey);
294
+ }
295
+ }
296
+
297
+ /**
298
+ * Cancel one login. `flowId` names it exactly; without one the provider's newest in-flight flow
299
+ * is cancelled, which is every single-flow caller (GUI, CLI) unchanged. A flow id belonging to a
300
+ * different provider is refused rather than cancelled.
301
+ */
302
+ export function cancelLoginFlow(provider: string, flowId?: string): boolean {
303
+ const flowKey = flowId ?? latestLoginFlowKey(provider);
304
+ if (flowKey === undefined) return false;
305
+ const active = loginAbort.get(flowKey);
306
+ const existing = loginState.get(flowKey);
307
+ if ((active?.provider ?? loginFlowProvider(flowKey)) !== provider) return false;
308
+ if (!active && (!existing || existing.done)) return false;
309
+ abandonLoginFlow(flowKey, { error: "Login cancelled", errorCode: "no_pending_login" });
310
+ return true;
311
+ }
312
+
23
313
  /** Pending paste for a login in progress: either a waiter or a stashed early submission. */
24
314
  export interface ManualCodeSlot {
25
315
  pendingInput?: string;
26
316
  resolve?: (value: string) => void;
27
317
  /** Registered by the callback flow so submits can validate state synchronously. */
28
318
  expectedState?: string;
319
+ /** Code-display flows are single-use: set once a paste is handed to the exchange. */
320
+ delivered?: boolean;
29
321
  }
30
322
  const loginManual = new Map<string, ManualCodeSlot>();
31
323
  const OAUTH_PENDING_CODE_MAX_BYTES = 4 * 1024;
@@ -34,35 +326,41 @@ let lastOAuthFlowReconciledGeneration = 0;
34
326
  export function reconcileOAuthFlowState(context: GenerationContext): number {
35
327
  if (context.generation <= lastOAuthFlowReconciledGeneration) return 0;
36
328
  let removed = 0;
37
- for (const [provider, state] of loginState) {
38
- if (context.providerNames.has(provider) || !state.done || loginAbort.has(provider)) continue;
39
- if (loginState.delete(provider)) removed += 1;
40
- if (loginManual.delete(provider)) removed += 1;
41
- if (loginAbort.delete(provider)) removed += 1;
329
+ for (const [flowKey, state] of loginState) {
330
+ const provider = state.provider ?? flowKey!;
331
+ if (context.providerNames.has(provider) || !state.done || loginAbort.has(flowKey)) continue;
332
+ if (loginState.delete(flowKey)) removed += 1;
333
+ if (loginManual.delete(flowKey)) removed += 1;
334
+ if (loginAbort.delete(flowKey)) removed += 1;
42
335
  }
43
336
  lastOAuthFlowReconciledGeneration = context.generation;
44
337
  return removed;
45
338
  }
46
339
 
47
- export function clearManualCodeSlot(provider: string): void {
48
- loginManual.delete(provider);
340
+ /** Test-only counterpart to `resetOAuthReauthStateForTests`, for the same leak. */
341
+ export function resetOAuthFlowReconcileStateForTests(): void {
342
+ lastOAuthFlowReconciledGeneration = 0;
49
343
  }
50
344
 
51
- export function ensureManualCodeSlot(provider: string): ManualCodeSlot {
52
- let slot = loginManual.get(provider);
345
+ export function clearManualCodeSlot(flowKey: string): void {
346
+ loginManual.delete(flowKey);
347
+ }
348
+
349
+ export function ensureManualCodeSlot(flowKey: string): ManualCodeSlot {
350
+ let slot = loginManual.get(flowKey);
53
351
  if (!slot) {
54
352
  slot = {};
55
- loginManual.set(provider, slot);
353
+ loginManual.set(flowKey, slot);
56
354
  }
57
355
  return slot;
58
356
  }
59
357
 
60
358
  /** Wait for a GUI/CLI paste of the OAuth redirect URL or code (or return a stashed early submit). */
61
- export function waitForManualLoginCode(provider: string, signal: AbortSignal, expectedState?: string): Promise<string> {
359
+ export function waitForManualLoginCode(flowKey: string, signal: AbortSignal, expectedState?: string): Promise<string> {
62
360
  if (signal.aborted) {
63
361
  return Promise.reject(new Error(`OAuth callback cancelled: ${signal.reason}`));
64
362
  }
65
- const slot = ensureManualCodeSlot(provider);
363
+ const slot = ensureManualCodeSlot(flowKey);
66
364
  if (expectedState !== undefined) slot.expectedState = expectedState;
67
365
  if (slot.pendingInput !== undefined) {
68
366
  const value = slot.pendingInput;
@@ -84,17 +382,61 @@ export function waitForManualLoginCode(provider: string, signal: AbortSignal, ex
84
382
  }
85
383
 
86
384
  /**
87
- * Feed a pasted redirect URL or authorization code into an in-progress GUI login.
385
+ * Feed a pasted redirect URL or authorization code into an in-progress GUI login. Provider-only,
386
+ * so it feeds the provider's newest in-flight flow — see `latestLoginFlowKey`.
88
387
  * Returns ok:false when no login is waiting (or input is empty). Invalid pastes are accepted
89
388
  * here and re-prompted by the OAuth callback loop if they cannot be parsed / fail state checks.
90
389
  */
91
390
  export function submitManualLoginCode(provider: string, input: string): { ok: true } | { ok: false; error: string } {
391
+ const result = submitLoginCode(provider, input);
392
+ // The machine-readable code is dropped here on purpose: this return shape is the
393
+ // long-standing contract of the callback flow's paste route. Code mode calls
394
+ // submitLoginCode directly because it must answer with the code.
395
+ return result.ok ? { ok: true } : { ok: false, error: result.error };
396
+ }
397
+
398
+ /**
399
+ * Same validation as `submitManualLoginCode`, with the machine-readable reason attached, and
400
+ * the id of the flow the paste was applied to so the caller can await THAT flow rather than
401
+ * whichever one is newest by the time it looks.
402
+ *
403
+ * `expectedFlowId` pins the submission to one flow: a paste from a superseded attempt, or one
404
+ * aimed at another concurrent login, is refused rather than fed to whatever is running now.
405
+ */
406
+ export function submitLoginCode(
407
+ provider: string,
408
+ input: string,
409
+ expectedFlowId?: string,
410
+ ): { ok: true; flowId: string } | { ok: false; error: string; code: LoginCodeError } {
92
411
  const trimmed = input.trim();
93
- if (!trimmed) return { ok: false, error: "empty code" };
94
- if (retainedUtf8Bytes(trimmed) > OAUTH_PENDING_CODE_MAX_BYTES) return { ok: false, error: "code too large" };
95
- const st = loginState.get(provider);
96
- if (!st || st.done) return { ok: false, error: "no login in progress" };
97
- const slot = ensureManualCodeSlot(provider);
412
+ if (!trimmed) return { ok: false, error: "empty code", code: "malformed_input" };
413
+ if (retainedUtf8Bytes(trimmed) > OAUTH_PENDING_CODE_MAX_BYTES) {
414
+ return { ok: false, error: "code too large", code: "malformed_input" };
415
+ }
416
+ // The flow id IS the key, so an id belonging to another provider's flow (or to no flow at
417
+ // all) resolves to nothing here rather than reaching a login it does not own.
418
+ const flowKey = expectedFlowId ?? latestLoginFlowKey(provider);
419
+ const st = flowKey === undefined ? undefined : loginState.get(flowKey);
420
+ if (!st || st.done || loginFlowProvider(flowKey!) !== provider) {
421
+ return { ok: false, error: "no login in progress", code: "no_pending_login" };
422
+ }
423
+ if (st.expiresAt !== undefined && Date.now() > st.expiresAt) {
424
+ return { ok: false, error: "login expired", code: "no_pending_login" };
425
+ }
426
+ const slot = ensureManualCodeSlot(flowKey!);
427
+ // A code-display flow exchanges exactly one paste and then settles, success or not. A second
428
+ // paste while that exchange runs would otherwise be stashed and answered with the first
429
+ // paste's outcome, so it is refused instead.
430
+ if (st.mode === "code" && slot.delivered) {
431
+ return { ok: false, error: "login already received a code", code: "no_pending_login" };
432
+ }
433
+ // Without the expected state a paste cannot be checked here, and stashing it would spend the
434
+ // flow's single use on input the callback loop later throws away. The URL a caller needs to
435
+ // obtain a code is published only after the state is registered, so this is only reachable
436
+ // by racing the start.
437
+ if (st.mode === "code" && slot.expectedState === undefined) {
438
+ return { ok: false, error: "login is not ready for a code yet", code: "no_pending_login" };
439
+ }
98
440
  // Synchronous validation (validated request/ack): reject un-parseable input and
99
441
  // authorization responses (url/query kind) whose state is missing or mismatched
100
442
  // once the flow has registered its expected state. Raw codes stay in-session-PKCE
@@ -106,15 +448,20 @@ export function submitManualLoginCode(provider: string, input: string): { ok: tr
106
448
  // (`{ apiKey, state, ... }`). Keep that opaque to the generic raw parser so
107
449
  // hashes in JSON strings do not become a fake state suffix; its provider parser validates state.
108
450
  const isCommandCodeJson = provider === "command-code" && trimmed.startsWith("{");
109
- if (!parsed.code && !isCommandCodeJson) return { ok: false, error: "no authorization code found in input" };
451
+ if (!parsed.code && !isCommandCodeJson) {
452
+ return { ok: false, error: "no authorization code found in input", code: "malformed_input" };
453
+ }
110
454
  // A raw paste carrying an explicit code#state suffix is state-bearing too: it
111
455
  // must match the expected state rather than bypass validation.
112
456
  const stateBearing = !isCommandCodeJson && (parsed.kind !== "raw" || parsed.state !== undefined);
113
457
  if (stateBearing && slot.expectedState !== undefined) {
114
- if (parsed.state === undefined) return { ok: false, error: "redirect URL is missing the state parameter" };
458
+ if (parsed.state === undefined) {
459
+ return { ok: false, error: "redirect URL is missing the state parameter", code: "state_mismatch" };
460
+ }
115
461
  if (parsed.state !== slot.expectedState) {
116
462
  return {
117
463
  ok: false,
464
+ code: "state_mismatch",
118
465
  error: parsed.kind === "raw"
119
466
  ? "state mismatch — paste the bare code, or the correct code#state from THIS login attempt"
120
467
  : "state mismatch — paste the redirect URL from THIS login attempt",
@@ -129,5 +476,6 @@ export function submitManualLoginCode(provider: string, input: string): { ok: tr
129
476
  // Race: GUI may POST before the flow reaches onManualCodeInput — stash for the waiter.
130
477
  slot.pendingInput = trimmed;
131
478
  }
132
- return { ok: true };
479
+ if (st.mode === "code") slot.delivered = true;
480
+ return { ok: true, flowId: flowKey! };
133
481
  }
@@ -0,0 +1,41 @@
1
+ /** Closed, account-free diagnostics: never project a provider's raw error text. */
2
+ const messages = {
3
+ "cancelled": "Muse Code login cancelled.",
4
+ "pointer-unreadable": "Could not read the Muse Code credential file. Check its permissions or run `muse login` again.",
5
+ "pointer-invalid": "The Muse Code credential file is not valid JSON or has an invalid shape. Run `muse login` to rewrite it.",
6
+ "storage-unsupported": "Muse Code stored its credential with an unsupported backend; only the macOS Keychain import is verified. Retry import or paste an existing Muse Code key when prompted.",
7
+ "keychain-timeout": "The Muse Code Keychain approval timed out after 120s. Unlock the login keychain, approve the prompt and retry, or paste an existing Muse Code key when prompted.",
8
+ "keychain-denied": "Access to the Muse Code Keychain entry was denied or cancelled. Approve the Keychain prompt and retry, or paste an existing Muse Code key when prompted.",
9
+ "keychain-locked": "The Muse Code Keychain entry requires user interaction. Unlock the login keychain and retry from an interactive macOS session, or paste an existing Muse Code key when prompted.",
10
+ "keychain-missing": "The Muse Code Keychain entry was not found. Run `muse login` again, or paste an existing Muse Code key when prompted.",
11
+ "keychain-unreadable": "Could not read the Muse Code credential from the macOS Keychain. Run `muse login` again, or paste an existing Muse Code key when prompted.",
12
+ "keychain-invalid": "The Muse Code Keychain entry is not valid JSON or has an invalid shape. Run `muse login` to rewrite it.",
13
+ "key-missing": "The Muse Code Keychain entry carries no usable API key. Run `muse login` again, or paste an existing Muse Code key when prompted.",
14
+ "key-format": "The Muse Code credential is not in the expected Meta API key format. Run `muse login` again, or paste an existing Muse Code key when prompted.",
15
+ "manual-key-missing": "The pasted Muse Code credential carries no usable API key. Copy it again from https://dev.meta.ai.",
16
+ "manual-key-format": "The pasted Muse Code credential is not in the expected Meta API key format. Copy it again from https://dev.meta.ai.",
17
+ "validation-network": "Could not reach the Meta Model API to validate the Muse Code credential. Check the proxy host's network and retry.",
18
+ "validation-timeout": "The Meta Model API credential validation timed out after 10s. Check the proxy host's network and retry.",
19
+ "validation-http": "The Muse Code credential was rejected by the Meta Model API. Renew it with `muse login` or copy it again from https://dev.meta.ai, then retry.",
20
+ } as const;
21
+
22
+ export type MuseLoginErrorKind = keyof typeof messages;
23
+
24
+ export class MuseLoginError extends Error {
25
+ constructor(readonly kind: MuseLoginErrorKind, readonly status?: number) {
26
+ super(museLoginErrorMessage(kind, status));
27
+ this.name = "MuseLoginError";
28
+ }
29
+ }
30
+
31
+ export function museLoginErrorMessage(kind: MuseLoginErrorKind, status?: number): string {
32
+ const message = Object.hasOwn(messages, kind) ? messages[kind] : messages["keychain-unreadable"];
33
+ return kind === "validation-http" && Number.isInteger(status) && status! >= 100 && status! <= 599
34
+ ? `${message} (HTTP ${status})`
35
+ : message;
36
+ }
37
+
38
+ export function publicMuseLoginErrorMessage(error: unknown): string | undefined {
39
+ // Reconstruct from the closed kind; even a mutated Error.message cannot leak a key.
40
+ return error instanceof MuseLoginError ? museLoginErrorMessage(error.kind, error.status) : undefined;
41
+ }