cartethyia 2.4.6 → 2.5.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 (302) hide show
  1. package/README.md +45 -13
  2. package/dist/dashboard/assets/BannedPage-9igfctfZ.js +1 -0
  3. package/dist/dashboard/assets/BannedPage-9igfctfZ.js.gz +0 -0
  4. package/dist/dashboard/assets/{CliToolDetailPage-Bcy150Jo.js → CliToolDetailPage-ZRr2K2sF.js} +1 -1
  5. package/dist/dashboard/assets/CliToolDetailPage-ZRr2K2sF.js.gz +0 -0
  6. package/dist/dashboard/assets/{CliToolsPage-DyNXefph.js → CliToolsPage-D3xqZbN-.js} +1 -1
  7. package/dist/dashboard/assets/CliToolsPage-D3xqZbN-.js.gz +0 -0
  8. package/dist/dashboard/assets/CombosPage-CVnQPBBz.js +1 -0
  9. package/dist/dashboard/assets/CombosPage-CVnQPBBz.js.gz +0 -0
  10. package/dist/dashboard/assets/{ConsoleLogPage-CATpAEOz.js → ConsoleLogPage-u6W1UXSc.js} +1 -1
  11. package/dist/dashboard/assets/ConsoleLogPage-u6W1UXSc.js.gz +0 -0
  12. package/dist/dashboard/assets/{CustomizationPage-D6jEU7nL.js → CustomizationPage-CTTpCb25.js} +1 -1
  13. package/dist/dashboard/assets/CustomizationPage-CTTpCb25.js.gz +0 -0
  14. package/dist/dashboard/assets/{HealthEventsModal-CZkpn0a7.js → HealthEventsModal-C5XpWlvy.js} +1 -1
  15. package/dist/dashboard/assets/HealthEventsModal-C5XpWlvy.js.gz +0 -0
  16. package/dist/dashboard/assets/{LoginPage-BJlMVN4U.js → LoginPage-DHoB3Agn.js} +1 -1
  17. package/dist/dashboard/assets/LoginPage-DHoB3Agn.js.gz +0 -0
  18. package/dist/dashboard/assets/{ModelPicker-Br3CXEau.js → ModelPicker-oQ-SDXp8.js} +1 -1
  19. package/dist/dashboard/assets/ModelPicker-oQ-SDXp8.js.gz +0 -0
  20. package/dist/dashboard/assets/OverviewPage-BrY4SgIt.js +1 -0
  21. package/dist/dashboard/assets/OverviewPage-BrY4SgIt.js.gz +0 -0
  22. package/dist/dashboard/assets/ProviderDetailPage-D9VSt7T_.js +3 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-D9VSt7T_.js.gz +0 -0
  24. package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js +1 -0
  25. package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js.gz +0 -0
  26. package/dist/dashboard/assets/ProvidersPage-Df77V5I5.js +1 -0
  27. package/dist/dashboard/assets/ProvidersPage-Df77V5I5.js.gz +0 -0
  28. package/dist/dashboard/assets/ProxyPage-CDfxP7AC.js +7 -0
  29. package/dist/dashboard/assets/ProxyPage-CDfxP7AC.js.gz +0 -0
  30. package/dist/dashboard/assets/QuotaPage-CY4o2t4p.js +1 -0
  31. package/dist/dashboard/assets/QuotaPage-CY4o2t4p.js.gz +0 -0
  32. package/dist/dashboard/assets/{SettingsPage-BHW4Zt06.js → SettingsPage-CvjZxHE4.js} +1 -1
  33. package/dist/dashboard/assets/SettingsPage-CvjZxHE4.js.gz +0 -0
  34. package/dist/dashboard/assets/{SetupPage-VOYC_lIa.js → SetupPage-D3nza7LH.js} +1 -1
  35. package/dist/dashboard/assets/SetupPage-D3nza7LH.js.gz +0 -0
  36. package/dist/dashboard/assets/SortableList-Dtt12WT1.js +1 -0
  37. package/dist/dashboard/assets/SortableList-Dtt12WT1.js.gz +0 -0
  38. package/dist/dashboard/assets/{StudioPage-BNIDLiU6.js → StudioPage-CbK3hXvU.js} +4 -4
  39. package/dist/dashboard/assets/StudioPage-CbK3hXvU.js.gz +0 -0
  40. package/dist/dashboard/assets/UsagePage-BQlWbWg2.js +56 -0
  41. package/dist/dashboard/assets/UsagePage-BQlWbWg2.js.gz +0 -0
  42. package/dist/dashboard/assets/{api-keys-BETfaMYZ.js → api-keys-BmDbsior.js} +1 -1
  43. package/dist/dashboard/assets/api-keys-BmDbsior.js.gz +0 -0
  44. package/dist/dashboard/assets/app-BbpSarVV.css +2 -0
  45. package/dist/dashboard/assets/app-BbpSarVV.css.gz +0 -0
  46. package/dist/dashboard/assets/assert-BHfaei-X.js +1 -0
  47. package/dist/dashboard/assets/assert-BHfaei-X.js.gz +0 -0
  48. package/dist/dashboard/assets/{badge-BTyGk_DL.js → badge-DpXT4Ixt.js} +1 -1
  49. package/dist/dashboard/assets/badge-DpXT4Ixt.js.gz +0 -0
  50. package/dist/dashboard/assets/{banner-bujTWYM4.js → banner-CSAAWLJv.js} +1 -1
  51. package/dist/dashboard/assets/banner-CSAAWLJv.js.gz +0 -0
  52. package/dist/dashboard/assets/{card-B0Bnx5CA.js → card-DEhRXXw4.js} +1 -1
  53. package/dist/dashboard/assets/card-DEhRXXw4.js.gz +0 -0
  54. package/dist/dashboard/assets/{cli-tools-DE2ug812.js → cli-tools-B0wBq-RL.js} +1 -1
  55. package/dist/dashboard/assets/cli-tools-B0wBq-RL.js.gz +0 -0
  56. package/dist/dashboard/assets/{clipboard-button-R_rArH8S.js → clipboard-button-DOo6EU8O.js} +1 -1
  57. package/dist/dashboard/assets/clipboard-button-DOo6EU8O.js.gz +0 -0
  58. package/dist/dashboard/assets/{dialog-BWIP_EvW.js → dialog-BvQeBMfP.js} +1 -1
  59. package/dist/dashboard/assets/dialog-BvQeBMfP.js.gz +0 -0
  60. package/dist/dashboard/assets/{drawer-BKcgVugF.js → drawer-CMwPXwQB.js} +1 -1
  61. package/dist/dashboard/assets/drawer-CMwPXwQB.js.gz +0 -0
  62. package/dist/dashboard/assets/{entry-CcWkGR4a.js → entry-D4O2229X.js} +1 -1
  63. package/dist/dashboard/assets/{entry-CcWkGR4a.js.gz → entry-D4O2229X.js.gz} +0 -0
  64. package/dist/dashboard/assets/{entry-kTiTFrvh.js → entry-DKvr5Q9K.js} +1 -1
  65. package/dist/dashboard/assets/entry-DKvr5Q9K.js.gz +0 -0
  66. package/dist/dashboard/assets/entry-FQCejoDY.js +4 -0
  67. package/dist/dashboard/assets/entry-FQCejoDY.js.gz +0 -0
  68. package/dist/dashboard/assets/{github-badge-Booc5mNe.js → github-badge-zliLvBwr.js} +1 -1
  69. package/dist/dashboard/assets/github-badge-zliLvBwr.js.gz +0 -0
  70. package/dist/dashboard/assets/{index-D70pChfn.js → index-CXm3FIyt.js} +2 -2
  71. package/dist/dashboard/assets/index-CXm3FIyt.js.gz +0 -0
  72. package/dist/dashboard/assets/{inline-D1TCqM2D.js → inline-D1IlZZ8H.js} +1 -1
  73. package/dist/dashboard/assets/inline-D1IlZZ8H.js.gz +0 -0
  74. package/dist/dashboard/assets/{input-B9oyU93y.js → input-BpVZdrbc.js} +1 -1
  75. package/dist/dashboard/assets/input-BpVZdrbc.js.gz +0 -0
  76. package/dist/dashboard/assets/{layout-BArzXirQ.js → layout-BioLW7gN.js} +1 -1
  77. package/dist/dashboard/assets/layout-BioLW7gN.js.gz +0 -0
  78. package/dist/dashboard/assets/{list-row-BVZzaoHF.js → list-row-CwVcGAIc.js} +1 -1
  79. package/dist/dashboard/assets/list-row-CwVcGAIc.js.gz +0 -0
  80. package/dist/dashboard/assets/{live-EmuIayqp.js → live-yfEJFF9T.js} +1 -1
  81. package/dist/dashboard/assets/live-yfEJFF9T.js.gz +0 -0
  82. package/dist/dashboard/assets/{model-bans-CSPQ9xB0.js → model-bans-CT-TxSNX.js} +1 -1
  83. package/dist/dashboard/assets/model-bans-CT-TxSNX.js.gz +0 -0
  84. package/dist/dashboard/assets/providers/cursor.webp +0 -0
  85. package/dist/dashboard/assets/providers/trae.webp +0 -0
  86. package/dist/dashboard/assets/{routing-BExgxxBU.js → routing-Cs6jl49L.js} +1 -1
  87. package/dist/dashboard/assets/routing-Cs6jl49L.js.gz +0 -0
  88. package/dist/dashboard/assets/select-C0Rer3gF.js +41 -0
  89. package/dist/dashboard/assets/select-C0Rer3gF.js.gz +0 -0
  90. package/dist/dashboard/assets/settings-CfcTcOyU.js +1 -0
  91. package/dist/dashboard/assets/settings-CfcTcOyU.js.gz +0 -0
  92. package/dist/dashboard/assets/{stack-Ca5HxI5i.js → stack-C9_uUjaA.js} +1 -1
  93. package/dist/dashboard/assets/stack-C9_uUjaA.js.gz +0 -0
  94. package/dist/dashboard/assets/{state-CzUPfLXo.js → state-DA0B95UQ.js} +1 -1
  95. package/dist/dashboard/assets/state-DA0B95UQ.js.gz +0 -0
  96. package/dist/dashboard/assets/{switch-DOUIky_i.js → switch-Cittxy-x.js} +1 -1
  97. package/dist/dashboard/assets/switch-Cittxy-x.js.gz +0 -0
  98. package/dist/dashboard/assets/{toast-C76WrMhc.js → toast-CNt_jzO4.js} +1 -1
  99. package/dist/dashboard/assets/toast-CNt_jzO4.js.gz +0 -0
  100. package/dist/dashboard/assets/{useMutation-D87FZwH0.js → useMutation-BQKMcJ1H.js} +1 -1
  101. package/dist/dashboard/assets/useMutation-BQKMcJ1H.js.gz +0 -0
  102. package/dist/dashboard/index.html +1 -1
  103. package/dist/dashboard/index.html.gz +0 -0
  104. package/dist/dashboard/precompressed-assets.json +51 -52
  105. package/migrations/full-postgres/0015_add_payload_stages.sql +18 -0
  106. package/migrations/full-postgres/0016_add_share_link_slug.sql +20 -0
  107. package/migrations/full-postgres/0017_add_models_pro_only.sql +18 -0
  108. package/migrations/full-postgres/0018_add_accounts_quota_plan.sql +18 -0
  109. package/migrations/full-postgres/0019_add_models_free_tier.sql +15 -0
  110. package/migrations/lite-sqlite3/0015_add_payload_stages.sql +18 -0
  111. package/migrations/lite-sqlite3/0016_add_share_link_slug.sql +20 -0
  112. package/migrations/lite-sqlite3/0017_add_models_pro_only.sql +18 -0
  113. package/migrations/lite-sqlite3/0018_add_accounts_quota_plan.sql +18 -0
  114. package/migrations/lite-sqlite3/0019_add_models_free_tier.sql +15 -0
  115. package/package.json +3 -2
  116. package/scripts/cli/command/console-menu.ts +30 -11
  117. package/scripts/cli/command/start.ts +77 -13
  118. package/scripts/cli/command/update.ts +1 -1
  119. package/scripts/cli/gateway/instance-lock.ts +105 -0
  120. package/scripts/cli/gateway/spawn.ts +173 -101
  121. package/scripts/cli/ui/background.ts +110 -49
  122. package/src/boot/app.ts +20 -6
  123. package/src/boot/dependencies.ts +19 -0
  124. package/src/boot/elysia-typebox-bridge.ts +37 -0
  125. package/src/boot/lifecycle.ts +2 -0
  126. package/src/config/cooldowns.ts +138 -138
  127. package/src/config/providers.ts +5 -5
  128. package/src/config/runtime.ts +19 -3
  129. package/src/config/spec.ts +9 -0
  130. package/src/console/api-keys/contracts.ts +414 -391
  131. package/src/console/api-keys/routes.ts +880 -870
  132. package/src/console/auth/account-reset-store.ts +14 -0
  133. package/src/console/auth/account-reset.ts +3 -2
  134. package/src/console/auth/setup-service.ts +6 -3
  135. package/src/console/domain-registration.ts +9 -2
  136. package/src/console/observability/contracts.ts +475 -455
  137. package/src/console/observability/store.ts +1015 -952
  138. package/src/console/providers/catalog/contracts.ts +31 -0
  139. package/src/console/providers/catalog/provider-operations.ts +8 -0
  140. package/src/console/providers/catalog/store.ts +1532 -1525
  141. package/src/console/providers/oauth/callback-listener.ts +18 -3
  142. package/src/console/providers/oauth/routes.ts +38 -15
  143. package/src/console/quota/refresh.ts +19 -0
  144. package/src/console/updates/routes.ts +162 -4
  145. package/src/main.ts +3 -0
  146. package/src/network/http2-bidi.ts +85 -0
  147. package/src/network/outbound-fetch.ts +1 -26
  148. package/src/network/pool/agent.ts +478 -460
  149. package/src/network/pool/resolver.ts +1104 -544
  150. package/src/network/pool-health-machine.ts +12 -5
  151. package/src/observability/payload-capture.ts +344 -324
  152. package/src/persistence/db-pg.ts +9 -3
  153. package/src/persistence/db-sqlite.ts +497 -495
  154. package/src/persistence/health-event-flush.ts +137 -0
  155. package/src/persistence/migrate.ts +181 -129
  156. package/src/persistence/schema.ts +1360 -1295
  157. package/src/persistence/settings-revision.ts +25 -0
  158. package/src/persistence/shared-cache.ts +217 -0
  159. package/src/persistence/stores/share-store.ts +556 -528
  160. package/src/persistence/usage-flush.ts +61 -1
  161. package/src/protocol/canonical-model.ts +23 -1
  162. package/src/protocol/response/chat.ts +143 -10
  163. package/src/protocol/response/omp-tool-call-markup.ts +221 -0
  164. package/src/providers/default-registry.ts +173 -1
  165. package/src/providers/discovery/openai-model-discovery.ts +39 -3
  166. package/src/providers/discovery/openrouter-free-roster.ts +98 -0
  167. package/src/providers/discovery/probing-service.ts +1022 -1001
  168. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +39 -0
  169. package/src/providers/integrations/buddy/buddy-chat-shared.ts +913 -914
  170. package/src/providers/integrations/buddy/buddy-conversation-headers.ts +18 -5
  171. package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +4 -0
  172. package/src/providers/integrations/buddy/codebuddy/provider.ts +180 -175
  173. package/src/providers/integrations/buddy/codebuddy/shared.ts +15 -6
  174. package/src/providers/integrations/buddy/workbuddy/provider.ts +206 -204
  175. package/src/providers/integrations/buddy/workbuddy/shared.ts +3 -3
  176. package/src/providers/integrations/claude/claude.ts +606 -582
  177. package/src/providers/integrations/cline/cline-quota.ts +18 -16
  178. package/src/providers/integrations/cline/cline.ts +539 -468
  179. package/src/providers/integrations/commandcode/commandcode.ts +675 -541
  180. package/src/providers/integrations/commandcode/discovery.ts +116 -0
  181. package/src/providers/integrations/commandcode/quota.ts +160 -0
  182. package/src/providers/integrations/cursor/cursor-auth.ts +418 -0
  183. package/src/providers/integrations/cursor/cursor-catalog.ts +368 -0
  184. package/src/providers/integrations/cursor/cursor-http2.ts +300 -0
  185. package/src/providers/integrations/cursor/cursor-identity.ts +92 -0
  186. package/src/providers/integrations/cursor/cursor-request.ts +471 -0
  187. package/src/providers/integrations/cursor/cursor-session-manager.ts +281 -0
  188. package/src/providers/integrations/cursor/cursor-shared.ts +108 -0
  189. package/src/providers/integrations/cursor/cursor.ts +960 -0
  190. package/src/providers/integrations/cursor/protocol/framing.ts +129 -0
  191. package/src/providers/integrations/cursor/protocol/messages.ts +2355 -0
  192. package/src/providers/integrations/cursor/protocol/struct.ts +305 -0
  193. package/src/providers/integrations/github/github.ts +4 -4
  194. package/src/providers/integrations/hermes.ts +79 -8
  195. package/src/providers/integrations/kilo/kilo-discovery.ts +10 -1
  196. package/src/providers/integrations/kilo/kilo.ts +153 -146
  197. package/src/providers/integrations/opencode/provider.ts +532 -538
  198. package/src/providers/integrations/openrouter/provider.ts +139 -281
  199. package/src/providers/integrations/qoder/qoder-catalog.ts +450 -0
  200. package/src/providers/integrations/qoder/qoder-oauth.ts +310 -257
  201. package/src/providers/integrations/qoder/qoder-realm.ts +106 -0
  202. package/src/providers/integrations/qoder/qoder-virtual.ts +77 -0
  203. package/src/providers/integrations/qoder/qoder.ts +865 -730
  204. package/src/providers/integrations/trae/trae-catalog.ts +190 -0
  205. package/src/providers/integrations/trae/trae-headers.ts +149 -0
  206. package/src/providers/integrations/trae/trae-oauth.ts +773 -0
  207. package/src/providers/integrations/trae/trae-protocol.ts +400 -0
  208. package/src/providers/integrations/trae/trae-realm.ts +220 -0
  209. package/src/providers/integrations/trae/trae.ts +458 -0
  210. package/src/providers/operations/account-health-service.ts +107 -10
  211. package/src/providers/operations/client-versions.ts +133 -6
  212. package/src/providers/operations/provider-catalog-seeder.ts +95 -86
  213. package/src/providers/operations/provider-credential-service.ts +46 -7
  214. package/src/providers/provider-contracts.ts +76 -0
  215. package/src/providers/provider-metadata.ts +78 -6
  216. package/src/providers/provider-model-definition.ts +373 -349
  217. package/src/security/api-key-auth.ts +126 -8
  218. package/src/transport/contracts.ts +3 -0
  219. package/src/transport/dispatch/attempt-finalize.ts +32 -1
  220. package/src/transport/dispatch/attempt-loop.ts +530 -515
  221. package/src/transport/dispatch/generation.ts +88 -21
  222. package/src/transport/dispatch/leases.ts +99 -2
  223. package/src/transport/dispatch/proxy-request.ts +60 -3
  224. package/src/transport/dispatch/streaming/finalize.ts +16 -1
  225. package/src/transport/dispatch/streaming/stream.ts +22 -2
  226. package/src/transport/dispatch/upstream.ts +19 -1
  227. package/src/transport/guards/auth-guard.ts +26 -1
  228. package/src/transport/guards/body-policy.ts +5 -4
  229. package/src/transport/request/preparer.ts +103 -0
  230. package/src/transport/request/state.ts +14 -0
  231. package/src/transport/routing/plan-gate.ts +45 -0
  232. package/src/transport/routing/route-catalog.ts +799 -777
  233. package/src/transport/routing/route-model.ts +52 -1
  234. package/src/transport/routing/router.ts +51 -0
  235. package/dist/dashboard/assets/BannedPage-QhvbJB9o.js +0 -1
  236. package/dist/dashboard/assets/BannedPage-QhvbJB9o.js.gz +0 -0
  237. package/dist/dashboard/assets/CliToolDetailPage-Bcy150Jo.js.gz +0 -0
  238. package/dist/dashboard/assets/CliToolsPage-DyNXefph.js.gz +0 -0
  239. package/dist/dashboard/assets/CombosPage-Cak0ljnO.js +0 -1
  240. package/dist/dashboard/assets/CombosPage-Cak0ljnO.js.gz +0 -0
  241. package/dist/dashboard/assets/ConsoleLogPage-CATpAEOz.js.gz +0 -0
  242. package/dist/dashboard/assets/CustomizationPage-D6jEU7nL.js.gz +0 -0
  243. package/dist/dashboard/assets/HealthEventsModal-CZkpn0a7.js.gz +0 -0
  244. package/dist/dashboard/assets/LoginPage-BJlMVN4U.js.gz +0 -0
  245. package/dist/dashboard/assets/ModelPicker-Br3CXEau.js.gz +0 -0
  246. package/dist/dashboard/assets/OverviewPage-CQxXbo2g.js +0 -1
  247. package/dist/dashboard/assets/OverviewPage-CQxXbo2g.js.gz +0 -0
  248. package/dist/dashboard/assets/ProviderDetailPage--0RBNh99.js +0 -3
  249. package/dist/dashboard/assets/ProviderDetailPage--0RBNh99.js.gz +0 -0
  250. package/dist/dashboard/assets/ProviderIcon-paOCq8I0.js +0 -1
  251. package/dist/dashboard/assets/ProviderIcon-paOCq8I0.js.gz +0 -0
  252. package/dist/dashboard/assets/ProvidersPage-1M-C3mbu.js +0 -1
  253. package/dist/dashboard/assets/ProvidersPage-1M-C3mbu.js.gz +0 -0
  254. package/dist/dashboard/assets/ProxyPage-CXiumA53.js +0 -7
  255. package/dist/dashboard/assets/ProxyPage-CXiumA53.js.gz +0 -0
  256. package/dist/dashboard/assets/QuotaPage-BGDIfIYP.js +0 -1
  257. package/dist/dashboard/assets/QuotaPage-BGDIfIYP.js.gz +0 -0
  258. package/dist/dashboard/assets/SettingsPage-BHW4Zt06.js.gz +0 -0
  259. package/dist/dashboard/assets/SetupPage-VOYC_lIa.js.gz +0 -0
  260. package/dist/dashboard/assets/SortableList-DpOTnGuH.js +0 -1
  261. package/dist/dashboard/assets/SortableList-DpOTnGuH.js.gz +0 -0
  262. package/dist/dashboard/assets/StudioPage-BNIDLiU6.js.gz +0 -0
  263. package/dist/dashboard/assets/UsagePage-BAFaSDQV.js +0 -56
  264. package/dist/dashboard/assets/UsagePage-BAFaSDQV.js.gz +0 -0
  265. package/dist/dashboard/assets/api-keys-BETfaMYZ.js.gz +0 -0
  266. package/dist/dashboard/assets/app-BLCvGdzb.css +0 -2
  267. package/dist/dashboard/assets/app-BLCvGdzb.css.gz +0 -0
  268. package/dist/dashboard/assets/assert-7J6_LVD3.js +0 -1
  269. package/dist/dashboard/assets/assert-7J6_LVD3.js.gz +0 -0
  270. package/dist/dashboard/assets/badge-BTyGk_DL.js.gz +0 -0
  271. package/dist/dashboard/assets/banner-bujTWYM4.js.gz +0 -0
  272. package/dist/dashboard/assets/card-B0Bnx5CA.js.gz +0 -0
  273. package/dist/dashboard/assets/cli-tools-DE2ug812.js.gz +0 -0
  274. package/dist/dashboard/assets/clipboard-button-R_rArH8S.js.gz +0 -0
  275. package/dist/dashboard/assets/dialog-BWIP_EvW.js.gz +0 -0
  276. package/dist/dashboard/assets/drawer-BKcgVugF.js.gz +0 -0
  277. package/dist/dashboard/assets/entry-7dpgvpJ3.js +0 -4
  278. package/dist/dashboard/assets/entry-7dpgvpJ3.js.gz +0 -0
  279. package/dist/dashboard/assets/entry-kTiTFrvh.js.gz +0 -0
  280. package/dist/dashboard/assets/github-badge-Booc5mNe.js.gz +0 -0
  281. package/dist/dashboard/assets/index-D70pChfn.js.gz +0 -0
  282. package/dist/dashboard/assets/inline-D1TCqM2D.js.gz +0 -0
  283. package/dist/dashboard/assets/input-B9oyU93y.js.gz +0 -0
  284. package/dist/dashboard/assets/layout-BArzXirQ.js.gz +0 -0
  285. package/dist/dashboard/assets/list-row-BVZzaoHF.js.gz +0 -0
  286. package/dist/dashboard/assets/live-EmuIayqp.js.gz +0 -0
  287. package/dist/dashboard/assets/model-bans-CSPQ9xB0.js.gz +0 -0
  288. package/dist/dashboard/assets/routing-BExgxxBU.js.gz +0 -0
  289. package/dist/dashboard/assets/select-C9mMoEpJ.js +0 -41
  290. package/dist/dashboard/assets/select-C9mMoEpJ.js.gz +0 -0
  291. package/dist/dashboard/assets/settings-C4_Gn_wq.js +0 -1
  292. package/dist/dashboard/assets/settings-C4_Gn_wq.js.gz +0 -0
  293. package/dist/dashboard/assets/sliders-horizontal-ClG7dBeJ.js +0 -1
  294. package/dist/dashboard/assets/sliders-horizontal-ClG7dBeJ.js.gz +0 -0
  295. package/dist/dashboard/assets/stack-Ca5HxI5i.js.gz +0 -0
  296. package/dist/dashboard/assets/state-CzUPfLXo.js.gz +0 -0
  297. package/dist/dashboard/assets/switch-DOUIky_i.js.gz +0 -0
  298. package/dist/dashboard/assets/toast-C76WrMhc.js.gz +0 -0
  299. package/dist/dashboard/assets/useMutation-D87FZwH0.js.gz +0 -0
  300. package/src/network/tls/bun-client-hello.ts +0 -112
  301. /package/dist/dashboard/assets/{app-Cjq7nrwu.js → app-C-Y4oZT0.js} +0 -0
  302. /package/dist/dashboard/assets/{app-Cjq7nrwu.js.gz → app-C-Y4oZT0.js.gz} +0 -0
