@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
@@ -0,0 +1,12 @@
1
+ /** Structured JSON is never literal key material; malformed GCP text is also refused. */
2
+ export function isGcpCredentialMaterial(value: string): boolean {
3
+ const credentialField = /\b(?:service_account|authorized_user|external_account|private_key|refresh_token|client_secret)\b|-----BEGIN [A-Z ]*PRIVATE KEY-----/;
4
+ if (/[\[\{"']/.test(value) && credentialField.test(value)) return true;
5
+ // Reject structured carriers outright rather than recursively guessing their credential shape.
6
+ // This includes quoted/array/object wrappers with arbitrarily escaped or nested JSON inside.
7
+ try {
8
+ const parsed: unknown = JSON.parse(value);
9
+ return typeof parsed === "string" || (parsed !== null && typeof parsed === "object");
10
+ }
11
+ catch { return false; }
12
+ }
@@ -72,6 +72,11 @@ export async function buildLabProviderAuthHeaders(
72
72
  && configuredAnthropicInstance(config, routeContext.providerId) !== routeContext.providerId) {
73
73
  throw new TransportError("auth_blocked", "anthropic instance unavailable");
74
74
  }
75
+ // The DSH account token is sent only by its own adapter to its pinned endpoint; the generic
76
+ // probe transport would use another endpoint and auth scheme.
77
+ if (routeContext.providerId === "dsh-account") {
78
+ throw new TransportError("auth_blocked", "dsh-account probes are not supported");
79
+ }
75
80
  try {
76
81
  const snapshot = await getValidAccessTokenSnapshot(routeContext.providerId);
77
82
  headers.Authorization = `Bearer ${snapshot.accessToken}`;
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Single-route opaque proxy tunnel. The desktop upstream dialer retains its
3
+ * own fixed-target TLS contract.
4
+ * Shares OpenCodex's MIT SOCKS handshake; the caller owns all post-establishment
5
+ * cancellation and socket cleanup. Never replay or implicitly retry directly.
6
+ */
7
+ import net, { type Socket } from 'node:net';
8
+ import tls from 'node:tls';
9
+ import { socks5Handshake, socks5Credentials, Socks5HandshakeError } from './socks5-handshake';
10
+
11
+ interface RouteEndpoint { host: string; port: number }
12
+ type RouteCredentials = { username?: undefined; password?: undefined } | { username: string; password: string };
13
+ type PlainProxy = { kind?: 'http'; proxyCa?: never };
14
+ type SecureProxy = { kind: 'https'; proxyCa?: string };
15
+ type SocksProxy = { kind: 'socks5' | 'socks5h'; proxyCa?: never };
16
+ export type OpaqueProxyRoute = RouteEndpoint & RouteCredentials & (PlainProxy | SecureProxy | SocksProxy);
17
+ export interface OpaqueTunnelOptions {
18
+ signal?: AbortSignal;
19
+ timeoutMs?: number;
20
+ maxHeaderBytes?: number;
21
+ }
22
+ interface AttemptOptions {
23
+ signal?: AbortSignal;
24
+ timeoutMs: number;
25
+ maxHeaderBytes: number;
26
+ }
27
+ export class TunnelError extends Error {
28
+ override readonly name = 'TunnelError';
29
+ readonly code: string;
30
+ readonly status?: number;
31
+ constructor(code: string, message: string, { status }: { status?: number } = {}) {
32
+ super(message); this.code = code; this.status = status;
33
+ }
34
+ }
35
+ const aborted = () => new TunnelError('ABORTED', 'Tunnel establishment cancelled');
36
+
37
+ function validateAuthority(authority: string): string {
38
+ if (typeof authority !== 'string' || !/^(?:\[[0-9a-fA-F:]+\]|[a-zA-Z0-9._-]+):[0-9]+$/.test(authority)) {
39
+ throw new TunnelError('TARGET', 'An explicit host:port authority is required');
40
+ }
41
+ const port = Number(authority.slice(authority.lastIndexOf(':') + 1));
42
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new TunnelError('TARGET', 'Invalid target port');
43
+ return authority;
44
+ }
45
+
46
+ function validateOptions(p: OpaqueProxyRoute, timeoutMs: number, maxHeaderBytes: number): void {
47
+ if (!p || Array.isArray(p) || typeof p.host !== 'string' || !p.host || /[\s/@]/.test(p.host)
48
+ || !Number.isInteger(p.port) || p.port < 1 || p.port > 65535
49
+ || !['http', 'https', 'socks5', 'socks5h'].includes(p.kind ?? 'http')) throw new TunnelError('CONFIG', 'Invalid proxy route');
50
+ if ((p.username === undefined) !== (p.password === undefined)
51
+ || (p.username !== undefined && (typeof p.username !== 'string' || typeof p.password !== 'string'
52
+ || (!p.kind?.startsWith('socks') && p.username.includes(':')) || Buffer.byteLength(p.username + p.password) > 4096))) throw new TunnelError('CONFIG', 'Invalid proxy credentials');
53
+ if ((p.kind ?? 'http').startsWith('socks') && p.username !== undefined
54
+ && (Buffer.byteLength(p.username) > 255 || Buffer.byteLength(p.password) > 255)) throw new TunnelError('CONFIG', 'SOCKS credentials must each fit in 255 UTF-8 bytes');
55
+ if (p.proxyCa !== undefined && p.kind !== 'https') throw new TunnelError('CONFIG', 'Proxy TLS settings require HTTPS proxy');
56
+ if (p.proxyCa !== undefined && typeof p.proxyCa !== 'string') throw new TunnelError('CONFIG', 'Invalid proxy CA');
57
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 30000
58
+ || !Number.isSafeInteger(maxHeaderBytes) || maxHeaderBytes < 64 || maxHeaderBytes > 65536) {
59
+ throw new TunnelError('CONFIG', 'Invalid deadline or header limit');
60
+ }
61
+ }
62
+
63
+ function connectOne(authority: string, proxy: OpaqueProxyRoute, { signal, timeoutMs, maxHeaderBytes }: AttemptOptions): Promise<Socket> {
64
+ if (proxy.kind === 'socks5' || proxy.kind === 'socks5h') return connectSocks(authority, proxy, { signal, timeoutMs, maxHeaderBytes });
65
+ return new Promise<Socket>((resolve, reject) => {
66
+ if (signal?.aborted) return reject(aborted());
67
+ const secure = proxy.kind === 'https';
68
+ const identity = proxy.host;
69
+ const tlsOptions: tls.ConnectionOptions & { allowHalfOpen: boolean } = {
70
+ host: proxy.host, port: proxy.port, allowHalfOpen: true,
71
+ servername: net.isIP(identity) ? undefined : identity, ca: proxy.proxyCa,
72
+ checkServerIdentity: (_name, cert) => tls.checkServerIdentity(identity, cert),
73
+ rejectUnauthorized: true, ALPNProtocols: ['http/1.1'],
74
+ };
75
+ const socket = secure
76
+ ? tls.connect(tlsOptions)
77
+ : net.createConnection({ host: proxy.host, port: proxy.port, allowHalfOpen: true });
78
+ // Keep a defensive observer across the promise-to-owner handoff. Errors still
79
+ // settle the establishment promise or are handled by the relay after handoff.
80
+ socket.on('error', () => {});
81
+ let pending: Buffer = Buffer.alloc(0); let settled = false, tlsReady = !secure;
82
+ const connectedEvent = secure ? 'secureConnect' : 'connect';
83
+ const cleanup = () => {
84
+ clearTimeout(timer); signal?.removeEventListener('abort', onAbort);
85
+ socket.removeListener(connectedEvent, onConnect); socket.removeListener('data', onData);
86
+ socket.removeListener('error', onError); socket.removeListener('end', onEnd); socket.removeListener('close', onClose);
87
+ };
88
+ const fail = (error: Error) => {
89
+ if (settled) return; settled = true; cleanup(); socket.destroy(); reject(error);
90
+ };
91
+ const onAbort = () => fail(aborted());
92
+ const establishmentFailure = (code: string, message: string) => fail(new TunnelError(code, message));
93
+ const onError = () => {
94
+ if (secure && !tlsReady) {
95
+ fail(new TunnelError('PROXY_TLS', 'HTTPS proxy establishment failed'));
96
+ } else establishmentFailure('TRANSPORT', 'HTTP proxy connection failed');
97
+ };
98
+ const onEnd = () => establishmentFailure('EARLY_CLOSE', 'HTTP proxy closed before establishing the tunnel');
99
+ const onClose = onEnd;
100
+ const onConnect = () => {
101
+ tlsReady = true;
102
+ const auth = proxy.username === undefined ? '' : `Proxy-Authorization: Basic ${Buffer.from(`${proxy.username}:${proxy.password}`).toString('base64')}\r\n`;
103
+ socket.write(`CONNECT ${authority} HTTP/1.1\r\nHost: ${authority}\r\n${auth}\r\n`);
104
+ };
105
+ const onData = (chunk: Buffer) => {
106
+ pending = Buffer.concat([pending, chunk]);
107
+ const lineEnd = pending.indexOf('\r\n');
108
+ if (lineEnd >= 0) {
109
+ const firstLine = pending.subarray(0, lineEnd).toString('latin1');
110
+ const firstStatus = /^HTTP\/1\.[01] ([0-9]{3})(?: [\t\x20-\x7e\x80-\xff]*)?$/.exec(firstLine);
111
+ if (!firstStatus) return fail(new TunnelError('PROTOCOL', 'Invalid HTTP proxy status line'));
112
+ const status = Number(firstStatus[1]);
113
+ if (status < 200 || status >= 300) return fail(new TunnelError('REFUSED', 'HTTP proxy refused CONNECT', { status }));
114
+ }
115
+ const end = pending.indexOf('\r\n\r\n');
116
+ if (end < 0) {
117
+ if (pending.length > maxHeaderBytes) fail(new TunnelError('PROTOCOL', 'HTTP proxy response headers exceed limit'));
118
+ return;
119
+ }
120
+ if (end + 4 > maxHeaderBytes) return fail(new TunnelError('PROTOCOL', 'HTTP proxy response headers exceed limit'));
121
+ const lines = pending.subarray(0, end).toString('latin1').split('\r\n');
122
+ const statusLine = lines[0];
123
+ const match = /^HTTP\/1\.[01] ([0-9]{3})(?: [^\r\n]*)?$/.exec(statusLine);
124
+ if (!match) return fail(new TunnelError('PROTOCOL', 'Invalid HTTP proxy status line'));
125
+ const status = Number(match[1]);
126
+ if (lines.slice(1).some(line => !/^[!#$%&'*+.^_`|~0-9A-Za-z-]+:[\t\x20-\x7e\x80-\xff]*$/.test(line))) {
127
+ return fail(new TunnelError('PROTOCOL', 'Invalid HTTP proxy response header'));
128
+ }
129
+ if (status < 200 || status >= 300) return fail(new TunnelError('REFUSED', 'HTTP proxy refused CONNECT', { status }));
130
+ settled = true; socket.pause(); cleanup();
131
+ const head = pending.subarray(end + 4);
132
+ if (head.length) socket.unshift(head);
133
+ resolve(socket);
134
+ };
135
+ const timer = setTimeout(() => establishmentFailure('TIMEOUT', 'HTTP proxy establishment deadline exceeded'), timeoutMs);
136
+ socket.once(connectedEvent, onConnect); socket.on('data', onData);
137
+ socket.once('error', onError); socket.once('end', onEnd); socket.once('close', onClose);
138
+ signal?.addEventListener('abort', onAbort, { once: true });
139
+ if (signal?.aborted) onAbort();
140
+ });
141
+ }
142
+
143
+ // The bounded socket reader owns setup cancellation and preserves queued bytes.
144
+ // Framing uses the existing
145
+ // MIT socks5-handshake module; neither shared framing nor its DNS policy changes.
146
+ async function connectSocks(authority: string, proxy: OpaqueProxyRoute, { signal, timeoutMs }: AttemptOptions): Promise<Socket> {
147
+ if (signal?.aborted) throw aborted();
148
+ const socket = net.createConnection({ host: proxy.host, port: proxy.port, allowHalfOpen: true });
149
+ socket.on('error', () => {});
150
+ let buffer: Buffer = Buffer.alloc(0), failure: Error | undefined;
151
+ let waiting: { length: number; resolve: () => void; reject: (error: Error) => void } | undefined;
152
+ const fail = (error: Error) => { if (failure) return; failure = error; const current = waiting; waiting = undefined; current?.reject(error); socket.destroy(); };
153
+ const transportError = (code: string, message: string) => fail(new TunnelError(code, message));
154
+ const onData = (chunk: Buffer) => {
155
+ // Consume at most the bytes needed by a bounded handshake read, then pause.
156
+ // A valid CONNECT reply can share a packet with arbitrary early target data;
157
+ // that remainder is not a malformed/oversized handshake and must survive.
158
+ socket.pause();
159
+ buffer = Buffer.concat([buffer, chunk]);
160
+ if (waiting && buffer.length >= waiting.length) { const current = waiting; waiting = undefined; current.resolve(); }
161
+ else if (waiting) socket.resume();
162
+ };
163
+ const onError = () => transportError('TRANSPORT', 'SOCKS proxy connection failed');
164
+ const onClose = () => transportError('EARLY_CLOSE', 'SOCKS proxy closed before tunnel establishment');
165
+ const onAbort = () => fail(aborted());
166
+ const timer = setTimeout(() => transportError('TIMEOUT', 'SOCKS establishment deadline exceeded'), timeoutMs);
167
+ socket.on('data', onData); socket.once('error', onError); socket.once('end', onClose); socket.once('close', onClose);
168
+ socket.pause();
169
+ signal?.addEventListener('abort', onAbort, { once: true }); if (signal?.aborted) onAbort();
170
+ const reader = {
171
+ write(bytes: Uint8Array): void { if (failure) throw failure; socket.write(bytes); },
172
+ async readExact(length: number): Promise<Buffer> {
173
+ if (failure) throw failure;
174
+ if (!Number.isSafeInteger(length) || length < 0 || length > 1024) throw new TunnelError('PROTOCOL', 'Invalid handshake read size');
175
+ if (buffer.length < length) await new Promise<void>((resolve, reject) => {
176
+ if (waiting) { reject(new TunnelError('PROTOCOL', 'Concurrent handshake read')); return; }
177
+ waiting = { length, resolve, reject };
178
+ socket.resume();
179
+ });
180
+ if (failure) throw failure;
181
+ const result = buffer.subarray(0, length); buffer = buffer.subarray(length); return result;
182
+ },
183
+ };
184
+ try {
185
+ const credentialsUrl = new URL('socks5://fixture.invalid');
186
+ if (proxy.username !== undefined) { credentialsUrl.username = encodeURIComponent(proxy.username); credentialsUrl.password = encodeURIComponent(proxy.password); }
187
+ const colon = authority.lastIndexOf(':');
188
+ const target = { host: authority.slice(0, colon).replace(/^\[|\]$/g, ''), port: Number(authority.slice(colon + 1)) };
189
+ await socks5Handshake(reader, target, socks5Credentials(credentialsUrl), signal);
190
+ if (failure) throw failure;
191
+ if (signal?.aborted || socket.destroyed) throw aborted();
192
+ socket.pause(); if (buffer.length) socket.unshift(buffer); buffer = Buffer.alloc(0);
193
+ return socket;
194
+ } catch (error) {
195
+ socket.destroy();
196
+ if (error instanceof Socks5HandshakeError) throw new TunnelError('SOCKS_REFUSED', 'SOCKS authentication, policy, or protocol refusal');
197
+ throw error;
198
+ } finally {
199
+ clearTimeout(timer); signal?.removeEventListener('abort', onAbort);
200
+ socket.removeListener('data', onData); socket.removeListener('error', onError);
201
+ socket.removeListener('end', onClose); socket.removeListener('close', onClose);
202
+ }
203
+ }
204
+
205
+ /** Establish only. There is no application data, implicit DIRECT, or replay here.
206
+ * HTTP/HTTPS CONNECT and SOCKS5/SOCKS5h yield an opaque raw target tunnel.
207
+ */
208
+ export async function dialTunnel(authority: string, proxy: OpaqueProxyRoute, options: OpaqueTunnelOptions = {}): Promise<Socket> {
209
+ const { signal, timeoutMs = 10000, maxHeaderBytes = 65536 } = options;
210
+ validateAuthority(authority);
211
+ validateOptions(proxy, timeoutMs, maxHeaderBytes);
212
+ if (signal?.aborted) throw aborted();
213
+ // Snapshot the selected route. No alternate proxy or direct fallback exists.
214
+ const socket = await connectOne(authority, { ...proxy }, { signal, timeoutMs, maxHeaderBytes });
215
+ if (signal?.aborted || socket.destroyed) { socket.destroy(); throw aborted(); }
216
+ return socket;
217
+ }
package/src/lib/redact.ts CHANGED
@@ -7,7 +7,7 @@ export const REDACTED_SECRET = "[REDACTED]";
7
7
  * credentials over an unsafe channel (e.g. plaintext non-loopback HTTP) rather than
8
8
  * re-deriving a narrower local list.
9
9
  */
10
- export const SENSITIVE_KEY_PATTERN = /^(?:authorization|proxy-authorization|cookie|set-cookie|set-cookie2|api[-_]?key|x-api-key|x-goog-api-key|x-amz-security-token|copilot[-_]?session[-_]?token|session[-_]?token|access[-_]?token|refresh[-_]?token|id[-_]?token|token|secret|client[-_]?secret|password|profile[-_]?arn|exa[-_]?api[-_]?key)$/i;
10
+ export const SENSITIVE_KEY_PATTERN = /^(?:authorization|proxy-authorization|cookie|set-cookie|set-cookie2|api[-_]?key|x-api-key|x-goog-api-key|x-amz-security-token|x-dsh-auth-token|copilot[-_]?session[-_]?token|session[-_]?token|access[-_]?token|refresh[-_]?token|id[-_]?token|token|secret|client[-_]?secret|password|profile[-_]?arn|exa[-_]?api[-_]?key)$/i;
11
11
 
12
12
  /**
13
13
  * Colon-labelled credential headers echoed back inside an error body
@@ -40,7 +40,7 @@ export const SENSITIVE_KEY_PATTERN = /^(?:authorization|proxy-authorization|cook
40
40
  // Every letter position also accepts \u0001, the placeholder the fold emits for
41
41
  // an unresolved HTML named reference: `author&ii;zation` is the label with one
42
42
  // character we cannot name, and that is still the label.
43
- const CREDENTIAL_HEADER_LABEL_RAW = "copilot[_-]?session[_-]?token|session[_-]?token|sessionToken|x-api-key|x-goog-api-key|x-amz-security-token|api[_-]?key|apiKey|exa[_-]?api[_-]?key|exaApiKey|access[_-]?token|accessToken|refresh[_-]?token|refreshToken|id[_-]?token|client[_-]?secret|clientSecret|authorization|proxy-authorization|cookie|set-cookie|password|secret|token";
43
+ const CREDENTIAL_HEADER_LABEL_RAW = "copilot[_-]?session[_-]?token|session[_-]?token|sessionToken|x-api-key|x-goog-api-key|x-amz-security-token|x-dsh-auth-token|api[_-]?key|apiKey|exa[_-]?api[_-]?key|exaApiKey|access[_-]?token|accessToken|refresh[_-]?token|refreshToken|id[_-]?token|client[_-]?secret|clientSecret|authorization|proxy-authorization|cookie|set-cookie|password|secret|token";
44
44
 
45
45
  const CREDENTIAL_HEADER_LABEL = CREDENTIAL_HEADER_LABEL_RAW
46
46
  .replace(/(?<![\[\\])([A-Za-z])(?![\]\-])/g, "[$1\u0001]");
@@ -96,7 +96,7 @@ function headroomOf(provider: string, accountId: string, requestedModelId?: stri
96
96
  const family = classifyModelFamilyForQuota(provider, requestedModelId);
97
97
  if (family) {
98
98
  const percents = (quota.customWindows ?? [])
99
- .filter(window => windowMatchesFamily(window.label, family))
99
+ .filter(window => window.modelId === undefined && windowMatchesFamily(window.label, family))
100
100
  .map(window => window.percent)
101
101
  .filter((value): value is number => typeof value === "number");
102
102
  if (percents.length === 0) return null;
@@ -106,7 +106,7 @@ function headroomOf(provider: string, accountId: string, requestedModelId?: stri
106
106
  quota.fiveHourPercent,
107
107
  quota.weeklyPercent,
108
108
  quota.monthlyPercent,
109
- ...(quota.customWindows ?? []).map(window => window.percent),
109
+ ...(quota.customWindows ?? []).filter(window => window.modelId === undefined).map(window => window.percent),
110
110
  ].filter((value): value is number => typeof value === "number");
111
111
  if (percents.length === 0) return null;
112
112
  return 100 - Math.max(...percents);
@@ -140,6 +140,73 @@ export function isAccountQuotaExhausted(
140
140
  return headroom !== null && headroom <= 0;
141
141
  }
142
142
 
143
+ /**
144
+ * Earliest future reset timestamp (in ms) for the relevant quota window, or null if unknown.
145
+ * Prefers weekly reset when multiple windows exist so expiring allowance is consumed first.
146
+ */
147
+ export function accountResetTimestamp(
148
+ provider: string,
149
+ accountId: string,
150
+ requestedModelId?: string | null,
151
+ now = Date.now(),
152
+ ): number | null {
153
+ const quota = getCachedProviderAccountQuota(provider, accountId);
154
+ if (!quota) return null;
155
+ const family = classifyModelFamilyForQuota(provider, requestedModelId);
156
+ const windows = (quota.customWindows ?? []).filter(w => !family || windowMatchesFamily(w.label, family));
157
+ const weekly = windows.find(w => /week/i.test(w.label));
158
+ let rawReset = weekly?.resetAt ?? quota.weeklyResetAt;
159
+ if (rawReset === undefined && provider !== "google-antigravity" && provider !== "anthropic") {
160
+ // Providers without a weekly cadence: prefer an explicitly 5h-labeled window before
161
+ // falling back to first-window order.
162
+ const fiveHour = windows.find(w => /5h|five/i.test(w.label)) ?? windows[0];
163
+ rawReset = fiveHour?.resetAt ?? quota.fiveHourResetAt;
164
+ } else if (rawReset === undefined) {
165
+ rawReset = windows[0]?.resetAt ?? quota.fiveHourResetAt;
166
+ }
167
+ if (typeof rawReset !== "number" || !Number.isFinite(rawReset)) return null;
168
+ const ms = rawReset < 1e11 ? rawReset * 1000 : rawReset;
169
+ return ms > now ? ms : null;
170
+ }
171
+
172
+ /**
173
+ * Rank candidates by soonest reset timestamp first among healthy accounts.
174
+ * Accounts whose quota will reset earliest get priority so allowances do not expire unused.
175
+ *
176
+ * When a passive provider has incomplete quota evidence (some ring members with no
177
+ * cached record), reset timestamps are unreliable: an unknown account converts to
178
+ * Infinity and always loses to a known near-exhausted one. Fall back to headroom
179
+ * order instead of inventing a reset ordering.
180
+ */
181
+ export function rankAccountsByResetFirst(
182
+ provider: string,
183
+ ring: readonly string[],
184
+ requestedModelId?: string | null,
185
+ now = Date.now(),
186
+ accounts?: ReadonlyMap<string, ProviderAccount>,
187
+ ): string[] {
188
+ if (ring.length < 2) return [...ring];
189
+ const ranked = rankAccountsByHeadroom(provider, ring, requestedModelId, accounts);
190
+ const healthy = ranked.filter(id => !isAccountQuotaExhausted(provider, id, requestedModelId, accounts?.get(id)));
191
+ if (healthy.length < 2) return ranked;
192
+ if (
193
+ hasPassiveAccountQuota(provider)
194
+ && !ring.every(id => headroomOf(provider, id, requestedModelId, accounts?.get(id)) !== null)
195
+ ) return ranked;
196
+
197
+ const withReset = healthy.map((id, index) => ({
198
+ id,
199
+ resetAt: accountResetTimestamp(provider, id, requestedModelId, now) ?? Number.POSITIVE_INFINITY,
200
+ headroom: headroomOf(provider, id, requestedModelId, accounts?.get(id)) ?? 0,
201
+ index,
202
+ }));
203
+
204
+ withReset.sort((a, b) => (a.resetAt - b.resetAt) || (b.headroom - a.headroom) || (a.index - b.index));
205
+ const sortedHealthy = withReset.map(r => r.id);
206
+ const exhausted = ranked.filter(id => isAccountQuotaExhausted(provider, id, requestedModelId, accounts?.get(id)));
207
+ return [...sortedHealthy, ...exhausted];
208
+ }
209
+
143
210
  /**
144
211
  * Order candidates best-first.
145
212
  *
@@ -31,6 +31,22 @@ export function bindAnthropicRefusalCredentialForSend(response: Response, owner:
31
31
  responseCredentials.set(response, Object.freeze({ ...owner, providerAccountUuid, checkProviderUuid: arguments.length >= 3 }));
32
32
  }
33
33
 
34
+ /** Preserve the original send proof across internal error normalization, without renewing it. */
35
+ export function carryAnthropicRefusalCredential(source: Response, target: Response): void {
36
+ const sent = responseCredentials.get(source);
37
+ if (sent) responseCredentials.set(target, sent);
38
+ }
39
+
40
+ /** Recheck after body consumption: a late refusal cannot acquire a replacement login's authority. */
41
+ export function anthropicRefusalCredentialIsCurrent(response: Response, instance: AnthropicInstanceId, accountId: string): boolean {
42
+ const sent = responseCredentials.get(response);
43
+ if (!sent || sent.provider !== instance || sent.accountId !== accountId
44
+ || !anthropicPhysicalSendOwnershipIsCurrent(sent)) return false;
45
+ const row = getAccountCredentialWithStatus(instance, accountId);
46
+ return !!row && !row.paused && !row.needsReauth
47
+ && (!sent.checkProviderUuid || row.credential.accountId === sent.providerAccountUuid);
48
+ }
49
+
34
50
  async function isRevokedOAuthToken(response: Response, signal?: AbortSignal): Promise<boolean> {
35
51
  if (response.status !== 401) return false;
36
52
  try {
@@ -59,9 +75,11 @@ async function isAccountRefusal(response: Response, signal?: AbortSignal): Promi
59
75
  // Whole-message patterns: request-policy/model/resource refusals and quoted diagnostics
60
76
  // must not acquire account authority merely by containing entitlement keywords.
61
77
  if ("code" in error && error.code != null
62
- && (typeof error.code !== "string" || !["subscription_required", "insufficient_quota", "permission_denied"].includes(error.code))) return false;
78
+ && (typeof error.code !== "string" || !["subscription_required", "insufficient_quota", "permission_denied", "oauth_org_not_allowed"].includes(error.code))) return false;
79
+ // The organization disabled subscription access: an account-scoped verdict, cooled like its siblings.
80
+ if (error.type === "oauth_org_not_allowed" || ("code" in error && error.code === "oauth_org_not_allowed")) return true;
63
81
  const message = error.message.trim();
64
- if (error.type === "permission_error" && /^(?:Your account does not have access to Claude Code|Your (?:Claude )?subscription (?:has expired|is (?:expired|inactive))|Your account does not have an active subscription)[.!]?$/i.test(message)) return true;
82
+ if (error.type === "permission_error" && /^(?:Your account does not have access to Claude Code|Your (?:Claude )?subscription (?:has expired|is (?:expired|inactive))|Your account does not have an active subscription|(?:Your|This) organization has disabled Claude subscription access for Claude Code(?:\. Use an Anthropic API key instead, or ask your admin to enable access)?)[.!]?$/i.test(message)) return true;
65
83
  return (error.type === "billing_error" || error.type === "permission_error")
66
84
  && /^Your credit balance is too low to access the Anthropic API\.(?: Please go to Plans & Billing to upgrade or purchase credits\.)?$/i.test(message);
67
85
  } catch {
@@ -1,7 +1,7 @@
1
1
  import { ConfigMutationLockError } from "../config";
2
2
  import { deriveOAuthDefaultModel, deriveOAuthProviderConfig } from "../providers/derive";
3
3
  import type { AnthropicInstanceId } from "../providers/anthropic-instance-id";
4
- import { loginAnthropic, refreshAnthropicToken } from "./anthropic";
4
+ import { ANTHROPIC_CODE_REDIRECT_URI, loginAnthropic, refreshAnthropicToken } from "./anthropic";
5
5
  import type { OAuthProviderDef } from "./index";
6
6
  import {
7
7
  clearOAuthRefreshIntent, clearOAuthRefreshIntentIfMatch, markOAuthRefreshIntentCleanupPending,
@@ -16,8 +16,10 @@ export function anthropicOAuthDefinition(instance: AnthropicInstanceId): OAuthPr
16
16
  return {
17
17
  login: (ctrl, opts) => loginAnthropic(ctrl, {
18
18
  instance, importLocal: instance === "anthropic2" || opts?.forceLogin ? "off" : "fallback",
19
+ codeMode: opts?.codeMode === true,
19
20
  }),
20
21
  refresh: refreshAnthropicToken,
22
+ codeRedirectUri: ANTHROPIC_CODE_REDIRECT_URI,
21
23
  providerConfig,
22
24
  defaultModel,
23
25
  // Subscription OAuth must never generate unattended traffic by default.
@@ -19,7 +19,6 @@ import { clearAllAnthropicFamilyQuota, anthropicModelQuotaFor, anthropicModelPer
19
19
  */
20
20
  import { clearAllAnthropicRatePauses, anthropicRatePolicyFor, classifyAnthropic429, anthropicRetryAfterMs } from "./anthropic-rate-limit-policy";
21
21
  import { REFRESH_SKEW_MS } from "./refresh-policy";
22
- import { createHash } from "node:crypto";
23
22
  import { captureOAuthAccountSelection, commitOAuthAccountSelection, credentialGeneration, getAccountSet, getAccountCredential, getAccountCredentialWithStatus } from "./store";
24
23
  import type { OAuthAccessSnapshot } from "./index";
25
24
  import { getCachedProviderAccountQuota } from "../providers/quota";
@@ -273,26 +272,7 @@ export function formatAnthropicProviderForLog(
273
272
  return `${providerName}-${formatAnthropicAccountOrdinal(accountId)}`;
274
273
  }
275
274
 
276
- export function anthropicSessionKeyFromParts(input: {
277
- sessionIdHeader?: string | null;
278
- threadIdHeader?: string | null;
279
- promptCacheKey?: string | null;
280
- clientThreadId?: string | null;
281
- /** When true, prompt_cache_key is a shared Desktop cohort — ignore it for affinity. */
282
- promptCacheKeyIsSharedCohort?: boolean;
283
- }): string | null {
284
- const preferred = input.clientThreadId?.trim()
285
- || input.sessionIdHeader?.trim()
286
- || input.threadIdHeader?.trim()
287
- || "";
288
- if (preferred) {
289
- return preferred.length <= 128 ? preferred : createHash("sha256").update(preferred).digest("hex");
290
- }
291
- if (input.promptCacheKeyIsSharedCohort) return null;
292
- const cacheKey = input.promptCacheKey?.trim() ?? "";
293
- if (!cacheKey) return null;
294
- return cacheKey.length <= 128 ? cacheKey : createHash("sha256").update(cacheKey).digest("hex");
295
- }
275
+ export { oauthSessionKeyFromParts as anthropicSessionKeyFromParts } from "./session-affinity-key";
296
276
 
297
277
 
298
278
  export type AnthropicCooldownRecoveryClaim = Readonly<{
@@ -15,6 +15,16 @@ const CALLBACK_PATH = "/callback";
15
15
  /** Profile enrichment budget on token refresh; login paths keep the resolver's own deadline. */
16
16
  const REFRESH_PROFILE_TIMEOUT_MS = 2_000;
17
17
  const SCOPES = "org:create_api_key user:profile user:inference";
18
+ /**
19
+ * Provider-hosted code-display redirect: Anthropic renders `code#state` for the user to copy
20
+ * instead of redirecting to a local port. This is what makes login possible from a container or
21
+ * a hosted gateway, where no browser can reach this process. Same OAuth client as the callback
22
+ * flow — only the redirect differs, so it must be the redirect_uri used for BOTH the authorize
23
+ * request and the token exchange.
24
+ */
25
+ export const ANTHROPIC_CODE_REDIRECT_URI = "https://platform.claude.com/oauth/code/callback";
26
+ /** A displayed code is copied by hand across devices; the 300s callback wait is too short. */
27
+ const CODE_MODE_TIMEOUT_MS = 10 * 60_000;
18
28
 
19
29
  // ── OAuth-request requirements applied by the anthropic adapter when authMode==="oauth" ──
20
30
  export const ANTHROPIC_OAUTH_BETA = "claude-code-20250219,oauth-2025-04-20";
@@ -142,10 +152,27 @@ async function withAccountProfile(credential: OAuthCredentials, signal?: AbortSi
142
152
  }
143
153
 
144
154
  export class AnthropicOAuthFlow extends OAuthCallbackFlow {
155
+ /**
156
+ * PKCE verifier for THIS flow. One `AnthropicOAuthFlow` is constructed per login and
157
+ * `generateAuthUrl` runs once on it, so the verifier is already flow-scoped: a second
158
+ * concurrent login builds a second instance and cannot reach this field.
159
+ */
145
160
  #verifier = "";
161
+ #codeMode: boolean;
146
162
 
147
- constructor(ctrl: OAuthController) {
148
- super(ctrl, CALLBACK_PORT, CALLBACK_PATH);
163
+ constructor(ctrl: OAuthController, opts?: { codeMode?: boolean }) {
164
+ if (opts?.codeMode) {
165
+ super(ctrl, {
166
+ preferredPort: CALLBACK_PORT,
167
+ callbackPath: CALLBACK_PATH,
168
+ redirectUri: ANTHROPIC_CODE_REDIRECT_URI,
169
+ skipCallbackServer: true,
170
+ timeoutMs: CODE_MODE_TIMEOUT_MS,
171
+ });
172
+ } else {
173
+ super(ctrl, CALLBACK_PORT, CALLBACK_PATH);
174
+ }
175
+ this.#codeMode = opts?.codeMode === true;
149
176
  }
150
177
 
151
178
  async generateAuthUrl(state: string, redirectUri: string): Promise<{ url: string; instructions?: string }> {
@@ -163,8 +190,9 @@ export class AnthropicOAuthFlow extends OAuthCallbackFlow {
163
190
  });
164
191
  return {
165
192
  url: `${AUTHORIZE_URL}?${authParams.toString()}`,
166
- instructions:
167
- "Complete Claude login in your browser. If the browser cannot reach this machine, paste the final redirect URL or authorization code when prompted.",
193
+ instructions: this.#codeMode
194
+ ? "Open this URL in any browser, approve access, then paste the code Anthropic displays (it looks like code#state)."
195
+ : "Complete Claude login in your browser. If the browser cannot reach this machine, paste the final redirect URL or authorization code when prompted.",
168
196
  };
169
197
  }
170
198
 
@@ -191,9 +219,12 @@ export class AnthropicOAuthFlow extends OAuthCallbackFlow {
191
219
 
192
220
  export async function loginAnthropic(
193
221
  ctrl: OAuthController,
194
- opts?: { importLocal?: LocalTokenImportMode; instance?: AnthropicInstanceId },
222
+ opts?: { importLocal?: LocalTokenImportMode; instance?: AnthropicInstanceId; codeMode?: boolean },
195
223
  ): Promise<OAuthCredentials> {
196
- const importLocal = opts?.importLocal ?? "off";
224
+ // A code-mode caller asked for a URL to show and a box to paste into. Importing a local
225
+ // Claude Code token instead would complete WITHOUT ever firing onAuth, so the caller would
226
+ // get no URL at all — a different outcome than the one its contract promises.
227
+ const importLocal = opts?.codeMode ? "off" : opts?.importLocal ?? "off";
197
228
  if (opts?.instance === "anthropic2" && importLocal !== "off") throw new AnthropicLocalCliImportError();
198
229
  if (importLocal !== "off") {
199
230
  const { detectClaudeCodeToken } = await import("./local-token-detect");
@@ -221,7 +252,7 @@ export async function loginAnthropic(
221
252
  );
222
253
  }
223
254
  }
224
- return new AnthropicOAuthFlow(ctrl).login();
255
+ return new AnthropicOAuthFlow(ctrl, { codeMode: opts?.codeMode === true }).login();
225
256
  }
226
257
 
227
258
  export async function refreshAnthropicToken(refreshToken: string): Promise<OAuthCredentials> {
@@ -66,6 +66,15 @@ export interface OAuthCallbackFlowOptions {
66
66
  callbackBindHostname?: string;
67
67
  /** Exact redirect URI advertised to the provider; disables port fallback. */
68
68
  redirectUri?: string;
69
+ /**
70
+ * Code-display mode: the provider renders the authorization code for the user to paste, so
71
+ * this process never receives a redirect and must NOT open a listener. Requires `redirectUri`
72
+ * (the provider-hosted code page) and an `onManualCodeInput` channel — without one the flow
73
+ * would wait for a callback that can never arrive.
74
+ */
75
+ skipCallbackServer?: boolean;
76
+ /** Overrides the 300s wait for the code; code-display pastes are hand-typed and slower. */
77
+ timeoutMs?: number;
69
78
  }
70
79
 
71
80
  type BunServer = ReturnType<typeof Bun.serve>;
@@ -77,6 +86,8 @@ export abstract class OAuthCallbackFlow {
77
86
  callbackHostname: string;
78
87
  callbackBindHostname: string;
79
88
  redirectUri?: string;
89
+ skipCallbackServer: boolean;
90
+ timeoutMs: number;
80
91
  #callbackResolve?: (result: CallbackResult) => void;
81
92
  #callbackReject?: (error: string) => void;
82
93
 
@@ -86,6 +97,8 @@ export abstract class OAuthCallbackFlow {
86
97
  callbackPath: string = CALLBACK_PATH,
87
98
  ) {
88
99
  this.ctrl = ctrl;
100
+ this.skipCallbackServer = false;
101
+ this.timeoutMs = DEFAULT_TIMEOUT;
89
102
  if (typeof preferredPortOrOptions === "number") {
90
103
  this.preferredPort = preferredPortOrOptions;
91
104
  this.callbackPath = callbackPath;
@@ -98,6 +111,11 @@ export abstract class OAuthCallbackFlow {
98
111
  this.callbackHostname = preferredPortOrOptions.callbackHostname ?? DEFAULT_HOSTNAME;
99
112
  this.callbackBindHostname = preferredPortOrOptions.callbackBindHostname ?? DEFAULT_BIND_HOSTNAME;
100
113
  this.redirectUri = preferredPortOrOptions.redirectUri;
114
+ this.skipCallbackServer = preferredPortOrOptions.skipCallbackServer === true;
115
+ this.timeoutMs = preferredPortOrOptions.timeoutMs ?? DEFAULT_TIMEOUT;
116
+ if (this.skipCallbackServer && !this.redirectUri) {
117
+ throw new Error("skipCallbackServer requires an explicit redirectUri");
118
+ }
101
119
  }
102
120
 
103
121
  /** Build provider-specific authorization URL. */
@@ -132,6 +150,14 @@ export abstract class OAuthCallbackFlow {
132
150
  }
133
151
 
134
152
  async #startCallbackServer(expectedState: string): Promise<{ servers: BunServer[]; redirectUri: string }> {
153
+ if (this.skipCallbackServer) {
154
+ // The paste channel is the ONLY way this flow can complete. Fail loudly here rather than
155
+ // hanging until the timeout on a controller that can never deliver a code.
156
+ if (!this.ctrl.onManualCodeInput) {
157
+ throw new Error("Code-display login requires a manual code input channel");
158
+ }
159
+ return { servers: [], redirectUri: this.redirectUri! };
160
+ }
135
161
  try {
136
162
  const servers = this.#createServers(this.preferredPort, expectedState);
137
163
  if (this.redirectUri) {
@@ -226,7 +252,7 @@ export abstract class OAuthCallbackFlow {
226
252
  }
227
253
 
228
254
  #waitForCallback(expectedState: string): Promise<CallbackResult> {
229
- const timeoutSignal = AbortSignal.timeout(DEFAULT_TIMEOUT);
255
+ const timeoutSignal = AbortSignal.timeout(this.timeoutMs);
230
256
  const signal = this.ctrl.signal ? AbortSignal.any([this.ctrl.signal, timeoutSignal]) : timeoutSignal;
231
257
 
232
258
  const callbackPromise = new Promise<CallbackResult>((resolve, reject) => {
@@ -1,5 +1,5 @@
1
1
  import type { OAuthController, OAuthCredentials } from "./types";
2
- import { CHATGPT_CLIENT_ID, CHATGPT_TOKEN_URL, credsFromToken } from "./chatgpt";
2
+ import { CHATGPT_CLIENT_ID, CHATGPT_TOKEN_URL, credsFromToken } from "./chatgpt-shared";
3
3
 
4
4
  /**
5
5
  * OpenAI deviceauth (device-code) grant for the ChatGPT/Codex provider.