cartethyia 2.7.9 → 2.8.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/.env.example +6 -5
- package/dist/dashboard/assets/{BannedPage-BxoG1MrL.js → BannedPage-Dq530awD.js} +1 -1
- package/dist/dashboard/assets/BannedPage-Dq530awD.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolDetailPage-ixwgKk53.js → CliToolDetailPage-DZzaS-7S.js} +1 -1
- package/dist/dashboard/assets/CliToolDetailPage-DZzaS-7S.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolsPage-BmttNV3U.js → CliToolsPage-DaZZ3cqP.js} +1 -1
- package/dist/dashboard/assets/CliToolsPage-DaZZ3cqP.js.gz +0 -0
- package/dist/dashboard/assets/{CombosPage-BE3qwwfC.js → CombosPage-BlC_xdkX.js} +1 -1
- package/dist/dashboard/assets/CombosPage-BlC_xdkX.js.gz +0 -0
- package/dist/dashboard/assets/{ConsoleLogPage-CRZdmMIX.js → ConsoleLogPage-D-rqeVtG.js} +1 -1
- package/dist/dashboard/assets/ConsoleLogPage-D-rqeVtG.js.gz +0 -0
- package/dist/dashboard/assets/{CustomizationPage-Dv_FJ6Er.js → CustomizationPage-7xT5yg2E.js} +1 -1
- package/dist/dashboard/assets/CustomizationPage-7xT5yg2E.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-Clq8E9cW.js +1 -0
- package/dist/dashboard/assets/HealthEventsModal-Clq8E9cW.js.gz +0 -0
- package/dist/dashboard/assets/{InjectSystemPromptPanel-BOoO8rpz.js → InjectSystemPromptPanel-6Teoq9Se.js} +2 -2
- package/dist/dashboard/assets/InjectSystemPromptPanel-6Teoq9Se.js.gz +0 -0
- package/dist/dashboard/assets/{LoginPage-ChJuNNVN.js → LoginPage-CZuI0MAR.js} +1 -1
- package/dist/dashboard/assets/LoginPage-CZuI0MAR.js.gz +0 -0
- package/dist/dashboard/assets/{ModelPicker-BZAJtPg1.js → ModelPicker-C1hNTlkr.js} +1 -1
- package/dist/dashboard/assets/ModelPicker-C1hNTlkr.js.gz +0 -0
- package/dist/dashboard/assets/{OverviewPage-BE40s3u5.js → OverviewPage-0yfhSI1C.js} +1 -1
- package/dist/dashboard/assets/OverviewPage-0yfhSI1C.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-xqTxdQ4c.js +8 -0
- package/dist/dashboard/assets/ProviderDetailPage-xqTxdQ4c.js.gz +0 -0
- package/dist/dashboard/assets/{ProviderIcon-CQ6bPUf_.js → ProviderIcon-Ddhm1InL.js} +1 -1
- package/dist/dashboard/assets/ProviderIcon-Ddhm1InL.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-C3fYuu6i.js +1 -0
- package/dist/dashboard/assets/ProvidersPage-C3fYuu6i.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-CHtJtZ3u.js +9 -0
- package/dist/dashboard/assets/ProxyPage-CHtJtZ3u.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-D1RV_2BX.js +1 -0
- package/dist/dashboard/assets/QuotaPage-D1RV_2BX.js.gz +0 -0
- package/dist/dashboard/assets/{SettingsPage-C5HPWqEv.js → SettingsPage-BzZAvYSX.js} +1 -1
- package/dist/dashboard/assets/SettingsPage-BzZAvYSX.js.gz +0 -0
- package/dist/dashboard/assets/{SetupPage-CFNcATJJ.js → SetupPage--s-EfEIL.js} +1 -1
- package/dist/dashboard/assets/SetupPage--s-EfEIL.js.gz +0 -0
- package/dist/dashboard/assets/{StudioPage-YoD4tY5l.js → StudioPage-BjXFGSK4.js} +1 -1
- package/dist/dashboard/assets/StudioPage-BjXFGSK4.js.gz +0 -0
- package/dist/dashboard/assets/{UsagePage-DxjaAhb_.js → UsagePage-CP8F8BN7.js} +1 -1
- package/dist/dashboard/assets/UsagePage-CP8F8BN7.js.gz +0 -0
- package/dist/dashboard/assets/{api-keys-BTlkq_Uk.js → api-keys-2hc12Uz2.js} +1 -1
- package/dist/dashboard/assets/api-keys-2hc12Uz2.js.gz +0 -0
- package/dist/dashboard/assets/app-DGXbNfAR.css +2 -0
- package/dist/dashboard/assets/app-DGXbNfAR.css.gz +0 -0
- package/dist/dashboard/assets/assert-BMcj1xZE.js +1 -0
- package/dist/dashboard/assets/assert-BMcj1xZE.js.gz +0 -0
- package/dist/dashboard/assets/{badge-DdbT_7Am.js → badge-DkRUuVfz.js} +1 -1
- package/dist/dashboard/assets/badge-DkRUuVfz.js.gz +0 -0
- package/dist/dashboard/assets/{banner-CsGN-nbm.js → banner-CDjW841Q.js} +1 -1
- package/dist/dashboard/assets/banner-CDjW841Q.js.gz +0 -0
- package/dist/dashboard/assets/{card-mgZ_TLLZ.js → card-Bto5UlL9.js} +1 -1
- package/dist/dashboard/assets/card-Bto5UlL9.js.gz +0 -0
- package/dist/dashboard/assets/{cli-tools-A1-v8-03.js → cli-tools-DaH6g9hL.js} +1 -1
- package/dist/dashboard/assets/cli-tools-DaH6g9hL.js.gz +0 -0
- package/dist/dashboard/assets/{clipboard-button-CJ4Zty89.js → clipboard-button-BYKBCzjk.js} +1 -1
- package/dist/dashboard/assets/clipboard-button-BYKBCzjk.js.gz +0 -0
- package/dist/dashboard/assets/{dialog-B5L2jPQ_.js → dialog-DL0Zftwv.js} +1 -1
- package/dist/dashboard/assets/dialog-DL0Zftwv.js.gz +0 -0
- package/dist/dashboard/assets/{drawer-DkjUouAj.js → drawer-Dpx_UXaS.js} +1 -1
- package/dist/dashboard/assets/drawer-Dpx_UXaS.js.gz +0 -0
- package/dist/dashboard/assets/{entry-B-LVLV6o.js → entry-BV7-1NoM.js} +1 -1
- package/dist/dashboard/assets/entry-BV7-1NoM.js.gz +0 -0
- package/dist/dashboard/assets/{entry-CAhdYl3B.js → entry-CGvdNU6r.js} +1 -1
- package/dist/dashboard/assets/entry-CGvdNU6r.js.gz +0 -0
- package/dist/dashboard/assets/entry-DScKYvZj.js +4 -0
- package/dist/dashboard/assets/entry-DScKYvZj.js.gz +0 -0
- package/dist/dashboard/assets/{formatters-CrsdLVsn.js → formatters-BS3DZlZj.js} +1 -1
- package/dist/dashboard/assets/formatters-BS3DZlZj.js.gz +0 -0
- package/dist/dashboard/assets/{index-BU64Lqvp.js → index-SQnvvTWX.js} +2 -2
- package/dist/dashboard/assets/index-SQnvvTWX.js.gz +0 -0
- package/dist/dashboard/assets/{inline-B2aDLyyg.js → inline-Dl-cuLK0.js} +1 -1
- package/dist/dashboard/assets/inline-Dl-cuLK0.js.gz +0 -0
- package/dist/dashboard/assets/{input-BCVkdR9d.js → input-Bfx0EzRc.js} +1 -1
- package/dist/dashboard/assets/input-Bfx0EzRc.js.gz +0 -0
- package/dist/dashboard/assets/{layout-DNcAdMK-.js → layout-BxhbQ_JS.js} +1 -1
- package/dist/dashboard/assets/layout-BxhbQ_JS.js.gz +0 -0
- package/dist/dashboard/assets/{list-row-DgrIH0hw.js → list-row-D49Y4ptn.js} +1 -1
- package/dist/dashboard/assets/list-row-D49Y4ptn.js.gz +0 -0
- package/dist/dashboard/assets/{live-Cae01iu8.js → live-CbVTdX9V.js} +1 -1
- package/dist/dashboard/assets/live-CbVTdX9V.js.gz +0 -0
- package/dist/dashboard/assets/{network-CRib7H_2.js → network-JGvDveof.js} +1 -1
- package/dist/dashboard/assets/network-JGvDveof.js.gz +0 -0
- package/dist/dashboard/assets/{numbers-BKQE8Xpz.js → numbers-Dw6kiew-.js} +1 -1
- package/dist/dashboard/assets/numbers-Dw6kiew-.js.gz +0 -0
- package/dist/dashboard/assets/{pagination-ecPvG_8g.js → pagination-iUoI8GeE.js} +1 -1
- package/dist/dashboard/assets/pagination-iUoI8GeE.js.gz +0 -0
- package/dist/dashboard/assets/providers/sensenova-intl.png +0 -0
- package/dist/dashboard/assets/providers/sensenova.png +0 -0
- package/dist/dashboard/assets/providers/zro.svg +3 -0
- package/dist/dashboard/assets/providers/zro.svg.gz +0 -0
- package/dist/dashboard/assets/quota-DiPD6UKH.js +1 -0
- package/dist/dashboard/assets/quota-DiPD6UKH.js.gz +0 -0
- package/dist/dashboard/assets/{routing-Ckm1UXuC.js → routing-8jZr-3WE.js} +1 -1
- package/dist/dashboard/assets/routing-8jZr-3WE.js.gz +0 -0
- package/dist/dashboard/assets/{select-ChoFWyKz.js → select-oO1FxMVb.js} +2 -2
- package/dist/dashboard/assets/select-oO1FxMVb.js.gz +0 -0
- package/dist/dashboard/assets/{settings-CKjFPlKW.js → settings-CLr3kdIx.js} +1 -1
- package/dist/dashboard/assets/settings-CLr3kdIx.js.gz +0 -0
- package/dist/dashboard/assets/{stack-DWCwU3fh.js → stack-Y3pDteZr.js} +1 -1
- package/dist/dashboard/assets/stack-Y3pDteZr.js.gz +0 -0
- package/dist/dashboard/assets/{state-Cg3VL5J_.js → state-CArX_Iak.js} +1 -1
- package/dist/dashboard/assets/state-CArX_Iak.js.gz +0 -0
- package/dist/dashboard/assets/{switch-Db70BMix.js → switch-DwlwmDO-.js} +1 -1
- package/dist/dashboard/assets/switch-DwlwmDO-.js.gz +0 -0
- package/dist/dashboard/assets/{useMutation-BqdE0LuM.js → useMutation-DJ9GYEgf.js} +1 -1
- package/dist/dashboard/assets/useMutation-DJ9GYEgf.js.gz +0 -0
- package/dist/dashboard/index.html +2 -2
- package/dist/dashboard/index.html.gz +0 -0
- package/dist/dashboard/precompressed-assets.json +54 -53
- package/migrations/full-postgres/0046_add_accounts_quota_plan_fetched_at.sql +7 -0
- package/migrations/full-postgres/0047_add_provider_device_flow_proxy.sql +17 -0
- package/migrations/lite-sqlite3/0046_add_accounts_quota_plan_fetched_at.sql +4 -0
- package/migrations/lite-sqlite3/0047_add_provider_device_flow_proxy.sql +19 -0
- package/package.json +1 -1
- package/src/boot/app.ts +11 -14
- package/src/boot/dependencies.ts +5 -7
- package/src/boot/flush.ts +20 -0
- package/src/boot/lifecycle.ts +7 -5
- package/src/config/providers.ts +6 -5
- package/src/config/routing.ts +26 -0
- package/src/config/spec-metadata.ts +72 -0
- package/src/config/spec.ts +28 -8
- package/src/console/api-keys/contracts.ts +12 -1
- package/src/console/api-keys/routes.ts +40 -28
- package/src/console/auth/account-reset.ts +7 -9
- package/src/console/backup/contracts.ts +4 -0
- package/src/console/backup/export-endpoint.ts +7 -5
- package/src/console/backup/nine-router.ts +71 -10
- package/src/console/backup/service.ts +8 -1
- package/src/console/cli-tools/service.ts +6 -12
- package/src/console/console-router-guards.ts +23 -16
- package/src/console/console-router.ts +1 -1
- package/src/console/domain-registration.ts +7 -4
- package/src/console/inferhub/client.ts +1 -1
- package/src/console/inferhub/normalize.ts +24 -28
- package/src/console/inferhub/types.ts +3 -0
- package/src/console/ip-bans/routes.ts +7 -13
- package/src/console/observability/live.ts +2 -2
- package/src/console/observability/store.ts +0 -53
- package/src/console/providers/catalog/contracts.ts +9 -3
- package/src/console/providers/catalog/model-operations.ts +2 -3
- package/src/console/providers/catalog/provider-operations.ts +1 -1
- package/src/console/providers/catalog/routes.ts +3 -2
- package/src/console/providers/catalog/store.ts +1914 -1902
- package/src/console/providers/detail/routes.ts +1 -0
- package/src/console/providers/detail/store.ts +4 -0
- package/src/console/providers/oauth/callback-listener.ts +30 -11
- package/src/console/providers/oauth/device-egress.ts +123 -0
- package/src/console/providers/oauth/routes.ts +123 -16
- package/src/console/quota/account-quota-global.ts +14 -24
- package/src/console/quota/account-quota-shared.ts +49 -0
- package/src/console/quota/account-quota-tenant.ts +11 -23
- package/src/console/quota/account-test-egress.ts +2 -2
- package/src/console/quota/daily-growth-job.ts +1 -1
- package/src/console/quota/quota-view.ts +19 -3
- package/src/console/routing/pools/contracts.ts +1 -1
- package/src/console/routing/pools/operations.ts +32 -47
- package/src/console/routing/pools/probe-verdict.ts +2 -2
- package/src/console/routing/pools/relay-deploy.ts +1 -1
- package/src/console/routing/pools/store.ts +4 -4
- package/src/console/shared/errors.ts +33 -6
- package/src/main.ts +2 -3
- package/src/network/client.ts +67 -0
- package/src/network/egress/bidi-session.ts +322 -0
- package/src/network/egress/dial.ts +201 -0
- package/src/network/egress/factory.ts +191 -0
- package/src/network/{outbound-fetch.ts → egress/fetch.ts} +195 -29
- package/src/network/egress/tls-profile.ts +123 -0
- package/src/network/egress/ws.ts +175 -0
- package/src/network/pool/{byte-accounting.ts → accounting.ts} +3 -2
- package/src/network/{types.ts → pool/agent-config.ts} +1 -1
- package/src/network/pool/agent.ts +2 -2
- package/src/network/{pool-cooldown-store.ts → pool/cooldown-store.ts} +95 -20
- package/src/network/{pool-health-machine.ts → pool/health.ts} +205 -6
- package/src/network/pool/resolver.ts +10 -849
- package/src/network/pool/selector.ts +184 -41
- package/src/network/ssrf.ts +7 -7
- package/src/network/{retry.ts → util/retry.ts} +1 -1
- package/src/network/{rate-limit-verdict.ts → verdict.ts} +1 -1
- package/src/observability/event-log.ts +7 -361
- package/src/observability/performance-metrics.ts +9 -0
- package/src/observability/runtime-metrics.ts +14 -9
- package/src/observability/telemetry-buffer.ts +16 -26
- package/src/observability/telemetry-reconciliation.ts +9 -37
- package/src/observability/telemetry-row.ts +125 -0
- package/src/persistence/db-mode.ts +4 -1
- package/src/persistence/dialect.ts +18 -0
- package/src/persistence/migrate.ts +6 -3
- package/src/persistence/schema.ts +17 -0
- package/src/persistence/ttl-cache.ts +21 -3
- package/src/protocol/codecs.ts +59 -2
- package/src/protocol/dsml.ts +34 -86
- package/src/protocol/markup-stream-parser.ts +195 -0
- package/src/protocol/reasoning-text.ts +19 -0
- package/src/protocol/request/chat.ts +10 -10
- package/src/protocol/request/gemini.ts +8 -2
- package/src/protocol/request/messages.ts +34 -22
- package/src/protocol/response/chat.ts +12 -20
- package/src/protocol/response/messages.ts +34 -7
- package/src/protocol/response/omp-tool-call-markup.ts +43 -86
- package/src/protocol/response/responses.ts +18 -2
- package/src/protocol/stop-reason.ts +23 -1
- package/src/protocol/think-split.ts +112 -0
- package/src/protocol/translation/dialect/qwen.ts +26 -58
- package/src/protocol/translation/quirks.ts +15 -0
- package/src/protocol/upstream/send-claude-messages.ts +2 -2
- package/src/providers/adapters/compatible-adapter.ts +76 -12
- package/src/providers/authentication/oauth-client.ts +40 -6
- package/src/providers/authentication/oauth-device-flow.ts +105 -11
- package/src/providers/authentication/oauth-flow-store.ts +82 -1
- package/src/providers/authentication/oauth-refresh-service.ts +766 -758
- package/src/providers/configured-provider.ts +148 -0
- package/src/providers/default-registry.ts +137 -36
- package/src/providers/discovery/discovery-types.ts +7 -0
- package/src/providers/discovery/probe-phases.ts +20 -6
- package/src/providers/discovery/probing-service.ts +22 -2
- package/src/providers/integrations/alibabacp/alibabacp-models.ts +1 -2
- package/src/providers/integrations/{dashscope-models.ts → alibabacp/dashscope-models.ts} +2 -2
- package/src/providers/integrations/antigravity/antigravity-protocol.ts +16 -1
- package/src/providers/integrations/buddy/account.ts +969 -0
- package/src/providers/integrations/buddy/capabilities.ts +12 -12
- package/src/providers/integrations/buddy/{buddy-catalog-shared.ts → catalog.ts} +198 -136
- package/src/providers/integrations/buddy/{buddy-chat-shared.ts → chat.ts} +176 -20
- package/src/providers/integrations/buddy/codebuddy/index.ts +429 -0
- package/src/providers/integrations/buddy/codebuddy/shared.ts +41 -26
- package/src/providers/integrations/buddy/headers.ts +311 -0
- package/src/providers/integrations/buddy/lifecycle.ts +1018 -0
- package/src/providers/integrations/buddy/{buddy-media.ts → media.ts} +1 -1
- package/src/providers/integrations/claude/claude-quota.ts +14 -17
- package/src/providers/integrations/cline/cline-oauth.ts +10 -5
- package/src/providers/integrations/cline/cline.ts +5 -6
- package/src/providers/integrations/clixad/clixad-oauth.ts +98 -0
- package/src/providers/integrations/clixad/clixad-quota.ts +132 -0
- package/src/providers/integrations/clixad/provider.ts +145 -0
- package/src/providers/integrations/codex/codex-oauth.ts +8 -5
- package/src/providers/integrations/commandcode/quota.ts +22 -28
- package/src/providers/integrations/cursor/cursor-auth.ts +1 -9
- package/src/providers/integrations/cursor/cursor-exec.ts +9 -2
- package/src/providers/integrations/cursor/cursor-hardening.ts +279 -0
- package/src/providers/integrations/cursor/cursor-http2.ts +605 -6
- package/src/providers/integrations/cursor/cursor-request.ts +26 -12
- package/src/providers/integrations/cursor/cursor-shared.ts +8 -5
- package/src/providers/integrations/cursor/cursor.ts +574 -110
- package/src/providers/integrations/cursor/protocol/messages.ts +22 -29
- package/src/providers/integrations/cursor/protocol/protobuf-schema.ts +37 -0
- package/src/providers/integrations/deepai/deepai-catalog.ts +149 -0
- package/src/providers/integrations/deepai/deepai-common.ts +145 -0
- package/src/providers/integrations/deepai/deepai-oauth.ts +129 -0
- package/src/providers/integrations/deepai/deepai-quota.ts +97 -0
- package/src/providers/integrations/deepai/provider.ts +42 -0
- package/src/providers/integrations/devin/devin-auth.ts +7 -0
- package/src/providers/integrations/devin/protocol/devin-proto.ts +1 -27
- package/src/providers/integrations/factory/factory-oauth.ts +33 -51
- package/src/providers/integrations/freebuff/assets/official-system-manager.txt +88 -0
- package/src/providers/integrations/freebuff/assets/official-system-worker.txt +57 -0
- package/src/providers/integrations/freebuff/assets/official-tools.json +1070 -0
- package/src/providers/integrations/freebuff/freebuff-catalog.ts +120 -0
- package/src/providers/integrations/freebuff/freebuff-constants.ts +1233 -0
- package/src/providers/integrations/freebuff/freebuff-discovery.ts +108 -0
- package/src/providers/integrations/freebuff/freebuff-oauth.ts +240 -0
- package/src/providers/integrations/freebuff/freebuff-quota.ts +260 -0
- package/src/providers/integrations/freebuff/freebuff-virtual.ts +89 -0
- package/src/providers/integrations/freebuff/freebuff.ts +682 -0
- package/src/providers/integrations/freepi/freepi-catalog.ts +5 -0
- package/src/providers/integrations/freepi/freepi-discovery.ts +2 -2
- package/src/providers/integrations/freepi/freepi-headers.ts +2 -2
- package/src/providers/integrations/freepi/freepi-protocol.ts +11 -2
- package/src/providers/integrations/freepi/freepi-realm.ts +0 -3
- package/src/providers/integrations/freepi/freepi-session.ts +158 -0
- package/src/providers/integrations/freepi/freepi.ts +80 -54
- package/src/providers/integrations/github/github-discovery.ts +30 -13
- package/src/providers/integrations/github/github-oauth.ts +217 -44
- package/src/providers/integrations/github/github-quota.ts +22 -5
- package/src/providers/integrations/github/github.ts +40 -32
- package/src/providers/integrations/grok/grok-oauth.ts +52 -49
- package/src/providers/integrations/grok/grok-ratelimit-store.ts +10 -17
- package/src/providers/integrations/grok/grok-turn-index.ts +11 -16
- package/src/providers/integrations/hermes/hermes-oauth.ts +3 -3
- package/src/providers/integrations/{hermes.ts → hermes/hermes.ts} +42 -13
- package/src/providers/integrations/hermes/index.ts +2 -0
- package/src/providers/integrations/kilo/kilo-oauth.ts +4 -4
- package/src/providers/integrations/kilo/kilo.ts +1 -1
- package/src/providers/integrations/kimi/kimi-oauth.ts +3 -3
- package/src/providers/integrations/kiro/kiro-catalog.ts +17 -1
- package/src/providers/integrations/kiro/kiro-discovery.ts +10 -12
- package/src/providers/integrations/kiro/kiro-oauth.ts +2 -2
- package/src/providers/integrations/kiro/kiro-quota.ts +8 -4
- package/src/providers/integrations/kiro/kiro.ts +15 -19
- package/src/providers/integrations/muse/muse-oauth.ts +5 -5
- package/src/providers/integrations/ollamacloud/index.ts +2 -0
- package/src/providers/integrations/{ollamacloud-models.ts → ollamacloud/models.ts} +2 -2
- package/src/providers/integrations/{ollamacloud-quota.ts → ollamacloud/quota.ts} +3 -3
- package/src/providers/integrations/opencode/fingerprint.ts +1 -1
- package/src/providers/integrations/opencode/opencode-go-quota.ts +2 -8
- package/src/providers/integrations/openrouter/provider.ts +1 -1
- package/src/providers/integrations/perch/perch-credential.ts +25 -0
- package/src/providers/integrations/perch/perch-protocol.ts +0 -6
- package/src/providers/integrations/perch/perch-quota.ts +2 -16
- package/src/providers/integrations/perch/perch.ts +6 -16
- package/src/providers/integrations/qoder/claim-qoder-credits.ts +19 -60
- package/src/providers/integrations/qoder/qoder-adapter.ts +20 -125
- package/src/providers/integrations/qoder/qoder-auth.ts +109 -30
- package/src/providers/integrations/qoder/qoder-catalog.ts +21 -6
- package/src/providers/integrations/qoder/qoder-chat.ts +32 -29
- package/src/providers/integrations/qoder/qoder-checkin.ts +5 -5
- package/src/providers/integrations/qoder/qoder-cosy.ts +37 -9
- package/src/providers/integrations/qoder/qoder-oauth.ts +9 -2
- package/src/providers/integrations/qoder/qoder-realm.ts +13 -36
- package/src/providers/integrations/qoder/qoder-sse.ts +447 -68
- package/src/providers/integrations/qoder/qoder-types.ts +9 -0
- package/src/providers/integrations/qoder/qoder.ts +3 -26
- package/src/providers/integrations/sensenova/provider.ts +144 -0
- package/src/providers/integrations/trae/trae-protocol.ts +9 -5
- package/src/providers/integrations/trae/trae-quota.ts +21 -29
- package/src/providers/integrations/trae/trae-realm.ts +38 -0
- package/src/providers/integrations/trae/trae.ts +7 -24
- package/src/providers/integrations/vercel/provider.ts +117 -0
- package/src/providers/integrations/vercel/vercel-quota.ts +96 -0
- package/src/providers/integrations/xai/xai-oauth.ts +5 -5
- package/src/providers/integrations/zcode-start-plan/gifts.ts +1 -1
- package/src/providers/integrations/zcode-start-plan/oauth.ts +3 -3
- package/src/providers/integrations/zcode-start-plan/provider.ts +226 -23
- package/src/providers/integrations/zcode-start-plan/quota.ts +19 -2
- package/src/providers/integrations/zro/zro.ts +205 -0
- package/src/providers/operations/account-error-classification.ts +199 -307
- package/src/providers/operations/account-health-service.ts +82 -1
- package/src/providers/operations/account-refresh-status.ts +36 -37
- package/src/providers/operations/account-tier.ts +15 -16
- package/src/providers/operations/client-version-resolver.ts +1 -1
- package/src/providers/operations/client-versions.ts +100 -81
- package/src/providers/operations/cooldown-rules.ts +11 -16
- package/src/providers/operations/oauth-refresh-lead.ts +46 -22
- package/src/providers/operations/{model-catalog-cache.ts → provider-caches.ts} +68 -16
- package/src/providers/operations/provider-catalog-service.ts +1 -1
- package/src/providers/operations/provider-credential-service.ts +118 -51
- package/src/providers/operations/provider-rules/github.ts +48 -0
- package/src/providers/operations/provider-rules/grok.ts +33 -0
- package/src/providers/operations/provider-rules/inception.ts +49 -0
- package/src/providers/operations/provider-rules/index.ts +32 -0
- package/src/providers/operations/provider-rules/perch.ts +37 -0
- package/src/providers/operations/provider-rules/qoder.ts +70 -0
- package/src/providers/operations/provider-rules/trae.ts +81 -0
- package/src/providers/operations/session-resolution.ts +17 -1
- package/src/providers/provider-contracts.ts +6 -4
- package/src/providers/{provider-credential-resolver.ts → provider-credentials.ts} +40 -3
- package/src/providers/provider-metadata.ts +70 -10
- package/src/providers/provider-probe.ts +15 -1
- package/src/providers/provider-registry.ts +42 -33
- package/src/providers/quota/quota-contracts.ts +2 -0
- package/src/providers/quota/quota-limit-windows.ts +16 -5
- package/src/providers/quota/quota-support.ts +1 -1
- package/src/providers/quota/quota-window-parser.ts +90 -18
- package/src/providers/quota/refresh.ts +100 -21
- package/src/security/api-key-auth-cache.ts +1 -1
- package/src/security/api-key-auth.ts +6 -2
- package/src/security/ip-boundary.ts +1 -1
- package/src/transport/dispatch/attempt-finalize.ts +15 -14
- package/src/transport/dispatch/attempt-loop.ts +73 -19
- package/src/transport/dispatch/generation.ts +2 -8
- package/src/transport/dispatch/handler-deps.ts +1 -1
- package/src/transport/dispatch/leases.ts +1 -1
- package/src/transport/dispatch/native-shared.ts +25 -0
- package/src/transport/dispatch/proxy-request.ts +8 -46
- package/src/transport/dispatch/responses-compact.ts +2 -8
- package/src/transport/dispatch/retry-policy.ts +1 -1
- package/src/transport/dispatch/streaming/finalize.ts +10 -7
- package/src/transport/dispatch/streaming/stream.ts +9 -37
- package/src/transport/dispatch/systemone.ts +2 -8
- package/src/transport/dispatch/token-count.ts +2 -6
- package/src/transport/dispatch/upstream.ts +63 -0
- package/src/transport/drain-endpoint.ts +31 -4
- package/src/transport/failure-policy.ts +16 -30
- package/src/transport/guards/body-policy.ts +7 -1
- package/src/transport/guards/error-lifecycle.ts +13 -1
- package/src/transport/guards/request-context.ts +22 -14
- package/src/transport/request/preparer.ts +81 -71
- package/src/transport/routing/bounded-lru.ts +14 -0
- package/src/transport/routing/drain-reserve.ts +129 -0
- package/src/transport/routing/plan-gate.ts +7 -1
- package/src/transport/routing/round-robin.ts +60 -0
- package/src/transport/routing/route-catalog.ts +123 -111
- package/src/transport/routing/route-model.ts +51 -9
- package/src/transport/routing/router.ts +393 -32
- package/src/transport/shutdown-notice.ts +1 -1
- package/src/transport/surface/adapters.ts +11 -2
- package/src/transport/surface/chat/adapter.ts +2 -2
- package/src/transport/surface/chat/encode.ts +47 -4
- package/src/transport/surface/chat/parse.ts +2 -12
- package/src/transport/surface/completion.ts +44 -33
- package/src/transport/surface/content-parts.ts +21 -0
- package/src/transport/surface/messages/adapter.ts +7 -1
- package/src/transport/surface/messages/encode.ts +11 -3
- package/src/transport/surface/messages/parse.ts +2 -1
- package/src/transport/surface/messages/stream.ts +33 -17
- package/src/transport/surface/responses/encode.ts +22 -5
- package/src/transport/surface/responses/parse.ts +3 -11
- package/src/workers/buddy-growth-tasks.ts +8 -8
- package/src/workers/daily-checkin.ts +4 -4
- package/src/workers/quota-refresh-sweep.ts +1 -1
- package/src/workers/zcode-gift-claim.ts +1 -1
- package/dist/dashboard/assets/BannedPage-BxoG1MrL.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-ixwgKk53.js.gz +0 -0
- package/dist/dashboard/assets/CliToolsPage-BmttNV3U.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-BE3qwwfC.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-CRZdmMIX.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-Dv_FJ6Er.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-CoVrw2sM.js +0 -1
- package/dist/dashboard/assets/HealthEventsModal-CoVrw2sM.js.gz +0 -0
- package/dist/dashboard/assets/InjectSystemPromptPanel-BOoO8rpz.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-ChJuNNVN.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-BZAJtPg1.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-BE40s3u5.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-sfLoQtPV.js +0 -8
- package/dist/dashboard/assets/ProviderDetailPage-sfLoQtPV.js.gz +0 -0
- package/dist/dashboard/assets/ProviderIcon-CQ6bPUf_.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-DcepmPWo.js +0 -1
- package/dist/dashboard/assets/ProvidersPage-DcepmPWo.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-BJ87Pp0U.js +0 -9
- package/dist/dashboard/assets/ProxyPage-BJ87Pp0U.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-gkSyRAKa.js +0 -1
- package/dist/dashboard/assets/QuotaPage-gkSyRAKa.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-C5HPWqEv.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-CFNcATJJ.js.gz +0 -0
- package/dist/dashboard/assets/StudioPage-YoD4tY5l.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-DxjaAhb_.js.gz +0 -0
- package/dist/dashboard/assets/api-keys-BTlkq_Uk.js.gz +0 -0
- package/dist/dashboard/assets/app-BX2nnKqs.css +0 -2
- package/dist/dashboard/assets/app-BX2nnKqs.css.gz +0 -0
- package/dist/dashboard/assets/assert-CndthXFQ.js +0 -1
- package/dist/dashboard/assets/assert-CndthXFQ.js.gz +0 -0
- package/dist/dashboard/assets/badge-DdbT_7Am.js.gz +0 -0
- package/dist/dashboard/assets/banner-CsGN-nbm.js.gz +0 -0
- package/dist/dashboard/assets/card-mgZ_TLLZ.js.gz +0 -0
- package/dist/dashboard/assets/cli-tools-A1-v8-03.js.gz +0 -0
- package/dist/dashboard/assets/clipboard-button-CJ4Zty89.js.gz +0 -0
- package/dist/dashboard/assets/dialog-B5L2jPQ_.js.gz +0 -0
- package/dist/dashboard/assets/drawer-DkjUouAj.js.gz +0 -0
- package/dist/dashboard/assets/entry-B-LVLV6o.js.gz +0 -0
- package/dist/dashboard/assets/entry-CAhdYl3B.js.gz +0 -0
- package/dist/dashboard/assets/entry-D2xD0Tmd.js +0 -4
- package/dist/dashboard/assets/entry-D2xD0Tmd.js.gz +0 -0
- package/dist/dashboard/assets/formatters-CrsdLVsn.js.gz +0 -0
- package/dist/dashboard/assets/index-BU64Lqvp.js.gz +0 -0
- package/dist/dashboard/assets/inline-B2aDLyyg.js.gz +0 -0
- package/dist/dashboard/assets/input-BCVkdR9d.js.gz +0 -0
- package/dist/dashboard/assets/layout-DNcAdMK-.js.gz +0 -0
- package/dist/dashboard/assets/list-row-DgrIH0hw.js.gz +0 -0
- package/dist/dashboard/assets/live-Cae01iu8.js.gz +0 -0
- package/dist/dashboard/assets/network-CRib7H_2.js.gz +0 -0
- package/dist/dashboard/assets/numbers-BKQE8Xpz.js.gz +0 -0
- package/dist/dashboard/assets/pagination-ecPvG_8g.js.gz +0 -0
- package/dist/dashboard/assets/quota-DJ1QlAOq.js +0 -1
- package/dist/dashboard/assets/quota-DJ1QlAOq.js.gz +0 -0
- package/dist/dashboard/assets/routing-Ckm1UXuC.js.gz +0 -0
- package/dist/dashboard/assets/select-ChoFWyKz.js.gz +0 -0
- package/dist/dashboard/assets/settings-CKjFPlKW.js.gz +0 -0
- package/dist/dashboard/assets/stack-DWCwU3fh.js.gz +0 -0
- package/dist/dashboard/assets/state-Cg3VL5J_.js.gz +0 -0
- package/dist/dashboard/assets/switch-Db70BMix.js.gz +0 -0
- package/dist/dashboard/assets/useMutation-BqdE0LuM.js.gz +0 -0
- package/src/network/pool-health.ts +0 -210
- package/src/providers/bundled-discovery-providers.ts +0 -98
- package/src/providers/configured-openai-providers.ts +0 -120
- package/src/providers/integrations/buddy/buddy-checkin.ts +0 -521
- package/src/providers/integrations/buddy/buddy-cli-headers.ts +0 -76
- package/src/providers/integrations/buddy/buddy-conversation-headers.ts +0 -130
- package/src/providers/integrations/buddy/buddy-discovery-shared.ts +0 -234
- package/src/providers/integrations/buddy/buddy-growth-shared.ts +0 -496
- package/src/providers/integrations/buddy/buddy-identity.ts +0 -300
- package/src/providers/integrations/buddy/buddy-oauth-shared.ts +0 -341
- package/src/providers/integrations/buddy/buddy-quota-shared.ts +0 -417
- package/src/providers/integrations/buddy/buddy-server-tools.ts +0 -240
- package/src/providers/integrations/buddy/buddy-usage-shared.ts +0 -221
- package/src/providers/integrations/buddy/buddy-usage-store.ts +0 -181
- package/src/providers/integrations/buddy/codebuddy/cn-catalog.ts +0 -37
- package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +0 -185
- package/src/providers/integrations/buddy/codebuddy/oauth.ts +0 -47
- package/src/providers/integrations/buddy/codebuddy/provider.ts +0 -175
- package/src/providers/integrations/buddy/codebuddy/quota.ts +0 -154
- package/src/providers/integrations/qoder/chat-proto.ts +0 -328
- package/src/providers/integrations/qoder/qoder-grpc.ts +0 -871
- package/src/providers/integrations/qoder/qoder-ide.ts +0 -588
- package/src/providers/operations/model-discovery-cache.ts +0 -34
- package/src/providers/operations/probe-fetch.ts +0 -15
- package/src/providers/operations/provider-version-cache.ts +0 -30
- package/src/providers/provider-capabilities.ts +0 -33
- package/src/providers/provider-credential-envelope.ts +0 -43
- /package/dist/dashboard/assets/{app-B7kIWFm5.js → app-C0gTXEoN.js} +0 -0
- /package/dist/dashboard/assets/{app-B7kIWFm5.js.gz → app-C0gTXEoN.js.gz} +0 -0
- /package/src/network/{http2-bidi.ts → bidi.ts} +0 -0
- /package/src/network/pool/{pool-scripts.ts → scripts.ts} +0 -0
- /package/src/network/pool/{pool-state.ts → state.ts} +0 -0
- /package/src/network/{backoff.ts → util/backoff.ts} +0 -0
- /package/src/network/{deduplication.ts → util/dedupe.ts} +0 -0
- /package/src/network/{ip-utils.ts → util/ip.ts} +0 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ValidatedNetworkBindingFactory` — one binding point for every validated
|
|
3
|
+
* egress capability: fetch, profile-fetch, HTTP/2 bidi, WebSocket, and
|
|
4
|
+
* one-shot DNS+SSRF resolution.
|
|
5
|
+
*
|
|
6
|
+
* Direct egress resolves and validates *every* DNS answer for the hostname,
|
|
7
|
+
* pins the chosen public address, and keeps TLS SNI pointed at the validated
|
|
8
|
+
* hostname — so a rebind between check and connect cannot retarget the socket,
|
|
9
|
+
* and the certificate still validates against the name the caller asked for.
|
|
10
|
+
* Pooled egress reuses the selected tenant pool's CONNECT/SOCKS tunnel and
|
|
11
|
+
* accounts the tunnel's bytes to that pool, exactly as `fetch` does.
|
|
12
|
+
*/
|
|
13
|
+
import { GatewayError } from "../../protocol/gateway-error";
|
|
14
|
+
import type { SsrfPolicy } from "../../config/network";
|
|
15
|
+
import { createValidatedFetch, type ValidatedFetch } from "./fetch";
|
|
16
|
+
import { isAddressAllowed, resolveAndValidateOnce, type ValidatedDestination } from "../ssrf";
|
|
17
|
+
import { tlsProfileOf, type TlsProfile } from "./tls-profile";
|
|
18
|
+
import { assertBidiTarget, dialDirect, dialThroughPool } from "./dial";
|
|
19
|
+
import { openBidiStream } from "./bidi-session";
|
|
20
|
+
import { connectValidatedWebSocket } from "./ws";
|
|
21
|
+
import type { ValidatedOutboundWebSocket, ValidatedOutboundHttp2Bidi } from "../../providers/provider-contracts";
|
|
22
|
+
import { PoolBindingError, type PoolAgent } from "../pool/agent";
|
|
23
|
+
import type { PoolAgentResolver } from "../pool/resolver";
|
|
24
|
+
|
|
25
|
+
export class ValidatedNetworkBindingFactory {
|
|
26
|
+
private readonly directFetch: ValidatedFetch;
|
|
27
|
+
/**
|
|
28
|
+
* One `ValidatedFetch` per resolved pool agent. Building the closure (dialer,
|
|
29
|
+
* relay setup, redirect loop) on every request through a pool was pure
|
|
30
|
+
* churn; keying by the agent object means a released agent is naturally
|
|
31
|
+
* collected (new builds mint a new agent) so no explicit invalidation is
|
|
32
|
+
* needed.
|
|
33
|
+
*/
|
|
34
|
+
private readonly pooledFetchCache = new WeakMap<PoolAgent, ValidatedFetch>();
|
|
35
|
+
/** Default TLS ClientHello profile for direct https dials. */
|
|
36
|
+
private readonly tlsProfile: TlsProfile;
|
|
37
|
+
|
|
38
|
+
constructor(
|
|
39
|
+
private readonly policy: SsrfPolicy = {},
|
|
40
|
+
fetchFn?: typeof fetch,
|
|
41
|
+
private readonly poolResolver?: PoolAgentResolver,
|
|
42
|
+
tlsProfile: TlsProfile = "default",
|
|
43
|
+
) {
|
|
44
|
+
this.directFetch = createValidatedFetch({
|
|
45
|
+
policy,
|
|
46
|
+
...(fetchFn ? { fetchFn } : {}),
|
|
47
|
+
tlsProfile,
|
|
48
|
+
});
|
|
49
|
+
this.tlsProfile = tlsProfile;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* One-shot DNS resolution + SSRF validation for direct egress targets:
|
|
54
|
+
* every resolved address (all families, not just the first answer) must
|
|
55
|
+
* pass the SSRF policy, and the validated destination pins the address so
|
|
56
|
+
* the subsequent socket cannot be re-pointed at a private/link-local host
|
|
57
|
+
* by a DNS rebind between check and connect.
|
|
58
|
+
*/
|
|
59
|
+
resolve(hostname: string, signal: AbortSignal, port = 443): Promise<ValidatedDestination> {
|
|
60
|
+
return resolveAndValidateOnce(hostname, this.policy, signal, port);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Without `networkPoolId` (or when no pool resolver is configured), dispatch
|
|
65
|
+
* through validated direct egress. With one, route through that pool's
|
|
66
|
+
* validated agent. A pool configuration failure is returned to the caller;
|
|
67
|
+
* configured egress is never silently replaced with direct traffic.
|
|
68
|
+
*/
|
|
69
|
+
fetch(networkPoolId?: string, tenantId?: string): ValidatedFetch {
|
|
70
|
+
if (!networkPoolId || !this.poolResolver || !tenantId) return this.directFetch;
|
|
71
|
+
const { poolResolver, policy } = this;
|
|
72
|
+
return async (input, init) => {
|
|
73
|
+
try {
|
|
74
|
+
const agent = await poolResolver.resolveAgent(networkPoolId, tenantId);
|
|
75
|
+
let validated = this.pooledFetchCache.get(agent);
|
|
76
|
+
if (validated === undefined) {
|
|
77
|
+
validated = createValidatedFetch({ policy, agent });
|
|
78
|
+
this.pooledFetchCache.set(agent, validated);
|
|
79
|
+
}
|
|
80
|
+
return await validated(input, init);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (error instanceof PoolBindingError) {
|
|
83
|
+
throw new GatewayError(
|
|
84
|
+
"proxy_pool_unhealthy",
|
|
85
|
+
503,
|
|
86
|
+
"Configured proxy pool could not be established (request {requestId}). This bridge looks shaky — repair or reroute 🔧",
|
|
87
|
+
{ poolId: networkPoolId, reason: error.message.slice(0, 200) },
|
|
88
|
+
"network",
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
webSocket(networkPoolId?: string, tenantId?: string): ValidatedOutboundWebSocket {
|
|
97
|
+
return async (url, headers, signal) => {
|
|
98
|
+
if (url.protocol !== "wss:") {
|
|
99
|
+
throw new GatewayError("invalid_request", 400, "validated WebSocket egress requires wss (request {requestId}). Check the shape of what you sent — then resend 🪞");
|
|
100
|
+
}
|
|
101
|
+
if (url.username || url.password || url.hash || (url.port !== "" && url.port !== "443")) {
|
|
102
|
+
throw new GatewayError("invalid_request", 400, "invalid validated WebSocket target (request {requestId}). Check the shape of what you sent — then resend 🪞");
|
|
103
|
+
}
|
|
104
|
+
const hostname = url.hostname;
|
|
105
|
+
if (!hostname) throw new GatewayError("invalid_request", 400, "WebSocket target hostname is required (request {requestId}). Check the shape of what you sent — then resend 🪞");
|
|
106
|
+
const destination = await this.resolve(hostname, signal, 443);
|
|
107
|
+
if (!isAddressAllowed(destination.resolvedAddress, this.policy)) {
|
|
108
|
+
throw new GatewayError("invalid_request", 400, "unsafe upstream address rejected (request {requestId}). Check the shape of what you sent — then resend 🪞");
|
|
109
|
+
}
|
|
110
|
+
let agent: PoolAgent | undefined;
|
|
111
|
+
if (networkPoolId || tenantId) {
|
|
112
|
+
if (!networkPoolId || !tenantId || !this.poolResolver) {
|
|
113
|
+
throw new GatewayError("proxy_pool_unhealthy", 503, "configured WebSocket proxy pool could not be established (request {requestId}). This bridge looks shaky — repair or reroute 🔧");
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
agent = await this.poolResolver.resolveAgent(networkPoolId, tenantId);
|
|
117
|
+
} catch (error) {
|
|
118
|
+
if (error instanceof PoolBindingError) {
|
|
119
|
+
throw new GatewayError("proxy_pool_unhealthy", 503, "configured WebSocket proxy pool could not be established (request {requestId}). This bridge looks shaky — repair or reroute 🔧");
|
|
120
|
+
}
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return connectValidatedWebSocket(url, headers, signal, destination, agent);
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Validated bidirectional HTTP/2 egress for one writable streaming request.
|
|
130
|
+
*
|
|
131
|
+
* Direct egress resolves and validates *every* DNS answer for the hostname,
|
|
132
|
+
* pins the chosen public address, and keeps TLS SNI pointed at the validated
|
|
133
|
+
* hostname — so a rebind between check and connect cannot retarget the socket,
|
|
134
|
+
* and the certificate still validates against the name the caller asked for.
|
|
135
|
+
* Pooled egress reuses the selected tenant pool's CONNECT/SOCKS tunnel and
|
|
136
|
+
* accounts the tunnel's bytes to that pool, exactly as `fetch` does.
|
|
137
|
+
*
|
|
138
|
+
* Credentials in the authority, fragments, non-443 ports, IP literals, and
|
|
139
|
+
* hostnames outside `allowHostname` are refused before anything is dialed: a
|
|
140
|
+
* bidi stream carries the account credential for its whole lifetime, so the
|
|
141
|
+
* target is checked once here rather than trusted to each caller.
|
|
142
|
+
*/
|
|
143
|
+
http2Bidi(networkPoolId?: string, tenantId?: string): ValidatedOutboundHttp2Bidi {
|
|
144
|
+
return async (url, headers, options) => {
|
|
145
|
+
assertBidiTarget(url);
|
|
146
|
+
const { hostname } = url;
|
|
147
|
+
if (options?.allowHostname !== undefined && !options.allowHostname(hostname)) {
|
|
148
|
+
throw new GatewayError("invalid_request", 400, "HTTP/2 target host is not allowlisted (request {requestId}). All roads blocked — time to find a new route 🗺️");
|
|
149
|
+
}
|
|
150
|
+
const signal = options?.signal ?? AbortSignal.timeout(15_000);
|
|
151
|
+
const profile = tlsProfileOf(this.tlsProfile);
|
|
152
|
+
// Validation runs on every new connection, not once per capability: a
|
|
153
|
+
// long-lived Run is re-opened after a session dies, and a hostname that
|
|
154
|
+
// resolved safely the first time is not safe the twentieth.
|
|
155
|
+
const destination = await this.resolve(hostname, signal, 443);
|
|
156
|
+
if (!isAddressAllowed(destination.resolvedAddress, this.policy)) {
|
|
157
|
+
throw new GatewayError("invalid_request", 400, "unsafe upstream address rejected (request {requestId}). All roads blocked — time to find a new route 🗺️");
|
|
158
|
+
}
|
|
159
|
+
// Mirrors `fetch`: no pool selected (or none configured) means validated
|
|
160
|
+
// *direct* egress, which is already resolved and SSRF-checked above.
|
|
161
|
+
// Requiring a pool whenever a tenant id was supplied made bidi-only
|
|
162
|
+
// providers (Cursor) unreachable on every install without a pool, even
|
|
163
|
+
// though the equivalent `fetch` path dials direct there.
|
|
164
|
+
if (networkPoolId !== undefined || tenantId !== undefined) {
|
|
165
|
+
if (networkPoolId === undefined || tenantId === undefined || this.poolResolver === undefined) {
|
|
166
|
+
const socket = await dialDirect(destination, signal, profile);
|
|
167
|
+
return openBidiStream(socket, url, headers, options ?? {});
|
|
168
|
+
}
|
|
169
|
+
let agent: PoolAgent;
|
|
170
|
+
try {
|
|
171
|
+
agent = await this.poolResolver.resolveAgent(networkPoolId, tenantId);
|
|
172
|
+
} catch (error) {
|
|
173
|
+
if (error instanceof PoolBindingError) {
|
|
174
|
+
throw new GatewayError(
|
|
175
|
+
"proxy_pool_unhealthy",
|
|
176
|
+
503,
|
|
177
|
+
"Configured proxy pool could not be established (request {requestId}). This bridge looks shaky — repair or reroute 🔧",
|
|
178
|
+
{ poolId: networkPoolId, reason: error.message.slice(0, 200) },
|
|
179
|
+
"network",
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
184
|
+
const socket = await dialThroughPool(agent, hostname, signal, profile);
|
|
185
|
+
return openBidiStream(socket, url, headers, options ?? {});
|
|
186
|
+
}
|
|
187
|
+
const socket = await dialDirect(destination, signal, profile);
|
|
188
|
+
return openBidiStream(socket, url, headers, options ?? {});
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -1,14 +1,77 @@
|
|
|
1
1
|
// Validated fetch: SSRF-guarded outbound fetch with optional pool-agent support.
|
|
2
|
-
import { GatewayError } from "
|
|
2
|
+
import { GatewayError } from "../../protocol/gateway-error";
|
|
3
3
|
import { request as httpRequest, Agent as HttpAgent } from "node:http";
|
|
4
4
|
import { request as httpsRequest, Agent as HttpsAgent } from "node:https";
|
|
5
5
|
import { Agent as UndiciAgent, fetch as undiciFetch, type UndiciRequestInit } from "undici";
|
|
6
6
|
import { SocksProxyAgent } from "socks-proxy-agent";
|
|
7
|
-
import { resolveAllAddresses, validateResolvedAddresses } from "
|
|
8
|
-
import type { SsrfPolicy } from "
|
|
9
|
-
import { isProxyAgentPair, type ProxyAgentPair } from "
|
|
10
|
-
import { metrics } from "
|
|
11
|
-
import { trackNetworkCall } from "
|
|
7
|
+
import { resolveAllAddresses, validateResolvedAddresses } from "../ssrf";
|
|
8
|
+
import type { SsrfPolicy } from "../../config/network";
|
|
9
|
+
import { isProxyAgentPair, type ProxyAgentPair } from "../pool/agent";
|
|
10
|
+
import { metrics } from "../../observability/metrics";
|
|
11
|
+
import { trackNetworkCall } from "../../observability/performance-metrics";
|
|
12
|
+
import { nodeTlsOptions, tlsProfileOf, type TlsProfile, type TlsProfileConfig } from "./tls-profile";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Keep-alive socket lifetime for outbound direct dials.
|
|
16
|
+
*
|
|
17
|
+
* Some upstream edges (accelerated hosting, CDN front doors) drop idle or
|
|
18
|
+
* silently-errored keep-alive connections without sending a TCP RST. undici
|
|
19
|
+
* keeps those dead sockets in its pool — the default `keepAliveMaxTimeout`
|
|
20
|
+
* is 600s, and an upstream `Keep-Alive` header can push it even higher — so a
|
|
21
|
+
* retry reuses a poisoned socket and hangs until the connect timeout. Capping
|
|
22
|
+
* the TTL (both fields) keeps a dead socket from being reused; a fresh dial
|
|
23
|
+
* is also replayed once when a pooled socket dies before response headers.
|
|
24
|
+
*/
|
|
25
|
+
export const DEFAULT_KEEPALIVE_TIMEOUT_MS = 4_000;
|
|
26
|
+
/** Fresh-socket retry agent: keep-alive effectively disabled (1 ms). */
|
|
27
|
+
const FRESH_SOCKET_KEEPALIVE_TIMEOUT_MS = 1;
|
|
28
|
+
|
|
29
|
+
/** Error codes that prove a pooled keep-alive socket died mid-flight. */
|
|
30
|
+
const STALE_SOCKET_ERROR_CODES: ReadonlySet<string> = new Set([
|
|
31
|
+
"UND_ERR_SOCKET",
|
|
32
|
+
"ECONNRESET",
|
|
33
|
+
"EPIPE",
|
|
34
|
+
"UND_ERR_HEADERS_TIMEOUT",
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
/** Error codes that prove the host itself is unreachable — never a stale socket. */
|
|
38
|
+
const NON_STALE_SOCKET_ERROR_CODES: ReadonlySet<string> = new Set([
|
|
39
|
+
"ENOTFOUND",
|
|
40
|
+
"EAI_AGAIN",
|
|
41
|
+
"ECONNREFUSED",
|
|
42
|
+
"EHOSTUNREACH",
|
|
43
|
+
"ENETUNREACH",
|
|
44
|
+
"UND_ERR_CONNECT_TIMEOUT",
|
|
45
|
+
"UND_ERR_INVALID_ARG",
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
/** Message fragments that prove the other side closed a pooled connection. */
|
|
49
|
+
const STALE_SOCKET_ERROR_SUBSTRINGS: readonly string[] = [
|
|
50
|
+
"other side closed",
|
|
51
|
+
"socket hang up",
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
/** True when `error` proves a pooled keep-alive socket died before headers. */
|
|
55
|
+
export function isStaleSocketError(error: unknown): boolean {
|
|
56
|
+
if (!(error instanceof Error) || error.name === "AbortError") return false;
|
|
57
|
+
const cause = (error as { cause?: unknown }).cause;
|
|
58
|
+
const code =
|
|
59
|
+
(cause !== null && typeof cause === "object" && "code" in cause
|
|
60
|
+
? (cause as { code?: unknown }).code
|
|
61
|
+
: undefined) ?? (error as { code?: unknown }).code;
|
|
62
|
+
if (typeof code === "string") {
|
|
63
|
+
if (NON_STALE_SOCKET_ERROR_CODES.has(code)) return false;
|
|
64
|
+
if (STALE_SOCKET_ERROR_CODES.has(code)) return true;
|
|
65
|
+
}
|
|
66
|
+
const message = `${error.message} ${cause instanceof Error ? cause.message : ""}`.toLowerCase();
|
|
67
|
+
return STALE_SOCKET_ERROR_SUBSTRINGS.some((fragment) => message.includes(fragment));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** True when the request body can be safely replayed (not a locked stream). */
|
|
71
|
+
function isBodyReplaySafe(body: BodyInit | null | undefined): boolean {
|
|
72
|
+
if (body === null || body === undefined) return true;
|
|
73
|
+
return !(typeof body === "object" && "locked" in body && body.locked === true);
|
|
74
|
+
}
|
|
12
75
|
// Validated fetch — single egress with ProxyAgent support
|
|
13
76
|
interface ValidatedFetchOptions {
|
|
14
77
|
readonly fetchFn?: typeof fetch;
|
|
@@ -27,6 +90,18 @@ interface ValidatedFetchOptions {
|
|
|
27
90
|
* Defaults to undici's fetch; injectable for offline tests.
|
|
28
91
|
*/
|
|
29
92
|
readonly socksFetch?: (input: RequestInfo | URL, init?: UndiciRequestInit) => Promise<Response>;
|
|
93
|
+
/**
|
|
94
|
+
* TLS ClientHello profile for direct https dials. `"default"` (or absent)
|
|
95
|
+
* keeps the runtime's own TLS stack; a named profile (see `tls-profile.ts`)
|
|
96
|
+
* dials through a Node https agent configured with that profile's OpenSSL
|
|
97
|
+
* options, so the wire fingerprint matches the impersonated client.
|
|
98
|
+
*/
|
|
99
|
+
readonly tlsProfile?: TlsProfile;
|
|
100
|
+
/**
|
|
101
|
+
* CA bundle for profile dials (`https.Agent({ ca })`). Defaults to the
|
|
102
|
+
* system store; set it to trust a private/custom CA.
|
|
103
|
+
*/
|
|
104
|
+
readonly ca?: string | Buffer | Array<string | Buffer>;
|
|
30
105
|
}
|
|
31
106
|
export type ValidatedFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
32
107
|
|
|
@@ -91,7 +166,7 @@ function socksDispatcherFor(agent: SocksProxyAgent): UndiciAgent {
|
|
|
91
166
|
let dispatcher = socksDispatcherCache.get(agent);
|
|
92
167
|
if (!dispatcher) {
|
|
93
168
|
dispatcher = new UndiciAgent({
|
|
94
|
-
connect: async (opts) => {
|
|
169
|
+
connect: async (opts: Record<string, unknown>) => {
|
|
95
170
|
const o = opts as {
|
|
96
171
|
hostname?: string;
|
|
97
172
|
port?: number | string;
|
|
@@ -108,14 +183,63 @@ function socksDispatcherFor(agent: SocksProxyAgent): UndiciAgent {
|
|
|
108
183
|
} as never,
|
|
109
184
|
);
|
|
110
185
|
},
|
|
111
|
-
|
|
186
|
+
// Cap the pooled socket lifetime so a silently-dropped keep-alive
|
|
187
|
+
// connection is not reused by a later request.
|
|
188
|
+
keepAliveTimeout: DEFAULT_KEEPALIVE_TIMEOUT_MS,
|
|
189
|
+
keepAliveMaxTimeout: DEFAULT_KEEPALIVE_TIMEOUT_MS,
|
|
190
|
+
} as unknown as ConstructorParameters<typeof UndiciAgent>[0]);
|
|
112
191
|
socksDispatcherCache.set(agent, dispatcher);
|
|
113
192
|
}
|
|
114
193
|
return dispatcher;
|
|
115
194
|
}
|
|
116
195
|
|
|
117
196
|
/**
|
|
118
|
-
*
|
|
197
|
+
* A Node `https` agent whose connections are dialed with a chosen TLS profile.
|
|
198
|
+
*
|
|
199
|
+
* The agent's `createConnection` is the only knob that reaches the ClientHello
|
|
200
|
+
* through OpenSSL: it lets `tls.connect` run with the profile's cipher list,
|
|
201
|
+
* TLS 1.3 suite order, ALPN, and version bounds. Used only when a profile is
|
|
202
|
+
* requested; the runtime-native fetch stays the default for everything else.
|
|
203
|
+
*/
|
|
204
|
+
export function createProfileAgent(profile: TlsProfileConfig, ca?: string | Buffer | Array<string | Buffer>): HttpsAgent {
|
|
205
|
+
// Straight option passthrough — no custom `createConnection`. Bun's
|
|
206
|
+
// Node-compat layer ignores a user-supplied `createConnection` on the agent
|
|
207
|
+
// (verified live 2026-11-11: the dial failed with ERR_SOCKET_CLOSED and no
|
|
208
|
+
// ClientHello reached the sniffer), but it honors the standard TLS options
|
|
209
|
+
// on the agent itself — `ALPNProtocols` switched the ALPN extension and
|
|
210
|
+
// `ciphers` narrowed the offered suite list in capture. The request path
|
|
211
|
+
// stays the plain https client with the profile's fingerprint.
|
|
212
|
+
return new HttpsAgent({
|
|
213
|
+
...nodeTlsOptions(profile),
|
|
214
|
+
...(ca === undefined ? {} : { ca }),
|
|
215
|
+
} as unknown as HttpsAgent);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* A shared undici dispatcher whose keep-alive socket lifetime is capped.
|
|
220
|
+
*
|
|
221
|
+
* Direct dials go through the runtime's own fetch; when a socks dispatcher is
|
|
222
|
+
* in play (pool egress) the same TTL cap applies. A fresh-socket dispatcher
|
|
223
|
+
* (`keepAliveTimeout: 1ms`) backs the stale-socket replay so a retry never
|
|
224
|
+
* reuses the poisoned connection.
|
|
225
|
+
*/
|
|
226
|
+
let directDispatcherCache: { agent: UndiciAgent; fresh: UndiciAgent } | undefined;
|
|
227
|
+
function directDispatchers(): { agent: UndiciAgent; fresh: UndiciAgent } {
|
|
228
|
+
directDispatcherCache ??= {
|
|
229
|
+
agent: new UndiciAgent({
|
|
230
|
+
keepAliveTimeout: DEFAULT_KEEPALIVE_TIMEOUT_MS,
|
|
231
|
+
keepAliveMaxTimeout: DEFAULT_KEEPALIVE_TIMEOUT_MS,
|
|
232
|
+
} as unknown as ConstructorParameters<typeof UndiciAgent>[0]),
|
|
233
|
+
fresh: new UndiciAgent({
|
|
234
|
+
keepAliveTimeout: FRESH_SOCKET_KEEPALIVE_TIMEOUT_MS,
|
|
235
|
+
keepAliveMaxTimeout: FRESH_SOCKET_KEEPALIVE_TIMEOUT_MS,
|
|
236
|
+
} as unknown as ConstructorParameters<typeof UndiciAgent>[0]),
|
|
237
|
+
};
|
|
238
|
+
return directDispatcherCache;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Dial one request, always through `fetch`.
|
|
119
243
|
*
|
|
120
244
|
* The runtime's own fetch (Bun/Undici) is the transport the clients this
|
|
121
245
|
* gateway impersonates actually use, and several upstreams meter per client
|
|
@@ -141,6 +265,7 @@ async function dial(
|
|
|
141
265
|
proxyUrl: string | undefined,
|
|
142
266
|
dispatcher?: UndiciAgent,
|
|
143
267
|
socksFetch: (input: RequestInfo | URL, init?: UndiciRequestInit) => Promise<Response> = undiciFetch,
|
|
268
|
+
freshSocket = false,
|
|
144
269
|
): Promise<Response> {
|
|
145
270
|
const headers = new Headers(init.headers);
|
|
146
271
|
const rawBody = init.body;
|
|
@@ -157,13 +282,16 @@ async function dial(
|
|
|
157
282
|
return await socksFetch(url, {
|
|
158
283
|
...init,
|
|
159
284
|
headers,
|
|
160
|
-
dispatcher,
|
|
285
|
+
dispatcher: freshSocket ? directDispatchers().fresh : dispatcher,
|
|
161
286
|
});
|
|
162
287
|
}
|
|
163
288
|
return await fetchFn(url, {
|
|
164
289
|
...init,
|
|
165
290
|
headers,
|
|
166
291
|
...(proxyUrl === undefined ? {} : { proxy: proxyUrl }),
|
|
292
|
+
// Fresh-socket replay uses the capped undici dispatcher directly so a
|
|
293
|
+
// retry never reuses the pooled connection that just died.
|
|
294
|
+
...(freshSocket ? { dispatcher: directDispatchers().fresh } : {}),
|
|
167
295
|
});
|
|
168
296
|
}
|
|
169
297
|
|
|
@@ -177,7 +305,7 @@ async function dial(
|
|
|
177
305
|
* after the SSRF resolve/validate pass and only when no pool/relay agent owns
|
|
178
306
|
* the dial, so the security boundary is unchanged.
|
|
179
307
|
*/
|
|
180
|
-
function nodeDial(url: URL, init: RequestInit): Promise<Response> {
|
|
308
|
+
function nodeDial(url: URL, init: RequestInit, agent?: HttpsAgent): Promise<Response> {
|
|
181
309
|
return new Promise((resolve, reject) => {
|
|
182
310
|
const mod = url.protocol === "http:" ? httpRequest : httpsRequest;
|
|
183
311
|
const headers: Record<string, string> = {};
|
|
@@ -195,8 +323,10 @@ function nodeDial(url: URL, init: RequestInit): Promise<Response> {
|
|
|
195
323
|
payload = body;
|
|
196
324
|
} else if (body instanceof Uint8Array) {
|
|
197
325
|
payload = Buffer.from(body);
|
|
326
|
+
} else if (body instanceof ArrayBuffer) {
|
|
327
|
+
payload = Buffer.from(new Uint8Array(body));
|
|
198
328
|
} else if (body !== null && body !== undefined) {
|
|
199
|
-
reject(new TypeError("nodeDial only supports string/Buffer/Uint8Array bodies"));
|
|
329
|
+
reject(new TypeError("nodeDial only supports string/Buffer/Uint8Array/ArrayBuffer bodies"));
|
|
200
330
|
return;
|
|
201
331
|
}
|
|
202
332
|
if (payload !== undefined) headers["content-length"] = String(payload.byteLength);
|
|
@@ -205,20 +335,30 @@ function nodeDial(url: URL, init: RequestInit): Promise<Response> {
|
|
|
205
335
|
{
|
|
206
336
|
method: (init.method ?? "GET").toUpperCase(),
|
|
207
337
|
headers,
|
|
338
|
+
...(agent === undefined ? {} : { agent }),
|
|
208
339
|
...(init.signal === undefined ? {} : { signal: init.signal as AbortSignal }),
|
|
209
340
|
},
|
|
210
341
|
(res) => {
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
)
|
|
342
|
+
// The response body is bridged into a web stream so callers can
|
|
343
|
+
// consume it incrementally (SSE chat streams) instead of buffering
|
|
344
|
+
// the entire payload before resolving.
|
|
345
|
+
const bodyStream = new ReadableStream<Uint8Array>({
|
|
346
|
+
start(controller) {
|
|
347
|
+
res.on("data", (c: Buffer) => controller.enqueue(new Uint8Array(c)));
|
|
348
|
+
res.on("end", () => controller.close());
|
|
349
|
+
res.on("error", (err: Error) => controller.error(err));
|
|
350
|
+
},
|
|
351
|
+
cancel() {
|
|
352
|
+
res.destroy();
|
|
353
|
+
},
|
|
221
354
|
});
|
|
355
|
+
resolve(
|
|
356
|
+
new Response(bodyStream, {
|
|
357
|
+
status: res.statusCode ?? 200,
|
|
358
|
+
...(res.statusMessage === undefined ? {} : { statusText: res.statusMessage }),
|
|
359
|
+
headers: res.headers as unknown as HeadersInit,
|
|
360
|
+
}),
|
|
361
|
+
);
|
|
222
362
|
},
|
|
223
363
|
);
|
|
224
364
|
req.on("error", reject);
|
|
@@ -304,15 +444,41 @@ export function createValidatedFetch(options: ValidatedFetchOptions = {}): Valid
|
|
|
304
444
|
} else {
|
|
305
445
|
const method = (requestInit.method ?? "GET").toUpperCase();
|
|
306
446
|
const hasBody = requestInit.body !== undefined && requestInit.body !== null;
|
|
307
|
-
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
|
|
447
|
+
const profile = tlsProfileOf(options.tlsProfile ?? "default");
|
|
448
|
+
// A TLS profile request is a *custom ClientHello* dial: it goes
|
|
449
|
+
// through a Node https agent configured with the profile's OpenSSL
|
|
450
|
+
// options, so the wire fingerprint matches the impersonated client.
|
|
451
|
+
// This path is direct-only (never a pool/relay dial) and rejects
|
|
452
|
+
// bodies on GET/HEAD like the runtime fetch does — that shape is
|
|
453
|
+
// still handled by `nodeDial` below.
|
|
454
|
+
if (profile !== undefined && !socksAgent && configuredAgent === undefined) {
|
|
455
|
+
response = await nodeDial(url, requestInit, createProfileAgent(profile, options.ca));
|
|
456
|
+
} else if ((method === "GET" || method === "HEAD") && hasBody && !socksAgent && options.fetchFn === undefined) {
|
|
457
|
+
// The runtime fetch cannot carry a body on GET/HEAD, but Qoder's
|
|
458
|
+
// model-list endpoint requires a signed GET body. When no pool/relay
|
|
459
|
+
// owns the dial and no injected test fetch is present, dial through the
|
|
460
|
+
// Node http(s) client (post SSRF validation). Every other case keeps
|
|
461
|
+
// the existing fetch path.
|
|
313
462
|
response = await nodeDial(url, requestInit);
|
|
314
463
|
} else {
|
|
315
|
-
|
|
464
|
+
try {
|
|
465
|
+
response = await dial(url, requestInit, fetchFn, proxyUrl, socksAgent ? socksDispatcherFor(socksAgent) : undefined, socksFetch);
|
|
466
|
+
} catch (error) {
|
|
467
|
+
// A pooled keep-alive socket that died before response headers is
|
|
468
|
+
// replayed once on a fresh socket. Only errors that *prove* the
|
|
469
|
+
// socket died qualify; a bare fetch failure (or an abort, or a
|
|
470
|
+
// locked body) is never replayed — the upstream may already hold
|
|
471
|
+
// that request and a replay would deliver it twice.
|
|
472
|
+
if (
|
|
473
|
+
!requestInit.signal?.aborted &&
|
|
474
|
+
isBodyReplaySafe(requestInit.body) &&
|
|
475
|
+
isStaleSocketError(error)
|
|
476
|
+
) {
|
|
477
|
+
response = await dial(url, requestInit, fetchFn, proxyUrl, socksAgent ? socksDispatcherFor(socksAgent) : undefined, socksFetch, true);
|
|
478
|
+
} else {
|
|
479
|
+
throw error;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
316
482
|
}
|
|
317
483
|
}
|
|
318
484
|
if (response.status < 300 || response.status >= 400) {
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TLS client-profile registry — "ClientHello/custom" egress support.
|
|
3
|
+
*
|
|
4
|
+
* The runtime's own TLS (Bun/BoringSSL) has a fixed ClientHello shape, which
|
|
5
|
+
* is fine when the client we impersonate is itself Bun — Qoder's desktop
|
|
6
|
+
* worker runs on Bun and its captured ClientHello (2026-11-11, `api2.qoder.sh`
|
|
7
|
+
* hijack) matches Bun's default byte-for-byte. But when an upstream meters per
|
|
8
|
+
* transport fingerprint, the egress layer must be able to dial with a *chosen*
|
|
9
|
+
* TLS profile instead of the runtime default.
|
|
10
|
+
*
|
|
11
|
+
* Node's `tls`/`https` stack (OpenSSL) honors `ciphers`, `ciphersuites`,
|
|
12
|
+
* `ALPNProtocols`, and `minVersion`/`maxVersion`, and Bun's Node-compat layer
|
|
13
|
+
* forwards those options into its own handshake — verified live (2026-11-11):
|
|
14
|
+
* an `https.Agent({ ALPNProtocols: ["h2"] })` changes the ALPN extension, and
|
|
15
|
+
* `ciphers` changes the offered cipher list. So a profile is real: it is the
|
|
16
|
+
* set of OpenSSL options that reproduce a target client's ClientHello.
|
|
17
|
+
*
|
|
18
|
+
* Profiles are pure data + one helper. They never open sockets; the dial
|
|
19
|
+
* paths (`outbound-fetch.ts`, `pool/resolver.ts`) apply them.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* One re-playable TLS fingerprint.
|
|
24
|
+
*
|
|
25
|
+
* `ciphers` names the TLS 1.2 suite list in OpenSSL order (the order the
|
|
26
|
+
* ClientHello advertises); `ciphersuites` names the TLS 1.3 suites in order.
|
|
27
|
+
* `alpn` is the ALPN list in preference order. When a field is absent the
|
|
28
|
+
* dial path leaves the runtime default in place for that knob.
|
|
29
|
+
*/
|
|
30
|
+
export interface TlsProfileConfig {
|
|
31
|
+
/** Stable id used to look the profile up (e.g. `"qoder-app"`). */
|
|
32
|
+
readonly id: string;
|
|
33
|
+
/** Human description; not sent on the wire. */
|
|
34
|
+
readonly label: string;
|
|
35
|
+
/** OpenSSL TLS 1.2 cipher list, client order. */
|
|
36
|
+
readonly ciphers?: string;
|
|
37
|
+
/** OpenSSL TLS 1.3 cipher-suites list, client order. */
|
|
38
|
+
readonly ciphersuites?: string;
|
|
39
|
+
/** ALPN protocols in preference order. */
|
|
40
|
+
readonly alpn?: readonly string[];
|
|
41
|
+
/** OpenSSL/Node TLS version floor (`"TLSv1.2"` / `"TLSv1.3"`). */
|
|
42
|
+
readonly minVersion?: string;
|
|
43
|
+
/** OpenSSL/Node TLS version ceiling (`"TLSv1.3"`). */
|
|
44
|
+
readonly maxVersion?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Profile selector accepted by egress options. `"default"` = runtime default. */
|
|
48
|
+
export type TlsProfile = "default" | string;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The Qoder Desktop 0.5.7 chat fingerprint, from the live ClientHello capture
|
|
52
|
+
* (`documentation/research/qoder/2026-11-11-qoder-desktop-0.5.7-chat-surface-evidence.md`
|
|
53
|
+
* §9, `hijack/conn.log`): 18 suites in this exact order, ALPN `http/1.1`
|
|
54
|
+
* only, TLS 1.2–1.3. This is the profile to dial with when an upstream expects
|
|
55
|
+
* the Qoder desktop client's transport identity.
|
|
56
|
+
*/
|
|
57
|
+
export const QODER_APP_TLS_PROFILE: TlsProfileConfig = {
|
|
58
|
+
id: "qoder-app",
|
|
59
|
+
label: "Qoder Desktop 0.5.7 (Bun/1.4.x worker) COSY chat",
|
|
60
|
+
// TLS 1.3 suites, client order (capture: 1301 1302 1303).
|
|
61
|
+
ciphersuites:
|
|
62
|
+
"TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256",
|
|
63
|
+
// TLS 1.2 suites, client order (capture: c02f c02b c030 c02c c027 cca9
|
|
64
|
+
// cca8 c009 c013 c00a c014 009c 009d 002f 0035).
|
|
65
|
+
ciphers:
|
|
66
|
+
"ECDHE-RSA-AES128-GCM-SHA256:" +
|
|
67
|
+
"ECDHE-ECDSA-AES128-GCM-SHA256:" +
|
|
68
|
+
"ECDHE-RSA-AES256-GCM-SHA384:" +
|
|
69
|
+
"ECDHE-ECDSA-AES256-GCM-SHA384:" +
|
|
70
|
+
"ECDHE-RSA-AES128-CBC-SHA256:" +
|
|
71
|
+
"ECDHE-ECDSA-CHACHA20-POLY1305:" +
|
|
72
|
+
"ECDHE-RSA-CHACHA20-POLY1305:" +
|
|
73
|
+
"ECDHE-ECDSA-AES128-CBC-SHA:" +
|
|
74
|
+
"ECDHE-RSA-AES128-CBC-SHA:" +
|
|
75
|
+
"ECDHE-ECDSA-AES256-CBC-SHA:" +
|
|
76
|
+
"ECDHE-RSA-AES256-CBC-SHA:" +
|
|
77
|
+
"AES128-GCM-SHA256:" +
|
|
78
|
+
"AES256-GCM-SHA384:" +
|
|
79
|
+
"AES128-CBC-SHA:" +
|
|
80
|
+
"AES256-CBC-SHA",
|
|
81
|
+
alpn: ["http/1.1"],
|
|
82
|
+
minVersion: "TLSv1.2",
|
|
83
|
+
maxVersion: "TLSv1.3",
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** Built-in profiles by id. */
|
|
87
|
+
export const TLS_PROFILES: Readonly<Record<string, TlsProfileConfig>> = {
|
|
88
|
+
[QODER_APP_TLS_PROFILE.id]: QODER_APP_TLS_PROFILE,
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** Resolves a selector to a concrete profile; `"default"`/unknown → `undefined`. */
|
|
92
|
+
export function tlsProfileOf(profile: TlsProfile): TlsProfileConfig | undefined {
|
|
93
|
+
if (profile === undefined || profile === "default") return undefined;
|
|
94
|
+
return TLS_PROFILES[profile];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface NodeTlsOptions {
|
|
98
|
+
ciphers?: string;
|
|
99
|
+
ciphersuites?: string;
|
|
100
|
+
ALPNProtocols?: readonly string[];
|
|
101
|
+
minVersion?: string;
|
|
102
|
+
maxVersion?: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Maps a profile to the `tls`/`https` option set. `alpn` is included by
|
|
107
|
+
* default; pass `includeAlpn: false` for transports that fix their own ALPN
|
|
108
|
+
* (HTTP/2 bidi always negotiates `h2`).
|
|
109
|
+
*/
|
|
110
|
+
export function nodeTlsOptions(
|
|
111
|
+
profile: TlsProfileConfig,
|
|
112
|
+
includeAlpn = true,
|
|
113
|
+
): NodeTlsOptions {
|
|
114
|
+
const out: NodeTlsOptions = {};
|
|
115
|
+
if (profile.ciphers !== undefined) out.ciphers = profile.ciphers;
|
|
116
|
+
if (profile.ciphersuites !== undefined) out.ciphersuites = profile.ciphersuites;
|
|
117
|
+
if (includeAlpn && profile.alpn !== undefined && profile.alpn.length > 0) {
|
|
118
|
+
out.ALPNProtocols = [...profile.alpn];
|
|
119
|
+
}
|
|
120
|
+
if (profile.minVersion !== undefined) out.minVersion = profile.minVersion;
|
|
121
|
+
if (profile.maxVersion !== undefined) out.maxVersion = profile.maxVersion;
|
|
122
|
+
return out;
|
|
123
|
+
}
|