@@ -0,0 +1,773 @@
1
+ // Trae credential login and refresh.
2
+ //
3
+ // Trae has a real browser authorization flow, just not a device-code one: the
4
+ // other donor's `start_login` answers `unsupported` for device authorization,
5
+ // which is what led to the earlier claim that Trae had no OAuth login at all.
6
+ // The browser path is `GetLoginGuidance` (discovers the login host) → the
7
+ // operator signs in at `/authorization` → the callback carries a refresh token
8
+ // → `ExchangeToken` redeems it → `GetUserInfo` names the account.
9
+ //
10
+ // A pasted credential (a PAT — which *is* a refresh token — or a desktop
11
+ // export) still enters through `importCredential`, so both doors are open.
12
+ //
13
+ // The exchange is `POST /cloudide/api/v3/trae/oauth/ExchangeToken` with a fixed
14
+ // client id. Upstream's client secret is the literal string `"-"`, which is
15
+ // upstream fact rather than a placeholder we failed to fill.
16
+ //
17
+ // Rotation is the rule that shapes the return value: every exchange returns a
18
+ // NEW refresh token and invalidates the one sent. A refresh that persisted only
19
+ // the access token would leave the account holding a dead refresh token and
20
+ // unrecoverable on its next expiry.
21
+ //
22
+ // Sourced from the donor hub `Public/trae2api-hub` (`_refresh_account_request`
23
+ // in `trae_client.py`).
24
+
25
+ import { randomUUID } from "node:crypto";
26
+ import { OAuthClient } from "../../authentication/oauth-client";
27
+ import type { OAuthExchangeResult, OAuthImportInput } from "../../authentication/oauth-flow-store";
28
+ import type { OAuthTokenRefreshResult } from "../../authentication/oauth-refresh-service";
29
+ import {
30
+ TRAE_AUTHORIZATION_PATH,
31
+ TRAE_CALLBACK_PATH,
32
+ TRAE_EXCHANGE_TOKEN_PATH,
33
+ TRAE_GET_USER_INFO_PATH,
34
+ TRAE_LOGIN_GUIDANCE_URLS,
35
+ TRAE_AUTH_CODE_EXCHANGE_PATHS,
36
+ traeAccountCandidates,
37
+ type TraeRealm,
38
+ } from "./trae-realm";
39
+ import { TRAE_DEVICE_BRAND, TRAE_OS_VERSION, traeDeviceIdentity } from "./trae-headers";
40
+ import { getTraeVersion } from "../../operations/client-versions";
41
+ import { browserAuthorizeRedirectUri } from "../../../config/providers";
42
+
43
+ /** Upstream client id. Constant across realms. */
44
+ const TRAE_CLIENT_ID = "ono9krqynydwx5";
45
+
46
+ /** UA for the login steps, distinct from the data-plane UA. */
47
+ const TRAE_OAUTH_USER_AGENT = "Trae/1.0.0 antigravity-cockpit-tools";
48
+
49
+ /**
50
+ * Plugin version the desktop client reports.
51
+ *
52
+ * Observed in a real authorize URL as `2.3.93439`. The donor sent `local`,
53
+ * which is what the *web* build reports; the login page accepts it, but the
54
+ * value is part of the fingerprint upstream correlates, so the desktop shape
55
+ * is used here.
56
+ */
57
+ const TRAE_PLUGIN_VERSION = "2.3.93439";
58
+
59
+ /**
60
+ * Device context for the login.
61
+ *
62
+ * Derived with the same function the data plane uses, so one account presents
63
+ * one device across login and requests. A per-process random pair — what the
64
+ * donor sends — made every gateway restart look like a brand new device to the
65
+ * risk-control surface that correlates them.
66
+ *
67
+ * The seed is the login trace id because no account identity exists yet: the
68
+ * authorize step is what produces the account.
69
+ */
70
+ function traeLoginDevice(traceId: string): { machineId: string; deviceId: string } {
71
+ return traeDeviceIdentity(traceId);
72
+ }
73
+
74
+ /**
75
+ * Upstream client secret.
76
+ *
77
+ * Literally `"-"` as the desktop client sends it. Not a missing value.
78
+ */
79
+ const TRAE_CLIENT_SECRET = "-";
80
+
81
+ /**
82
+ * Response envelope: the payload is nested under `Result`, and the error
83
+ * carries `Code`/`Message` at the top level.
84
+ */
85
+ interface TraeExchangeResult {
86
+ readonly Token?: string;
87
+ readonly RefreshToken?: string;
88
+ /** Access-token expiry, epoch milliseconds. */
89
+ readonly TokenExpireAt?: number;
90
+ /** Refresh-token expiry, epoch milliseconds. */
91
+ readonly RefreshExpireAt?: number;
92
+ readonly TokenExpireDuration?: number;
93
+ readonly UserJwt?: string;
94
+ }
95
+
96
+ interface TraeExchangeResponse extends TraeExchangeResult {
97
+ readonly Result?: TraeExchangeResult;
98
+ readonly Code?: string | number;
99
+ readonly Message?: string;
100
+ }
101
+
102
+ export class TraeOAuthClient extends OAuthClient {
103
+ /**
104
+ * Trae has no device-code flow — the donor's `start_login` answers
105
+ * `unsupported` for it — but it does have a browser authorization flow:
106
+ * `GetLoginGuidance` discovers the login host, the operator signs in there,
107
+ * and the callback carries a refresh token that `ExchangeToken` redeems.
108
+ *
109
+ * Declaring only the browser half is what keeps the console from offering a
110
+ * device login that cannot work.
111
+ */
112
+ override readonly supportsDeviceCode = false;
113
+ override readonly supportsBrowserCode = true;
114
+
115
+ /**
116
+ * The login page only accepts `/authorize` as the callback path (see
117
+ * `TRAE_CALLBACK_PATH`); the gateway-wide default is `/callback`, which it
118
+ * answers with "Login Failed" before consent. The port still comes from the
119
+ * gateway's own redirect setting so a deployment can move it.
120
+ */
121
+ override readonly browserRedirectUri: string = withTraeCallbackPath(browserAuthorizeRedirectUri());
122
+
123
+ protected override readonly providerLabel = "Trae";
124
+ protected override readonly clientId = TRAE_CLIENT_ID;
125
+ /** The exchange is per-realm: CN and intl use different account hosts. */
126
+ protected override readonly tokenUrl = "";
127
+ protected override readonly scopes = "";
128
+ /**
129
+ * Resolved at authorize time by `GetLoginGuidance` rather than pinned: the
130
+ * login host is an upstream discovery, and hard-coding the one observed today
131
+ * would break the day upstream moves it.
132
+ */
133
+ protected override readonly authorizeUrl = "";
134
+
135
+ private readonly realm: TraeRealm;
136
+
137
+ constructor(fetchFn: typeof fetch = globalThis.fetch, realm: TraeRealm = "intl") {
138
+ super(fetchFn);
139
+ this.realm = realm;
140
+ }
141
+
142
+ /**
143
+ * Resolves the login host before the authorize URL is built.
144
+ *
145
+ * The base `buildAuthorizeUrl` is synchronous and needs a static
146
+ * `authorizeUrl`, neither of which fits a host that upstream hands out per
147
+ * login. Resolving it here — the async step the console awaits before it
148
+ * builds the URL — and passing it on in `parameters` keeps the URL builder
149
+ * synchronous without pinning a host that can move.
150
+ */
151
+ async prepareAuthorize(request: {
152
+ readonly state: string;
153
+ readonly codeChallenge: string;
154
+ readonly redirectUri: string;
155
+ readonly parameters?: Readonly<Record<string, string>>;
156
+ }): Promise<{ readonly state: string; readonly codeChallenge: string; readonly redirectUri: string; readonly parameters?: Readonly<Record<string, string>> }> {
157
+ /**
158
+ * `login_trace_id` is a UUID, not the flow state.
159
+ *
160
+ * Upstream's page validates it as one — a base64url state (what the console
161
+ * generates) is not accepted. The flow state still has to reach the
162
+ * callback to match it to a pending login, so it rides along in
163
+ * `parameters` and is appended to the redirect as a query parameter.
164
+ */
165
+ const traceId = randomUUID();
166
+ const host = await this.loginHost(traceId);
167
+ return {
168
+ ...request,
169
+ parameters: { ...(request.parameters ?? {}), loginHost: host, loginTraceId: traceId },
170
+ };
171
+ }
172
+
173
+ /**
174
+ * Builds the URL the operator opens to sign in.
175
+ *
176
+ * `state` carries the login trace id, so the callback can be matched to the
177
+ * browser session that produced it.
178
+ */
179
+ override buildAuthorizeUrl(request: {
180
+ readonly state: string;
181
+ readonly codeChallenge?: string;
182
+ readonly redirectUri: string;
183
+ readonly parameters?: Readonly<Record<string, string>>;
184
+ }): string {
185
+ const host = request.parameters?.["loginHost"];
186
+ if (host === undefined) {
187
+ throw new Error("Trae login host is not resolved — the authorize step must run first");
188
+ }
189
+ /**
190
+ * The trace id is the flow `state`, not a fresh UUID: the login page echoes
191
+ * it back on the callback, and that echo is what the loopback listener
192
+ * matches the pending flow against. A separate random trace id would leave
193
+ * the callback with no correlation key.
194
+ */
195
+ const traceId = request.parameters?.["loginTraceId"] ?? request.state;
196
+ const device = traeLoginDevice(traceId);
197
+ const url = new URL(host.startsWith("http") ? host : `https://${host.replace(/^\/+/, "")}`);
198
+ url.pathname = TRAE_AUTHORIZATION_PATH;
199
+ /**
200
+ * Parameter set mirrored from a real client authorize URL. Three of these
201
+ * were missing and are not decoration:
202
+ *
203
+ * - `channel_name`: selects the desktop distribution channel; without it
204
+ * the page falls back to a build that does not hand back a refresh token.
205
+ *
206
+ * `code_challenge`/`code_challenge_method` are deliberately ABSENT. The page
207
+ * branches on them: with PKCE it mints an `AuthCode` via `GetPCAuthCode`,
208
+ * and that code is an opaque ticket for another client — nothing redeems it
209
+ * back into a token pair (verified: the PKCE pair matched, and every
210
+ * exchange endpoint answered "Invalid param"). Without them it sends a
211
+ * refresh token, which this gateway can rotate.
212
+ * - the device fields: the login is correlated with the data-plane
213
+ * fingerprint, so an `unknown`/`0` set is a device upstream has never
214
+ * seen from this account.
215
+ */
216
+ const params = new URLSearchParams({
217
+ login_version: "1",
218
+ auth_from: "trae",
219
+ login_channel: "native_ide",
220
+ plugin_version: TRAE_PLUGIN_VERSION,
221
+ auth_type: "local",
222
+ client_id: TRAE_CLIENT_ID,
223
+ redirect: "0",
224
+ login_trace_id: traceId,
225
+ auth_callback_url: request.redirectUri,
226
+ machine_id: device.machineId,
227
+ device_id: device.deviceId,
228
+ x_device_id: device.deviceId,
229
+ x_machine_id: device.machineId,
230
+ x_device_brand: TRAE_DEVICE_BRAND,
231
+ x_device_type: "windows",
232
+ x_os_version: TRAE_OS_VERSION,
233
+ x_env: "",
234
+ x_app_version: getTraeVersion(),
235
+ x_app_type: "stable",
236
+ channel_name: "common",
237
+ });
238
+ url.search = params.toString();
239
+ return url.toString();
240
+ }
241
+
242
+ /**
243
+ * Asks upstream where to sign in.
244
+ *
245
+ * Tried in order across the known guidance hosts: the call is what discovers
246
+ * the login host, so it cannot be scoped to one realm's account host.
247
+ */
248
+ private async loginHost(loginTraceId: string): Promise<string> {
249
+ const body = JSON.stringify({ loginTraceID: loginTraceId, login_trace_id: loginTraceId });
250
+ let lastError = "no successful response";
251
+ for (const url of TRAE_LOGIN_GUIDANCE_URLS) {
252
+ try {
253
+ const response = await this.fetchFn(url, {
254
+ method: "POST",
255
+ headers: {
256
+ Accept: "application/json",
257
+ "Content-Type": "application/json",
258
+ "User-Agent": TRAE_OAUTH_USER_AGENT,
259
+ },
260
+ body,
261
+ });
262
+ if (!response.ok) {
263
+ lastError = `${url} HTTP ${response.status}`;
264
+ continue;
265
+ }
266
+ const data = (await response.json()) as { Result?: { LoginHost?: unknown } };
267
+ const host = data.Result?.LoginHost;
268
+ if (typeof host === "string" && host.length > 0) return host;
269
+ lastError = `${url} missing LoginHost`;
270
+ } catch (err: unknown) {
271
+ lastError = `${url} ${err instanceof Error ? err.message : "request failed"}`;
272
+ }
273
+ }
274
+ throw new Error(`Trae GetLoginGuidance failed: ${lastError}`);
275
+ }
276
+
277
+ /**
278
+ * Completes the login from the callback the operator pastes back.
279
+ *
280
+ * Trae's callback is not an authorization code — it carries the refresh token
281
+ * directly, so `code` is the callback URL (or its query string) and the
282
+ * exchange is what turns it into an access token.
283
+ */
284
+ override async exchangeCode(
285
+ code: string,
286
+ codeVerifier: string,
287
+ _redirectUri: string,
288
+ ): Promise<OAuthExchangeResult> {
289
+ const callback = parseTraeCallback(code);
290
+ // Two shapes, two exchanges. The PKCE code is redeemed against the
291
+ // verifier; a refresh token is rotated. Sending one where the other is
292
+ // expected is answered "refresh token is invalid" or "Invalid param", so
293
+ // the branch is not an optimisation.
294
+ const exchanged =
295
+ callback.authCode === undefined
296
+ ? await this.refreshWithUser(callback.refreshToken ?? "", "")
297
+ : await this.exchangeAuthCode(callback.authCode, codeVerifier, callback.host);
298
+ const identity = await this.userInfo(exchanged.access);
299
+ const state: Record<string, unknown> = {};
300
+ if (identity.userId !== undefined) state["userId"] = identity.userId;
301
+ if (callback.region !== undefined) state["region"] = callback.region;
302
+ return {
303
+ access: exchanged.access,
304
+ // A refresh-token rotation may not return a new refresh token; the one
305
+ // that was sent stays valid in that case, so it is the fallback.
306
+ refresh: exchanged.refresh ?? callback.refreshToken ?? "",
307
+ expiresAt: new Date(exchanged.expiresAt),
308
+ ...(identity.email === undefined ? {} : { accountLabel: identity.email }),
309
+ ...(Object.keys(state).length === 0 ? {} : { auth_state: state }),
310
+ };
311
+ }
312
+
313
+ /**
314
+ * Redeems the PKCE code the login page redirects with.
315
+ *
316
+ * `public/oauth/ExchangeToken` is the endpoint that accepts `AuthCode` — the
317
+ * authenticated `oauth/ExchangeToken` only takes `RefreshToken` and answers
318
+ * any code with "Invalid param". The body is taken verbatim from the page's
319
+ * own client, which also sends an empty `RefreshToken`: upstream's binding
320
+ * requires the field to exist, not to be set.
321
+ */
322
+ private async exchangeAuthCode(
323
+ authCode: string,
324
+ codeVerifier: string,
325
+ host: string | undefined,
326
+ ): Promise<{ access: string; refresh: string; expiresAt: number }> {
327
+ const base = (host ?? traeAccountCandidates(this.realm)[0] ?? "").replace(/\/+$/, "");
328
+ /**
329
+ * `DeviceInfo` and `DeviceProof` must be maps, not strings — the gateway
330
+ * rejects the string form with an explicit binding error. Empty maps pass,
331
+ * and `RefreshToken` has to be present (the binding requires the field, not
332
+ * a value) even though an AuthCode exchange does not use it.
333
+ */
334
+ const body = JSON.stringify({
335
+ ClientID: TRAE_CLIENT_ID,
336
+ ClientSecret: TRAE_CLIENT_SECRET,
337
+ RefreshToken: "-",
338
+ UserID: "",
339
+ AuthCode: authCode,
340
+ CodeVerifier: codeVerifier,
341
+ DeviceInfo: {},
342
+ DeviceProof: {},
343
+ });
344
+ let lastError = "no endpoint answered";
345
+ const errors: string[] = [];
346
+ for (const path of TRAE_AUTH_CODE_EXCHANGE_PATHS) {
347
+ let response: Response;
348
+ try {
349
+ response = await this.fetchFn(`${base}${path}`, {
350
+ method: "POST",
351
+ headers: {
352
+ Accept: "application/json",
353
+ "Content-Type": "application/json",
354
+ "User-Agent": TRAE_OAUTH_USER_AGENT,
355
+ },
356
+ body,
357
+ });
358
+ } catch (failure) {
359
+ lastError = `${path} ${failure instanceof Error ? failure.message : "request failed"}`;
360
+ continue;
361
+ }
362
+ const text = await response.text();
363
+ if (!response.ok) {
364
+ lastError = `${path} HTTP ${response.status} ${text.slice(0, 200)}`;
365
+ // Both paths are kept in the message: the first one's rejection is what
366
+ // says whether the verifier or the endpoint is wrong, and only the last
367
+ // was surviving before.
368
+ errors.push(lastError);
369
+ continue;
370
+ }
371
+ const parsed = JSON.parse(text) as TraeExchangeResponse;
372
+ const result = (parsed.Result ?? parsed) as unknown as Record<string, unknown>;
373
+ const access = firstString(result, ["Token", "token"]);
374
+ const refresh = firstString(result, ["RefreshToken", "refreshToken"]);
375
+ if (access === undefined || refresh === undefined) {
376
+ lastError = `${path} returned no token pair`;
377
+ continue;
378
+ }
379
+ const expiresAt =
380
+ firstNumber(result, ["TokenExpireAt", "expireAt"]) ?? Date.now() + 3_600_000;
381
+ return { access, refresh, expiresAt };
382
+ }
383
+ throw new Error(`Trae AuthCode exchange failed: ${errors.length > 0 ? errors.join(" | ") : lastError}`);
384
+ }
385
+
386
+ /** Reads the account identity the fresh access token names. */
387
+ private async userInfo(accessToken: string): Promise<{ email?: string; userId?: string }> {
388
+ for (const host of traeAccountCandidates(this.realm)) {
389
+ try {
390
+ const response = await this.fetchFn(`${host}${TRAE_GET_USER_INFO_PATH}`, {
391
+ method: "POST",
392
+ headers: {
393
+ Accept: "application/json",
394
+ "Content-Type": "application/json",
395
+ "User-Agent": TRAE_OAUTH_USER_AGENT,
396
+ "x-cloudide-token": accessToken,
397
+ },
398
+ body: JSON.stringify({}),
399
+ });
400
+ if (!response.ok) continue;
401
+ const data = (await response.json()) as { Result?: Record<string, unknown> };
402
+ const result = data.Result ?? {};
403
+ const email = firstStringValue(result, ["NonPlainTextEmail", "Email", "email"]);
404
+ const userId = firstStringValue(result, ["UserID", "userId"]);
405
+ return {
406
+ ...(email === undefined ? {} : { email }),
407
+ ...(userId === undefined ? {} : { userId }),
408
+ };
409
+ } catch {
410
+ // Identity is optional: the account is usable without it.
411
+ }
412
+ }
413
+ return {};
414
+ }
415
+
416
+ /**
417
+ * Validates a pasted Trae export and stores it as a refreshable account.
418
+ *
419
+ * Trae has no authorize endpoint to click through, so the operator pastes
420
+ * the credential the desktop client already minted. Importing it through this
421
+ * path rather than the API-key path is what puts the refresh token in
422
+ * `refresh_ciphertext` — an account imported as a plain key has no refresh
423
+ * token stored and dies at expiry with nothing to exchange.
424
+ *
425
+ * `importFields` is left empty: the export carries everything needed, and
426
+ * asking the operator for a field the blob already contains invites a
427
+ * mismatch with the account the token belongs to.
428
+ */
429
+ async importCredential(input: OAuthImportInput): Promise<OAuthExchangeResult> {
430
+ const parsed = parseExport(input.credential);
431
+ if (parsed === undefined) {
432
+ // A Trae PAT *is* a refresh token, so the one-token form is accepted
433
+ // here and exchanged below rather than being stored as a dead access
434
+ // token.
435
+ throw new Error(
436
+ "Paste a Trae account export (JSON with accessToken + refreshToken), a PAT, or a refresh token",
437
+ );
438
+ }
439
+
440
+ // A full export already carries a usable access token; storing it directly
441
+ // avoids spending the refresh rotation on a credential that is still valid.
442
+ if (parsed.refresh === undefined && parsed.expiresAtMs !== undefined) {
443
+ return parsedResult(parsed, parsed.access, parsed.access, true);
444
+ }
445
+ if (parsed.refresh !== undefined && parsed.expiresAtMs !== undefined) {
446
+ return parsedResult(parsed, parsed.access, parsed.refresh, false);
447
+ }
448
+
449
+ // Only a refresh token (a PAT, or a bare token with no expiry): exchange it
450
+ // now. Upstream is the only source of the access token and of the account
451
+ // identity, and the exchange also proves the credential is live before it
452
+ // is stored — an import that failed here would otherwise surface as a
453
+ // dispatch failure much later.
454
+ const exchanged = await this.refreshWithUser(parsed.refresh ?? parsed.access, parsed.userId ?? "");
455
+ return {
456
+ access: exchanged.access,
457
+ refresh: exchanged.refresh ?? (parsed.refresh ?? parsed.access),
458
+ expiresAt: exchanged.expiresAt,
459
+ ...(parsed.email === undefined ? {} : { accountLabel: parsed.email }),
460
+ ...(parsed.userId === undefined
461
+ ? {}
462
+ : { auth_state: { userId: parsed.userId } }),
463
+ };
464
+ }
465
+
466
+ /**
467
+ * Exchanges a refresh token for a fresh access token.
468
+ *
469
+ * `userId` is part of the payload upstream: the exchange is addressed per
470
+ * account, not per client.
471
+ */
472
+ async refreshWithUser(
473
+ refreshToken: string,
474
+ userId: string,
475
+ signal?: AbortSignal,
476
+ ): Promise<OAuthTokenRefreshResult> {
477
+ const hosts = traeAccountCandidates(this.realm);
478
+ let lastError: unknown;
479
+ for (const host of hosts) {
480
+ const url = `${host}${TRAE_EXCHANGE_TOKEN_PATH}`;
481
+ try {
482
+ return await this.#exchange(url, refreshToken, userId, signal);
483
+ } catch (err: unknown) {
484
+ lastError = err;
485
+ // A business error (an invalidated refresh token, say) is not a host
486
+ // problem: replaying it on the fallback host just burns the token
487
+ // rotation again. Only a transport failure moves on.
488
+ if (isBusinessError(err)) throw err;
489
+ }
490
+ }
491
+ throw lastError instanceof Error
492
+ ? lastError
493
+ : new Error("Trae token exchange failed: no host answered");
494
+ }
495
+
496
+ /**
497
+ * The {@link OAuthTokenRefresher} entry point.
498
+ *
499
+ * The user id the exchange needs is the account's own id, which the refresh
500
+ * context already carries; requiring the caller to pass it separately would
501
+ * invite a mismatch between the account being refreshed and the one the
502
+ * exchange is addressed to.
503
+ */
504
+ override async refresh(
505
+ refreshToken: string,
506
+ signal?: AbortSignal,
507
+ context?: { readonly account_id?: string },
508
+ ): Promise<OAuthTokenRefreshResult> {
509
+ return this.refreshWithUser(refreshToken, context?.account_id ?? "", signal);
510
+ }
511
+
512
+ async #exchange(
513
+ url: string,
514
+ refreshToken: string,
515
+ userId: string,
516
+ signal?: AbortSignal,
517
+ ): Promise<OAuthTokenRefreshResult> {
518
+ const response = await this.fetchFn(url, {
519
+ method: "POST",
520
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
521
+ body: JSON.stringify({
522
+ ClientID: TRAE_CLIENT_ID,
523
+ RefreshToken: refreshToken,
524
+ ClientSecret: TRAE_CLIENT_SECRET,
525
+ UserID: userId,
526
+ }),
527
+ ...(signal ? { signal } : {}),
528
+ });
529
+ if (!response.ok) {
530
+ throw new Error(`Trae token exchange failed with HTTP ${response.status}`);
531
+ }
532
+ const parsed = (await response.json()) as TraeExchangeResponse;
533
+ const result = parsed.Result ?? parsed;
534
+ const access = result.Token;
535
+ if (typeof access !== "string" || access.length === 0) {
536
+ // Upstream reports the reason beside the missing token, so surface it
537
+ // rather than a generic failure — an invalidated refresh token is an
538
+ // operator-actionable message.
539
+ const code = parsed.Code ?? "refresh_failed";
540
+ const detail = typeof parsed.Message === "string" ? `: ${parsed.Message}` : "";
541
+ throw new Error(`Trae token exchange rejected (${code})${detail}`);
542
+ }
543
+ const refresh = result.RefreshToken;
544
+ const expiryMs = result.TokenExpireAt;
545
+ return {
546
+ access,
547
+ // Upstream rotates the refresh token, so the new one must be stored or
548
+ // the account cannot be refreshed a second time.
549
+ ...(typeof refresh === "string" && refresh.length > 0 ? { refresh } : {}),
550
+ expiresAt:
551
+ typeof expiryMs === "number" && Number.isFinite(expiryMs)
552
+ ? new Date(expiryMs)
553
+ : new Date(Date.now() + 3600_000),
554
+ ...(typeof result.UserJwt === "string" && result.UserJwt.length > 0
555
+ ? { auth_state: { userJwt: result.UserJwt } }
556
+ : {}),
557
+ };
558
+ }
559
+ }
560
+
561
+ /**
562
+ * True when an error came from upstream rather than the transport.
563
+ *
564
+ * `#exchange` throws a plain `Error` for both, so the distinction has to be
565
+ * made on the message; a business rejection must not be retried on another
566
+ * host, because each attempt rotates the refresh token again.
567
+ */
568
+ function isBusinessError(err: unknown): boolean {
569
+ return err instanceof Error && (err.message.includes("rejected") || err.message.includes("HTTP 4"));
570
+ }
571
+
572
+ /** Assembles an import result for an already-complete export. */
573
+ function parsedResult(
574
+ parsed: TraeExport,
575
+ access: string,
576
+ refresh: string,
577
+ isStatic: boolean,
578
+ ): OAuthExchangeResult {
579
+ return {
580
+ access,
581
+ refresh,
582
+ expiresAt:
583
+ parsed.expiresAtMs === undefined ? new Date(Date.now() + 3600_000) : new Date(parsed.expiresAtMs),
584
+ ...(isStatic ? { auth_state: { staticToken: true } } : {}),
585
+ ...(parsed.email === undefined ? {} : { accountLabel: parsed.email }),
586
+ };
587
+ }
588
+
589
+ /** The parts of a Trae export this flow needs. */
590
+ interface TraeExport {
591
+ readonly access: string;
592
+ readonly refresh?: string;
593
+ readonly expiresAtMs?: number;
594
+ readonly email?: string;
595
+ readonly userId?: string;
596
+ }
597
+
598
+ /**
599
+ * Reads a pasted credential as a Trae export.
600
+ *
601
+ * Accepts the exporter's own shape (`accessToken`/`refreshToken`) and the bare
602
+ * access token an operator may have copied out of it. Returns `undefined` when
603
+ * neither reading yields a token, so the caller reports a usable message rather
604
+ * than storing an empty credential.
605
+ */
606
+ function parseExport(credential: string): TraeExport | undefined {
607
+ const trimmed = credential.trim();
608
+ if (trimmed.length === 0) return undefined;
609
+ if (!trimmed.startsWith("{")) return { access: trimmed };
610
+ let parsed: Record<string, unknown>;
611
+ try {
612
+ parsed = JSON.parse(trimmed) as Record<string, unknown>;
613
+ } catch {
614
+ return undefined;
615
+ }
616
+ const access = firstString(parsed, ["accessToken", "access_token", "token"]);
617
+ if (access === undefined) return undefined;
618
+ const refresh = firstString(parsed, ["refreshToken", "refresh_token", "refresh"]);
619
+ const expiresAtMs = firstNumber(parsed, ["expiredAt", "expiresAt", "TokenExpireAt", "tokenExpireAt"]);
620
+ const email = firstString(parsed, ["email", "userEmail", "accountLabel"]);
621
+ const userId = firstString(parsed, ["userId", "user_id", "uid", "UserID"]);
622
+ return {
623
+ access,
624
+ ...(refresh === undefined ? {} : { refresh }),
625
+ ...(expiresAtMs === undefined ? {} : { expiresAtMs }),
626
+ ...(email === undefined ? {} : { email }),
627
+ ...(userId === undefined ? {} : { userId }),
628
+ };
629
+ }
630
+
631
+ function firstString(record: Record<string, unknown>, keys: readonly string[]): string | undefined {
632
+ for (const key of keys) {
633
+ const value = record[key];
634
+ if (typeof value === "string" && value.trim().length > 0) return value.trim();
635
+ }
636
+ return undefined;
637
+ }
638
+
639
+ function firstNumber(record: Record<string, unknown>, keys: readonly string[]): number | undefined {
640
+ for (const key of keys) {
641
+ const value = record[key];
642
+ if (typeof value === "number" && Number.isFinite(value)) return value;
643
+ if (typeof value === "string") {
644
+ // An ISO expiry is what a re-export carries; epoch ms is what the
645
+ // exchange returns, and both appear in the wild.
646
+ const asMs = Date.parse(value);
647
+ if (Number.isFinite(asMs)) return asMs;
648
+ }
649
+ }
650
+ return undefined;
651
+ }
652
+
653
+ /** What a Trae callback can carry. Exactly one of the two is present. */
654
+ interface TraeCallbackPayload {
655
+ /** Present on the refresh-token flow. */
656
+ readonly refreshToken?: string;
657
+ /** Present on the PKCE flow: the code the login page minted. */
658
+ readonly authCode?: string;
659
+ /** Host the exchange must go to, echoed back by the page. */
660
+ readonly host?: string;
661
+ /**
662
+ * Provisioned region (`sg`, `us`, …).
663
+ *
664
+ * The token only authenticates against its own region's core host, so this is
665
+ * persisted with the account rather than inferred from the realm.
666
+ */
667
+ readonly region?: string;
668
+ }
669
+
670
+ /**
671
+ * Reads the Trae callback the operator pastes back.
672
+ *
673
+ * Upstream has two shapes and the page picks by how the login started:
674
+ *
675
+ * - `?isRedirect=true&refreshToken=…&refreshExpireAt=…` — the refresh-token
676
+ * flow, which is what the donor implements.
677
+ * - `?isRedirect=true&scope=trae&authCodeInfo={"AuthCode":…}&host=…` — the PKCE
678
+ * flow, which is what the live page actually produces here. The code inside
679
+ * `authCodeInfo` is what `public/oauth/ExchangeToken` redeems against the
680
+ * verifier, so it is not interchangeable with a refresh token.
681
+ *
682
+ * The pasted value may be the whole URL, just its query, or a fragment, so all
683
+ * three are accepted.
684
+ */
685
+ function parseTraeCallback(raw: string): TraeCallbackPayload {
686
+ const text = raw.trim();
687
+ let query = text;
688
+ if (text.includes("?")) query = text.slice(text.indexOf("?") + 1);
689
+ if (text.startsWith("#")) query = text.slice(1);
690
+ const params = Object.fromEntries(new URLSearchParams(query));
691
+ const pick = (keys: readonly string[]): string | undefined => {
692
+ for (const key of keys) {
693
+ const value = params[key];
694
+ if (typeof value === "string" && value.trim().length > 0) return value.trim();
695
+ }
696
+ return undefined;
697
+ };
698
+ const error = pick(["error", "error_code", "errorCode"]);
699
+ if (error !== undefined) {
700
+ const detail = pick(["error_description", "error_desc", "message"]);
701
+ throw new Error(detail === undefined ? `Trae auth failed: ${error}` : `Trae auth failed: ${error} (${detail})`);
702
+ }
703
+ const host = pick(["loginHost", "login_host", "LoginHost", "host", "consoleHost"]);
704
+ const region = pick(["userRegion", "user_region", "UserRegion"]) ?? regionFromUserInfo(params["userInfo"]);
705
+ const extra = {
706
+ ...(host === undefined ? {} : { host }),
707
+ ...(region === undefined ? {} : { region }),
708
+ };
709
+ const refreshToken = pick(["refreshToken", "refresh_token", "RefreshToken"]);
710
+ if (refreshToken !== undefined) return { refreshToken, ...extra };
711
+ // The PKCE flow nests the code in `authCodeInfo`, a JSON string.
712
+ const authCode = pick(["AuthCode"]) ?? authCodeFromInfo(params["authCodeInfo"]);
713
+ if (authCode !== undefined) return { authCode, ...extra };
714
+ throw new Error(
715
+ "Trae callback carries neither a refreshToken nor an AuthCode — paste the full callback URL",
716
+ );
717
+ }
718
+
719
+ /** First non-empty string among the keys, for upstream's varied spellings. */
720
+ function firstStringValue(record: Record<string, unknown>, keys: readonly string[]): string | undefined {
721
+ for (const key of keys) {
722
+ const value = record[key];
723
+ if (typeof value === "string" && value.trim().length > 0) return value.trim();
724
+ }
725
+ return undefined;
726
+ }
727
+
728
+ /** Client bound to the intl deployment. */
729
+ export const traeOAuthClient = new TraeOAuthClient();
730
+
731
+ /** Client bound to the China deployment. */
732
+ export const traeCnOAuthClient = new TraeOAuthClient(globalThis.fetch, "cn");
733
+
734
+ /**
735
+ * Swaps the gateway redirect's path for the one Trae accepts.
736
+ *
737
+ * Parsed rather than string-replaced so a custom redirect (a different port,
738
+ * a host) keeps everything except the path.
739
+ */
740
+ function withTraeCallbackPath(redirectUri: string): string {
741
+ try {
742
+ const url = new URL(redirectUri);
743
+ url.pathname = TRAE_CALLBACK_PATH;
744
+ return url.toString();
745
+ } catch {
746
+ return `http://127.0.0.1:59653${TRAE_CALLBACK_PATH}`;
747
+ }
748
+ }
749
+
750
+ /** Extracts `AuthCode` from the `authCodeInfo` JSON string the page sends. */
751
+ function authCodeFromInfo(raw: string | undefined): string | undefined {
752
+ if (raw === undefined || raw.trim().length === 0) return undefined;
753
+ try {
754
+ const parsed: unknown = JSON.parse(raw);
755
+ if (typeof parsed !== "object" || parsed === null) return undefined;
756
+ return firstString(parsed as Record<string, unknown>, ["AuthCode", "authCode"]);
757
+ } catch {
758
+ return undefined;
759
+ }
760
+ }
761
+
762
+ /** Reads the provisioned region out of the `userInfo` JSON the page sends. */
763
+ function regionFromUserInfo(raw: string | undefined): string | undefined {
764
+ if (raw === undefined || raw.trim().length === 0) return undefined;
765
+ try {
766
+ const parsed: unknown = JSON.parse(raw);
767
+ if (typeof parsed !== "object" || parsed === null) return undefined;
768
+ const record = parsed as Record<string, unknown>;
769
+ return firstString(record, ["AIRegion", "aiRegion", "Region"]);
770
+ } catch {
771
+ return undefined;
772
+ }
773
+ }