cartethyia 2.4.6 → 2.5.1
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.
- package/README.md +45 -13
- package/dist/dashboard/assets/BannedPage-ChqnTRpZ.js +1 -0
- package/dist/dashboard/assets/BannedPage-ChqnTRpZ.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolDetailPage-Bcy150Jo.js → CliToolDetailPage-Bb9CNuoh.js} +1 -1
- package/dist/dashboard/assets/CliToolDetailPage-Bb9CNuoh.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolsPage-DyNXefph.js → CliToolsPage-BXUaRCQQ.js} +1 -1
- package/dist/dashboard/assets/CliToolsPage-BXUaRCQQ.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-CisNZOUt.js +1 -0
- package/dist/dashboard/assets/CombosPage-CisNZOUt.js.gz +0 -0
- package/dist/dashboard/assets/{ConsoleLogPage-CATpAEOz.js → ConsoleLogPage-BOpI_LOH.js} +1 -1
- package/dist/dashboard/assets/ConsoleLogPage-BOpI_LOH.js.gz +0 -0
- package/dist/dashboard/assets/{CustomizationPage-D6jEU7nL.js → CustomizationPage-DAkdvJ4k.js} +1 -1
- package/dist/dashboard/assets/CustomizationPage-DAkdvJ4k.js.gz +0 -0
- package/dist/dashboard/assets/{HealthEventsModal-CZkpn0a7.js → HealthEventsModal-DeT-VXHd.js} +1 -1
- package/dist/dashboard/assets/HealthEventsModal-DeT-VXHd.js.gz +0 -0
- package/dist/dashboard/assets/{LoginPage-BJlMVN4U.js → LoginPage-CytxSeGP.js} +1 -1
- package/dist/dashboard/assets/LoginPage-CytxSeGP.js.gz +0 -0
- package/dist/dashboard/assets/{ModelPicker-Br3CXEau.js → ModelPicker-oQ-SDXp8.js} +1 -1
- package/dist/dashboard/assets/ModelPicker-oQ-SDXp8.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-CU7S1wJs.js +1 -0
- package/dist/dashboard/assets/OverviewPage-CU7S1wJs.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-DLm2baot.js +3 -0
- package/dist/dashboard/assets/ProviderDetailPage-DLm2baot.js.gz +0 -0
- package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js +1 -0
- package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-DOXfuzfL.js +1 -0
- package/dist/dashboard/assets/ProvidersPage-DOXfuzfL.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-wvk0ciQN.js +7 -0
- package/dist/dashboard/assets/ProxyPage-wvk0ciQN.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-Cn_fcUHE.js +1 -0
- package/dist/dashboard/assets/QuotaPage-Cn_fcUHE.js.gz +0 -0
- package/dist/dashboard/assets/{SettingsPage-BHW4Zt06.js → SettingsPage-8-eVadhc.js} +1 -1
- package/dist/dashboard/assets/SettingsPage-8-eVadhc.js.gz +0 -0
- package/dist/dashboard/assets/{SetupPage-VOYC_lIa.js → SetupPage-uEIXPyGO.js} +1 -1
- package/dist/dashboard/assets/SetupPage-uEIXPyGO.js.gz +0 -0
- package/dist/dashboard/assets/SortableList-DCY0TPTZ.js +1 -0
- package/dist/dashboard/assets/SortableList-DCY0TPTZ.js.gz +0 -0
- package/dist/dashboard/assets/{StudioPage-BNIDLiU6.js → StudioPage-DqK5E26W.js} +4 -4
- package/dist/dashboard/assets/StudioPage-DqK5E26W.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-38JfiZ0H.js +56 -0
- package/dist/dashboard/assets/UsagePage-38JfiZ0H.js.gz +0 -0
- package/dist/dashboard/assets/{api-keys-BETfaMYZ.js → api-keys-BmDbsior.js} +1 -1
- package/dist/dashboard/assets/api-keys-BmDbsior.js.gz +0 -0
- package/dist/dashboard/assets/app-BbpSarVV.css +2 -0
- package/dist/dashboard/assets/app-BbpSarVV.css.gz +0 -0
- package/dist/dashboard/assets/assert-BHfaei-X.js +1 -0
- package/dist/dashboard/assets/assert-BHfaei-X.js.gz +0 -0
- package/dist/dashboard/assets/{badge-BTyGk_DL.js → badge-DpXT4Ixt.js} +1 -1
- package/dist/dashboard/assets/badge-DpXT4Ixt.js.gz +0 -0
- package/dist/dashboard/assets/{banner-bujTWYM4.js → banner-CSAAWLJv.js} +1 -1
- package/dist/dashboard/assets/banner-CSAAWLJv.js.gz +0 -0
- package/dist/dashboard/assets/{card-B0Bnx5CA.js → card-DEhRXXw4.js} +1 -1
- package/dist/dashboard/assets/card-DEhRXXw4.js.gz +0 -0
- package/dist/dashboard/assets/{cli-tools-DE2ug812.js → cli-tools-B0wBq-RL.js} +1 -1
- package/dist/dashboard/assets/cli-tools-B0wBq-RL.js.gz +0 -0
- package/dist/dashboard/assets/{clipboard-button-R_rArH8S.js → clipboard-button-DOo6EU8O.js} +1 -1
- package/dist/dashboard/assets/clipboard-button-DOo6EU8O.js.gz +0 -0
- package/dist/dashboard/assets/{dialog-BWIP_EvW.js → dialog-BvQeBMfP.js} +1 -1
- package/dist/dashboard/assets/dialog-BvQeBMfP.js.gz +0 -0
- package/dist/dashboard/assets/{drawer-BKcgVugF.js → drawer-CMwPXwQB.js} +1 -1
- package/dist/dashboard/assets/drawer-CMwPXwQB.js.gz +0 -0
- package/dist/dashboard/assets/entry--yj84zk3.js +4 -0
- package/dist/dashboard/assets/entry--yj84zk3.js.gz +0 -0
- package/dist/dashboard/assets/{entry-CcWkGR4a.js → entry-D4O2229X.js} +1 -1
- package/dist/dashboard/assets/{entry-CcWkGR4a.js.gz → entry-D4O2229X.js.gz} +0 -0
- package/dist/dashboard/assets/{entry-kTiTFrvh.js → entry-DKvr5Q9K.js} +1 -1
- package/dist/dashboard/assets/entry-DKvr5Q9K.js.gz +0 -0
- package/dist/dashboard/assets/{github-badge-Booc5mNe.js → github-badge-zliLvBwr.js} +1 -1
- package/dist/dashboard/assets/github-badge-zliLvBwr.js.gz +0 -0
- package/dist/dashboard/assets/{index-D70pChfn.js → index-CcD1eSJ6.js} +2 -2
- package/dist/dashboard/assets/index-CcD1eSJ6.js.gz +0 -0
- package/dist/dashboard/assets/{inline-D1TCqM2D.js → inline-D1IlZZ8H.js} +1 -1
- package/dist/dashboard/assets/inline-D1IlZZ8H.js.gz +0 -0
- package/dist/dashboard/assets/{input-B9oyU93y.js → input-BpVZdrbc.js} +1 -1
- package/dist/dashboard/assets/input-BpVZdrbc.js.gz +0 -0
- package/dist/dashboard/assets/{layout-BArzXirQ.js → layout-BioLW7gN.js} +1 -1
- package/dist/dashboard/assets/layout-BioLW7gN.js.gz +0 -0
- package/dist/dashboard/assets/{list-row-BVZzaoHF.js → list-row-CwVcGAIc.js} +1 -1
- package/dist/dashboard/assets/list-row-CwVcGAIc.js.gz +0 -0
- package/dist/dashboard/assets/{live-EmuIayqp.js → live-yfEJFF9T.js} +1 -1
- package/dist/dashboard/assets/live-yfEJFF9T.js.gz +0 -0
- package/dist/dashboard/assets/{model-bans-CSPQ9xB0.js → model-bans-CT-TxSNX.js} +1 -1
- package/dist/dashboard/assets/model-bans-CT-TxSNX.js.gz +0 -0
- package/dist/dashboard/assets/providers/cursor.webp +0 -0
- package/dist/dashboard/assets/providers/trae.webp +0 -0
- package/dist/dashboard/assets/{routing-BExgxxBU.js → routing-Cs6jl49L.js} +1 -1
- package/dist/dashboard/assets/routing-Cs6jl49L.js.gz +0 -0
- package/dist/dashboard/assets/select-XPJ-dIw3.js +41 -0
- package/dist/dashboard/assets/select-XPJ-dIw3.js.gz +0 -0
- package/dist/dashboard/assets/settings-CfcTcOyU.js +1 -0
- package/dist/dashboard/assets/settings-CfcTcOyU.js.gz +0 -0
- package/dist/dashboard/assets/{stack-Ca5HxI5i.js → stack-C9_uUjaA.js} +1 -1
- package/dist/dashboard/assets/stack-C9_uUjaA.js.gz +0 -0
- package/dist/dashboard/assets/{state-CzUPfLXo.js → state-DA0B95UQ.js} +1 -1
- package/dist/dashboard/assets/state-DA0B95UQ.js.gz +0 -0
- package/dist/dashboard/assets/{switch-DOUIky_i.js → switch-Cittxy-x.js} +1 -1
- package/dist/dashboard/assets/switch-Cittxy-x.js.gz +0 -0
- package/dist/dashboard/assets/{toast-C76WrMhc.js → toast-CNt_jzO4.js} +1 -1
- package/dist/dashboard/assets/toast-CNt_jzO4.js.gz +0 -0
- package/dist/dashboard/assets/{useMutation-D87FZwH0.js → useMutation-BQKMcJ1H.js} +1 -1
- package/dist/dashboard/assets/useMutation-BQKMcJ1H.js.gz +0 -0
- package/dist/dashboard/index.html +1 -1
- package/dist/dashboard/index.html.gz +0 -0
- package/dist/dashboard/precompressed-assets.json +51 -52
- package/migrations/full-postgres/0015_add_payload_stages.sql +18 -0
- package/migrations/full-postgres/0016_add_share_link_slug.sql +20 -0
- package/migrations/full-postgres/0017_add_models_pro_only.sql +18 -0
- package/migrations/full-postgres/0018_add_accounts_quota_plan.sql +18 -0
- package/migrations/full-postgres/0019_add_models_free_tier.sql +15 -0
- package/migrations/lite-sqlite3/0015_add_payload_stages.sql +18 -0
- package/migrations/lite-sqlite3/0016_add_share_link_slug.sql +20 -0
- package/migrations/lite-sqlite3/0017_add_models_pro_only.sql +18 -0
- package/migrations/lite-sqlite3/0018_add_accounts_quota_plan.sql +18 -0
- package/migrations/lite-sqlite3/0019_add_models_free_tier.sql +15 -0
- package/package.json +3 -2
- package/scripts/cli/command/console-menu.ts +30 -11
- package/scripts/cli/command/start.ts +77 -13
- package/scripts/cli/command/tray.ts +47 -8
- package/scripts/cli/command/update.ts +1 -1
- package/scripts/cli/gateway/instance-lock.ts +105 -0
- package/scripts/cli/gateway/spawn.ts +173 -101
- package/scripts/cli/ui/background.ts +110 -49
- package/src/boot/app.ts +20 -6
- package/src/boot/dependencies.ts +19 -0
- package/src/boot/elysia-typebox-bridge.ts +37 -0
- package/src/boot/lifecycle.ts +2 -0
- package/src/config/cooldowns.ts +138 -138
- package/src/config/providers.ts +5 -5
- package/src/config/runtime.ts +19 -3
- package/src/config/spec.ts +9 -0
- package/src/console/api-keys/contracts.ts +414 -391
- package/src/console/api-keys/routes.ts +880 -870
- package/src/console/auth/account-reset-store.ts +14 -0
- package/src/console/auth/account-reset.ts +3 -2
- package/src/console/auth/setup-service.ts +6 -3
- package/src/console/domain-registration.ts +9 -2
- package/src/console/observability/contracts.ts +475 -455
- package/src/console/observability/store.ts +1015 -952
- package/src/console/providers/catalog/contracts.ts +31 -0
- package/src/console/providers/catalog/provider-operations.ts +8 -0
- package/src/console/providers/catalog/store.ts +1532 -1525
- package/src/console/providers/oauth/callback-listener.ts +18 -3
- package/src/console/providers/oauth/routes.ts +38 -15
- package/src/console/quota/refresh.ts +19 -0
- package/src/console/updates/routes.ts +162 -4
- package/src/main.ts +3 -0
- package/src/network/http2-bidi.ts +85 -0
- package/src/network/outbound-fetch.ts +1 -26
- package/src/network/pool/agent.ts +478 -460
- package/src/network/pool/resolver.ts +1104 -544
- package/src/network/pool-health-machine.ts +12 -5
- package/src/observability/payload-capture.ts +344 -324
- package/src/persistence/db-pg.ts +9 -3
- package/src/persistence/db-sqlite.ts +497 -495
- package/src/persistence/health-event-flush.ts +137 -0
- package/src/persistence/migrate.ts +181 -129
- package/src/persistence/schema.ts +1360 -1295
- package/src/persistence/settings-revision.ts +25 -0
- package/src/persistence/shared-cache.ts +217 -0
- package/src/persistence/stores/share-store.ts +556 -528
- package/src/persistence/usage-flush.ts +61 -1
- package/src/protocol/canonical-model.ts +23 -1
- package/src/protocol/response/chat.ts +143 -10
- package/src/protocol/response/omp-tool-call-markup.ts +221 -0
- package/src/providers/default-registry.ts +173 -1
- package/src/providers/discovery/openai-model-discovery.ts +39 -3
- package/src/providers/discovery/openrouter-free-roster.ts +98 -0
- package/src/providers/discovery/probing-service.ts +1022 -1001
- package/src/providers/integrations/buddy/buddy-catalog-shared.ts +39 -0
- package/src/providers/integrations/buddy/buddy-chat-shared.ts +913 -914
- package/src/providers/integrations/buddy/buddy-conversation-headers.ts +18 -5
- package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +4 -0
- package/src/providers/integrations/buddy/codebuddy/provider.ts +180 -175
- package/src/providers/integrations/buddy/codebuddy/shared.ts +15 -6
- package/src/providers/integrations/buddy/workbuddy/provider.ts +206 -204
- package/src/providers/integrations/buddy/workbuddy/shared.ts +3 -3
- package/src/providers/integrations/claude/claude.ts +606 -582
- package/src/providers/integrations/cline/cline-quota.ts +18 -16
- package/src/providers/integrations/cline/cline.ts +539 -468
- package/src/providers/integrations/commandcode/commandcode.ts +675 -541
- package/src/providers/integrations/commandcode/discovery.ts +116 -0
- package/src/providers/integrations/commandcode/quota.ts +160 -0
- package/src/providers/integrations/cursor/cursor-auth.ts +418 -0
- package/src/providers/integrations/cursor/cursor-catalog.ts +368 -0
- package/src/providers/integrations/cursor/cursor-http2.ts +300 -0
- package/src/providers/integrations/cursor/cursor-identity.ts +92 -0
- package/src/providers/integrations/cursor/cursor-request.ts +471 -0
- package/src/providers/integrations/cursor/cursor-session-manager.ts +281 -0
- package/src/providers/integrations/cursor/cursor-shared.ts +108 -0
- package/src/providers/integrations/cursor/cursor.ts +960 -0
- package/src/providers/integrations/cursor/protocol/framing.ts +129 -0
- package/src/providers/integrations/cursor/protocol/messages.ts +2355 -0
- package/src/providers/integrations/cursor/protocol/struct.ts +305 -0
- package/src/providers/integrations/github/github.ts +4 -4
- package/src/providers/integrations/hermes.ts +79 -8
- package/src/providers/integrations/kilo/kilo-discovery.ts +10 -1
- package/src/providers/integrations/kilo/kilo.ts +153 -146
- package/src/providers/integrations/opencode/provider.ts +532 -538
- package/src/providers/integrations/openrouter/provider.ts +139 -281
- package/src/providers/integrations/qoder/qoder-catalog.ts +450 -0
- package/src/providers/integrations/qoder/qoder-oauth.ts +310 -257
- package/src/providers/integrations/qoder/qoder-realm.ts +106 -0
- package/src/providers/integrations/qoder/qoder-virtual.ts +77 -0
- package/src/providers/integrations/qoder/qoder.ts +865 -730
- package/src/providers/integrations/trae/trae-catalog.ts +190 -0
- package/src/providers/integrations/trae/trae-headers.ts +149 -0
- package/src/providers/integrations/trae/trae-oauth.ts +773 -0
- package/src/providers/integrations/trae/trae-protocol.ts +400 -0
- package/src/providers/integrations/trae/trae-realm.ts +220 -0
- package/src/providers/integrations/trae/trae.ts +458 -0
- package/src/providers/operations/account-health-service.ts +107 -10
- package/src/providers/operations/client-versions.ts +133 -6
- package/src/providers/operations/provider-catalog-seeder.ts +95 -86
- package/src/providers/operations/provider-credential-service.ts +46 -7
- package/src/providers/provider-contracts.ts +76 -0
- package/src/providers/provider-metadata.ts +78 -6
- package/src/providers/provider-model-definition.ts +373 -349
- package/src/security/api-key-auth.ts +126 -8
- package/src/transport/contracts.ts +3 -0
- package/src/transport/dispatch/attempt-finalize.ts +32 -1
- package/src/transport/dispatch/attempt-loop.ts +530 -515
- package/src/transport/dispatch/generation.ts +88 -21
- package/src/transport/dispatch/leases.ts +99 -2
- package/src/transport/dispatch/proxy-request.ts +60 -3
- package/src/transport/dispatch/streaming/finalize.ts +16 -1
- package/src/transport/dispatch/streaming/stream.ts +22 -2
- package/src/transport/dispatch/upstream.ts +19 -1
- package/src/transport/guards/auth-guard.ts +26 -1
- package/src/transport/guards/body-policy.ts +5 -4
- package/src/transport/request/preparer.ts +103 -0
- package/src/transport/request/state.ts +14 -0
- package/src/transport/routing/plan-gate.ts +45 -0
- package/src/transport/routing/route-catalog.ts +799 -777
- package/src/transport/routing/route-model.ts +52 -1
- package/src/transport/routing/router.ts +51 -0
- package/dist/dashboard/assets/BannedPage-QhvbJB9o.js +0 -1
- package/dist/dashboard/assets/BannedPage-QhvbJB9o.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-Bcy150Jo.js.gz +0 -0
- package/dist/dashboard/assets/CliToolsPage-DyNXefph.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-Cak0ljnO.js +0 -1
- package/dist/dashboard/assets/CombosPage-Cak0ljnO.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-CATpAEOz.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-D6jEU7nL.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-CZkpn0a7.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-BJlMVN4U.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-Br3CXEau.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-CQxXbo2g.js +0 -1
- package/dist/dashboard/assets/OverviewPage-CQxXbo2g.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage--0RBNh99.js +0 -3
- package/dist/dashboard/assets/ProviderDetailPage--0RBNh99.js.gz +0 -0
- package/dist/dashboard/assets/ProviderIcon-paOCq8I0.js +0 -1
- package/dist/dashboard/assets/ProviderIcon-paOCq8I0.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-1M-C3mbu.js +0 -1
- package/dist/dashboard/assets/ProvidersPage-1M-C3mbu.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-CXiumA53.js +0 -7
- package/dist/dashboard/assets/ProxyPage-CXiumA53.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-BGDIfIYP.js +0 -1
- package/dist/dashboard/assets/QuotaPage-BGDIfIYP.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-BHW4Zt06.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-VOYC_lIa.js.gz +0 -0
- package/dist/dashboard/assets/SortableList-DpOTnGuH.js +0 -1
- package/dist/dashboard/assets/SortableList-DpOTnGuH.js.gz +0 -0
- package/dist/dashboard/assets/StudioPage-BNIDLiU6.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-BAFaSDQV.js +0 -56
- package/dist/dashboard/assets/UsagePage-BAFaSDQV.js.gz +0 -0
- package/dist/dashboard/assets/api-keys-BETfaMYZ.js.gz +0 -0
- package/dist/dashboard/assets/app-BLCvGdzb.css +0 -2
- package/dist/dashboard/assets/app-BLCvGdzb.css.gz +0 -0
- package/dist/dashboard/assets/assert-7J6_LVD3.js +0 -1
- package/dist/dashboard/assets/assert-7J6_LVD3.js.gz +0 -0
- package/dist/dashboard/assets/badge-BTyGk_DL.js.gz +0 -0
- package/dist/dashboard/assets/banner-bujTWYM4.js.gz +0 -0
- package/dist/dashboard/assets/card-B0Bnx5CA.js.gz +0 -0
- package/dist/dashboard/assets/cli-tools-DE2ug812.js.gz +0 -0
- package/dist/dashboard/assets/clipboard-button-R_rArH8S.js.gz +0 -0
- package/dist/dashboard/assets/dialog-BWIP_EvW.js.gz +0 -0
- package/dist/dashboard/assets/drawer-BKcgVugF.js.gz +0 -0
- package/dist/dashboard/assets/entry-7dpgvpJ3.js +0 -4
- package/dist/dashboard/assets/entry-7dpgvpJ3.js.gz +0 -0
- package/dist/dashboard/assets/entry-kTiTFrvh.js.gz +0 -0
- package/dist/dashboard/assets/github-badge-Booc5mNe.js.gz +0 -0
- package/dist/dashboard/assets/index-D70pChfn.js.gz +0 -0
- package/dist/dashboard/assets/inline-D1TCqM2D.js.gz +0 -0
- package/dist/dashboard/assets/input-B9oyU93y.js.gz +0 -0
- package/dist/dashboard/assets/layout-BArzXirQ.js.gz +0 -0
- package/dist/dashboard/assets/list-row-BVZzaoHF.js.gz +0 -0
- package/dist/dashboard/assets/live-EmuIayqp.js.gz +0 -0
- package/dist/dashboard/assets/model-bans-CSPQ9xB0.js.gz +0 -0
- package/dist/dashboard/assets/routing-BExgxxBU.js.gz +0 -0
- package/dist/dashboard/assets/select-C9mMoEpJ.js +0 -41
- package/dist/dashboard/assets/select-C9mMoEpJ.js.gz +0 -0
- package/dist/dashboard/assets/settings-C4_Gn_wq.js +0 -1
- package/dist/dashboard/assets/settings-C4_Gn_wq.js.gz +0 -0
- package/dist/dashboard/assets/sliders-horizontal-ClG7dBeJ.js +0 -1
- package/dist/dashboard/assets/sliders-horizontal-ClG7dBeJ.js.gz +0 -0
- package/dist/dashboard/assets/stack-Ca5HxI5i.js.gz +0 -0
- package/dist/dashboard/assets/state-CzUPfLXo.js.gz +0 -0
- package/dist/dashboard/assets/switch-DOUIky_i.js.gz +0 -0
- package/dist/dashboard/assets/toast-C76WrMhc.js.gz +0 -0
- package/dist/dashboard/assets/useMutation-D87FZwH0.js.gz +0 -0
- package/src/network/tls/bun-client-hello.ts +0 -112
- /package/dist/dashboard/assets/{app-Cjq7nrwu.js → app-C-Y4oZT0.js} +0 -0
- /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
|
+
}
|