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
|
@@ -1,31 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `PoolAgentResolver` — builds, caches, and reaps the per-pool egress agents
|
|
3
|
-
* on demand
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* on demand.
|
|
4
|
+
*
|
|
5
|
+
* Lifecycle: `resolveAgent` single-flights agent construction per
|
|
6
|
+
* `tenantId:poolId` by caching the build *promise* — concurrent requests over
|
|
7
|
+
* the same pool share one build, and a failed build evicts itself so the next
|
|
8
|
+
* caller retries. Each agent holds persistent sockets (`keepAlive: true`) and
|
|
9
|
+
* carries the SSRF policy so every connect re-validates the proxy host.
|
|
6
10
|
*/
|
|
7
11
|
import type { CartethyiaDatabase } from "../../persistence/db-handle";
|
|
8
12
|
import {
|
|
9
13
|
isAddressAllowed,
|
|
10
14
|
resolveAllAddresses,
|
|
11
|
-
resolveAndValidateOnce,
|
|
12
|
-
type ValidatedDestination,
|
|
13
15
|
} from "../ssrf";
|
|
14
|
-
import { createValidatedFetch, type ValidatedFetch } from "../outbound-fetch";
|
|
15
|
-
import { GatewayError } from "../../protocol/gateway-error";
|
|
16
16
|
import type { SsrfPolicy } from "../../config/network";
|
|
17
17
|
import { log } from "../../observability/logger";
|
|
18
18
|
import type { TransportKind, PoolAgent } from "./agent";
|
|
19
19
|
import { isIP } from "node:net";
|
|
20
|
-
import * as tls from "node:tls";
|
|
21
|
-
import * as http2 from "node:http2";
|
|
22
|
-
import type { Duplex } from "node:stream";
|
|
23
|
-
import WebSocket, { type ClientOptions, type RawData } from "ws";
|
|
24
20
|
import { isProxyAgentPair, createHttpProxyAgent, createSocks5Agent, PoolBindingError } from "./agent";
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
27
|
-
import type { ProviderWebSocketSession, ValidatedOutboundWebSocket, ValidatedOutboundHttp2Bidi } from "../../providers/provider-contracts";
|
|
28
|
-
import type { Http2BidiEndReason, Http2BidiOpenOptions, ValidatedHttp2BidiStream } from "../http2-bidi";
|
|
21
|
+
import { parseAgentConfig, ProxyConfigError, type AgentConfig } from "./agent-config";
|
|
22
|
+
import type { ValidatedOutboundHttp2Bidi } from "../../providers/provider-contracts";
|
|
29
23
|
|
|
30
24
|
/** Idle time after which an unused pool agent is destroyed. */
|
|
31
25
|
const POOL_AGENT_IDLE_MS = 10 * 60_000;
|
|
@@ -33,22 +27,6 @@ const POOL_AGENT_IDLE_MS = 10 * 60_000;
|
|
|
33
27
|
const MAX_POOL_AGENTS = 1000;
|
|
34
28
|
/** Minimum gap between idle-time sweeps; the per-resolve scan is the hot cost. */
|
|
35
29
|
const POOL_AGENT_SWEEP_INTERVAL_MS = 30_000;
|
|
36
|
-
/** Time allowed for one HTTP/2 connection (TCP + TLS + preface) to establish. */
|
|
37
|
-
const HTTP2_CONNECT_TIMEOUT_MS = 15_000;
|
|
38
|
-
/** Read-idle bound after which a bidi stream is declared dead. */
|
|
39
|
-
const HTTP2_READ_IDLE_MS = 120_000;
|
|
40
|
-
/** Buffered inbound bytes before a bidi stream is refused as unbounded. */
|
|
41
|
-
const HTTP2_MAX_BUFFERED_BYTES = 64 * 1024 * 1024;
|
|
42
|
-
/**
|
|
43
|
-
* Session memory ceiling for one HTTP/2 session.
|
|
44
|
-
*
|
|
45
|
-
* Node and Bun count a session's unsent bytes against `maxSessionMemory`
|
|
46
|
-
* (10 MB by default) and, once over it, fail every new stream with
|
|
47
|
-
* `ENHANCE_YOUR_CALM`. A bidi Run request and its KV replies run to many
|
|
48
|
-
* megabytes and the caller holds those bytes anyway, so the default only adds
|
|
49
|
-
* a failure mode.
|
|
50
|
-
*/
|
|
51
|
-
const HTTP2_SESSION_MAX_MEMORY_MB = 256;
|
|
52
30
|
|
|
53
31
|
interface PoolAgentResolverDeps {
|
|
54
32
|
readonly ssrfPolicy?: SsrfPolicy;
|
|
@@ -118,7 +96,6 @@ function endpointHostname(kind: TransportKind, endpoint: string): string | undef
|
|
|
118
96
|
return undefined;
|
|
119
97
|
}
|
|
120
98
|
|
|
121
|
-
|
|
122
99
|
export interface NetworkPoolRow {
|
|
123
100
|
readonly id: string;
|
|
124
101
|
readonly kind: TransportKind;
|
|
@@ -132,27 +109,6 @@ export interface NetworkPoolLoader {
|
|
|
132
109
|
load(poolId: string): Promise<NetworkPoolRow | undefined>;
|
|
133
110
|
}
|
|
134
111
|
|
|
135
|
-
/**
|
|
136
|
-
* Per-pool egress agent cache.
|
|
137
|
-
*
|
|
138
|
-
* Lifecycle: `resolveAgent` single-flights agent construction per
|
|
139
|
-
* `tenantId:poolId` by caching the build *promise* — concurrent requests over
|
|
140
|
-
* the same pool share one build, and a failed build evicts itself so the next
|
|
141
|
-
* caller retries. Each agent holds persistent sockets (`keepAlive: true`) and
|
|
142
|
-
* carries the SSRF policy so every connect re-validates the proxy host.
|
|
143
|
-
*
|
|
144
|
-
* Reaping: entries idle beyond `POOL_AGENT_IDLE_MS` (10 min) are destroyed on
|
|
145
|
-
* the next resolve — bounded memory for pools deleted or disabled outside the
|
|
146
|
-
* mutation path, while hot entries are never destroyed mid-request. Also enforces
|
|
147
|
-
* a hard cardinality ceiling (MAX_POOL_AGENTS) by evicting the least-recently-used
|
|
148
|
-
* entries when the cache exceeds the cap.
|
|
149
|
-
* `releasePool` tears down eagerly when a pool is mutated; `closeAll` drains
|
|
150
|
-
* the cache at shutdown.
|
|
151
|
-
*
|
|
152
|
-
* Concurrency is deliberately *not* managed here: slot limits are owned by
|
|
153
|
-
* `NetworkPoolSelector` (local counters + Redis `proxy:inflight:*`), so
|
|
154
|
-
* agents stay pure connection factories.
|
|
155
|
-
*/
|
|
156
112
|
export class PoolAgentResolver {
|
|
157
113
|
private readonly agents = new Map<string, Promise<PoolAgent>>();
|
|
158
114
|
private readonly lastUsed = new Map<string, number>();
|
|
@@ -310,799 +266,4 @@ export class PoolAgentResolver {
|
|
|
310
266
|
|
|
311
267
|
|
|
312
268
|
}
|
|
313
|
-
|
|
314
|
-
function connectValidatedWebSocket(
|
|
315
|
-
url: URL,
|
|
316
|
-
headers: Readonly<Record<string, string>>,
|
|
317
|
-
signal: AbortSignal,
|
|
318
|
-
destination: ValidatedDestination,
|
|
319
|
-
agent?: PoolAgent,
|
|
320
|
-
): Promise<ProviderWebSocketSession> {
|
|
321
|
-
const targetUrl = new URL(url.toString());
|
|
322
|
-
targetUrl.protocol = "wss:";
|
|
323
|
-
targetUrl.port = "";
|
|
324
|
-
targetUrl.hash = "";
|
|
325
|
-
const connectionOptions: ClientOptions = {
|
|
326
|
-
handshakeTimeout: 15_000,
|
|
327
|
-
perMessageDeflate: false,
|
|
328
|
-
followRedirects: false,
|
|
329
|
-
headers: { ...headers },
|
|
330
|
-
...(agent === undefined ? {} : { agent: agentForWebSocket(agent) }),
|
|
331
|
-
...(agent === undefined
|
|
332
|
-
? {
|
|
333
|
-
createConnection: () => {
|
|
334
|
-
const socket = tls.connect({
|
|
335
|
-
host: destination.resolvedAddress,
|
|
336
|
-
port: 443,
|
|
337
|
-
servername: destination.hostname,
|
|
338
|
-
rejectUnauthorized: true,
|
|
339
|
-
ALPNProtocols: ["http/1.1"],
|
|
340
|
-
});
|
|
341
|
-
const onAbort = (): void => {
|
|
342
|
-
socket.destroy(new Error("connection aborted"));
|
|
343
|
-
};
|
|
344
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
345
|
-
socket.once("secureConnect", () => signal.removeEventListener("abort", onAbort));
|
|
346
|
-
return socket;
|
|
347
|
-
},
|
|
348
|
-
}
|
|
349
|
-
: {}),
|
|
350
|
-
};
|
|
351
|
-
return new Promise<ProviderWebSocketSession>((resolve, reject) => {
|
|
352
|
-
const socket = new WebSocket(targetUrl, connectionOptions);
|
|
353
|
-
let settled = false;
|
|
354
|
-
const timeout = setTimeout(() => {
|
|
355
|
-
if (settled) return;
|
|
356
|
-
settled = true;
|
|
357
|
-
socket.terminate();
|
|
358
|
-
reject(new GatewayError("transport_unavailable", 502, "WebSocket handshake timed out (request {requestId}). Some things just take longer 🐢"));
|
|
359
|
-
}, 15_000);
|
|
360
|
-
const abort = (): void => {
|
|
361
|
-
if (settled) return;
|
|
362
|
-
settled = true;
|
|
363
|
-
clearTimeout(timeout);
|
|
364
|
-
socket.terminate();
|
|
365
|
-
reject(new GatewayError("transport_closed", 499, "WebSocket connection was cancelled (request {requestId}). Send it again whenever ready 🙃"));
|
|
366
|
-
};
|
|
367
|
-
const onError = (): void => {
|
|
368
|
-
if (settled) return;
|
|
369
|
-
settled = true;
|
|
370
|
-
clearTimeout(timeout);
|
|
371
|
-
signal.removeEventListener("abort", abort);
|
|
372
|
-
reject(new GatewayError("transport_unavailable", 502, "WebSocket connection failed (request {requestId}). The connection ghosted us — come back in a moment 👻"));
|
|
373
|
-
};
|
|
374
|
-
socket.once("open", () => {
|
|
375
|
-
if (settled) return;
|
|
376
|
-
settled = true;
|
|
377
|
-
clearTimeout(timeout);
|
|
378
|
-
signal.removeEventListener("abort", abort);
|
|
379
|
-
resolve(new WsSession(socket, signal));
|
|
380
|
-
});
|
|
381
|
-
socket.once("error", onError);
|
|
382
|
-
socket.once("unexpected-response", onError);
|
|
383
|
-
signal.addEventListener("abort", abort, { once: true });
|
|
384
|
-
if (signal.aborted) abort();
|
|
385
|
-
});
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
function agentForWebSocket(agent: PoolAgent): import("http").Agent | import("https").Agent {
|
|
390
|
-
if (isProxyAgentPair(agent)) return agent.https;
|
|
391
|
-
if (agent instanceof HttpsAgent) return agent;
|
|
392
|
-
throw new GatewayError("proxy_pool_unhealthy", 503, "configured WebSocket proxy pool does not support WSS (request {requestId}). This bridge doesn't speak WSS — reconfigure the pool 🔧");
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
/**
|
|
396
|
-
* Rejects a bidi target that cannot be dialed safely.
|
|
397
|
-
*
|
|
398
|
-
* Every one of these is a property of the URL, not of the DNS answer, so it is
|
|
399
|
-
* checked before any resolution: a credential in the authority would be sent to
|
|
400
|
-
* whatever host resolves, a non-443 port is not the TLS endpoint the policy
|
|
401
|
-
* validated, and an IP literal bypasses the hostname the caller allowlists.
|
|
402
|
-
*/
|
|
403
|
-
function assertBidiTarget(url: URL): void {
|
|
404
|
-
if (url.protocol !== "https:") {
|
|
405
|
-
throw new GatewayError("invalid_request", 400, "validated HTTP/2 egress requires https (request {requestId}). Check the shape of what you sent — then resend 🪞");
|
|
406
|
-
}
|
|
407
|
-
if (url.username || url.password || url.hash) {
|
|
408
|
-
throw new GatewayError("invalid_request", 400, "invalid validated HTTP/2 target (request {requestId}). Check the shape of what you sent — then resend 🪞");
|
|
409
|
-
}
|
|
410
|
-
if (url.port !== "" && url.port !== "443") {
|
|
411
|
-
throw new GatewayError("invalid_request", 400, "validated HTTP/2 egress runs on port 443 (request {requestId}). Check the shape of what you sent — then resend 🪞");
|
|
412
|
-
}
|
|
413
|
-
const hostname = url.hostname;
|
|
414
|
-
if (hostname.length === 0) {
|
|
415
|
-
throw new GatewayError("invalid_request", 400, "HTTP/2 target hostname is required (request {requestId}). Check the shape of what you sent — then resend 🪞");
|
|
416
|
-
}
|
|
417
|
-
if (isIP(hostname) !== 0) {
|
|
418
|
-
throw new GatewayError("invalid_request", 400, "HTTP/2 target must be a hostname, not an IP literal (request {requestId}). Check the shape of what you sent — then resend 🪞");
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
/**
|
|
423
|
-
* Opens one TLS socket to a validated destination.
|
|
424
|
-
*
|
|
425
|
-
* Direct egress dials the pinned address with SNI set to the *validated
|
|
426
|
-
* hostname*: pinning is what stops a DNS rebind between check and connect, and
|
|
427
|
-
* preserving SNI is what makes the certificate validate against the name the
|
|
428
|
-
* caller asked for rather than against an address.
|
|
429
|
-
*/
|
|
430
|
-
function dialDirect(
|
|
431
|
-
destination: ValidatedDestination,
|
|
432
|
-
signal: AbortSignal,
|
|
433
|
-
): Promise<tls.TLSSocket> {
|
|
434
|
-
return new Promise<tls.TLSSocket>((resolve, reject) => {
|
|
435
|
-
const socket = tls.connect({
|
|
436
|
-
host: destination.resolvedAddress,
|
|
437
|
-
port: destination.port,
|
|
438
|
-
servername: destination.hostname,
|
|
439
|
-
rejectUnauthorized: true,
|
|
440
|
-
ALPNProtocols: ["h2"],
|
|
441
|
-
});
|
|
442
|
-
const cleanup = (): void => {
|
|
443
|
-
clearTimeout(timer);
|
|
444
|
-
signal.removeEventListener("abort", onAbort);
|
|
445
|
-
};
|
|
446
|
-
const onAbort = (): void => {
|
|
447
|
-
cleanup();
|
|
448
|
-
socket.destroy(new Error("HTTP/2 connection aborted"));
|
|
449
|
-
reject(new GatewayError("transport_closed", 499, "HTTP/2 connection was cancelled (request {requestId}). Send it again whenever ready 🙃"));
|
|
450
|
-
};
|
|
451
|
-
const timer = setTimeout(() => {
|
|
452
|
-
cleanup();
|
|
453
|
-
socket.destroy();
|
|
454
|
-
reject(
|
|
455
|
-
new GatewayError("transport_unavailable", 502, "HTTP/2 connection timed out (request {requestId}). Some things just take longer 🐢", {}, "network"),
|
|
456
|
-
);
|
|
457
|
-
}, HTTP2_CONNECT_TIMEOUT_MS);
|
|
458
|
-
socket.once("secureConnect", () => {
|
|
459
|
-
cleanup();
|
|
460
|
-
resolve(socket);
|
|
461
|
-
});
|
|
462
|
-
socket.once("error", (error) => {
|
|
463
|
-
cleanup();
|
|
464
|
-
reject(
|
|
465
|
-
new GatewayError(
|
|
466
|
-
"transport_unavailable",
|
|
467
|
-
502,
|
|
468
|
-
`HTTP/2 connection failed: ${error.message} (request {requestId}). The connection ghosted us — come back in a moment 👻`,
|
|
469
|
-
{},
|
|
470
|
-
"network",
|
|
471
|
-
),
|
|
472
|
-
);
|
|
473
|
-
});
|
|
474
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
475
|
-
if (signal.aborted) onAbort();
|
|
476
|
-
});
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
/**
|
|
480
|
-
* Opens one TLS socket through a pool agent's CONNECT/SOCKS tunnel.
|
|
481
|
-
*
|
|
482
|
-
* The agent owns the dial and re-validates the proxy host per connect, so the
|
|
483
|
-
* tunnel is the pool's egress; the target TLS handshake then runs inside it
|
|
484
|
-
* with SNI set to the validated target hostname.
|
|
485
|
-
*/
|
|
486
|
-
function dialThroughPool(
|
|
487
|
-
agent: PoolAgent,
|
|
488
|
-
hostname: string,
|
|
489
|
-
signal: AbortSignal,
|
|
490
|
-
): Promise<tls.TLSSocket> {
|
|
491
|
-
const pair = isProxyAgentPair(agent)
|
|
492
|
-
? agent
|
|
493
|
-
: agent instanceof HttpsAgent
|
|
494
|
-
? { https: agent }
|
|
495
|
-
: undefined;
|
|
496
|
-
if (pair === undefined) {
|
|
497
|
-
throw new GatewayError(
|
|
498
|
-
"proxy_pool_unhealthy",
|
|
499
|
-
503,
|
|
500
|
-
"configured proxy pool does not support HTTPS tunneling (request {requestId}). This bridge doesn't speak HTTPS tunnels — reconfigure the pool 🔧",
|
|
501
|
-
);
|
|
502
|
-
}
|
|
503
|
-
return new Promise<tls.TLSSocket>((resolve, reject) => {
|
|
504
|
-
pair.https.createConnection(
|
|
505
|
-
{ host: hostname, port: 443, servername: hostname },
|
|
506
|
-
(error: Error | null, socket: Duplex) => {
|
|
507
|
-
if (error !== null || socket === null) {
|
|
508
|
-
reject(
|
|
509
|
-
error instanceof PoolBindingError
|
|
510
|
-
? new GatewayError(
|
|
511
|
-
"proxy_pool_unhealthy",
|
|
512
|
-
503,
|
|
513
|
-
"Configured proxy pool could not be established (request {requestId}). This bridge looks shaky — repair or reroute 🔧",
|
|
514
|
-
{ reason: error.message.slice(0, 200) },
|
|
515
|
-
"network",
|
|
516
|
-
)
|
|
517
|
-
: new GatewayError(
|
|
518
|
-
"transport_unavailable",
|
|
519
|
-
502,
|
|
520
|
-
`HTTP/2 tunnel failed: ${error?.message ?? "no socket"} (request {requestId}). The connection ghosted us — come back in a moment 👻`,
|
|
521
|
-
{},
|
|
522
|
-
"network",
|
|
523
|
-
),
|
|
524
|
-
);
|
|
525
|
-
return;
|
|
526
|
-
}
|
|
527
|
-
const secured = tls.connect({
|
|
528
|
-
socket: socket as tls.TLSSocket,
|
|
529
|
-
servername: hostname,
|
|
530
|
-
rejectUnauthorized: true,
|
|
531
|
-
ALPNProtocols: ["h2"],
|
|
532
|
-
});
|
|
533
|
-
const cleanup = (): void => {
|
|
534
|
-
clearTimeout(timer);
|
|
535
|
-
signal.removeEventListener("abort", onAbort);
|
|
536
|
-
};
|
|
537
|
-
const onAbort = (): void => {
|
|
538
|
-
cleanup();
|
|
539
|
-
secured.destroy();
|
|
540
|
-
reject(new GatewayError("transport_closed", 499, "HTTP/2 connection was cancelled (request {requestId}). Send it again whenever ready 🙃"));
|
|
541
|
-
};
|
|
542
|
-
const timer = setTimeout(() => {
|
|
543
|
-
cleanup();
|
|
544
|
-
secured.destroy();
|
|
545
|
-
reject(
|
|
546
|
-
new GatewayError("transport_unavailable", 502, "HTTP/2 connection timed out (request {requestId}). Some things just take longer 🐢", {}, "network"),
|
|
547
|
-
);
|
|
548
|
-
}, HTTP2_CONNECT_TIMEOUT_MS);
|
|
549
|
-
secured.once("secureConnect", () => {
|
|
550
|
-
cleanup();
|
|
551
|
-
resolve(secured);
|
|
552
|
-
});
|
|
553
|
-
secured.once("error", (tlsError) => {
|
|
554
|
-
cleanup();
|
|
555
|
-
reject(
|
|
556
|
-
new GatewayError(
|
|
557
|
-
"transport_unavailable",
|
|
558
|
-
502,
|
|
559
|
-
`HTTP/2 TLS handshake failed: ${tlsError.message} (request {requestId}). The connection ghosted us — come back in a moment 👻`,
|
|
560
|
-
{},
|
|
561
|
-
"network",
|
|
562
|
-
),
|
|
563
|
-
);
|
|
564
|
-
});
|
|
565
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
566
|
-
if (signal.aborted) onAbort();
|
|
567
|
-
},
|
|
568
|
-
);
|
|
569
|
-
});
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
/**
|
|
573
|
-
* Wraps one connected HTTP/2 stream as a validated bidi stream.
|
|
574
|
-
*
|
|
575
|
-
* Inbound bytes are exposed as an async iterable whose consumer pace is the
|
|
576
|
-
* backpressure: a paused consumer leaves the data unread and lets the HTTP/2
|
|
577
|
-
* flow-control window close, which is what keeps a multi-megabyte Run from
|
|
578
|
-
* buffering entirely in memory.
|
|
579
|
-
*/
|
|
580
|
-
class Http2BidiSession implements ValidatedHttp2BidiStream {
|
|
581
|
-
private status = 0;
|
|
582
|
-
private responseHeaders: Record<string, string> = {};
|
|
583
|
-
private responseTrailers: Record<string, string> = {};
|
|
584
|
-
private readonly chunks: Uint8Array[] = [];
|
|
585
|
-
private readonly waiters: Array<{
|
|
586
|
-
resolve(value: IteratorResult<Uint8Array>): void;
|
|
587
|
-
reject(reason: unknown): void;
|
|
588
|
-
}> = [];
|
|
589
|
-
private inboundEnded = false;
|
|
590
|
-
private inboundFailure: Error | undefined;
|
|
591
|
-
private ended = false;
|
|
592
|
-
private terminalReason: Http2BidiEndReason | undefined;
|
|
593
|
-
private readonly terminalListeners = new Set<(reason: Http2BidiEndReason) => void>();
|
|
594
|
-
private readIdleTimer: ReturnType<typeof setTimeout> | undefined;
|
|
595
|
-
private bufferedBytes = 0;
|
|
596
|
-
|
|
597
|
-
constructor(
|
|
598
|
-
private readonly stream: http2.ClientHttp2Stream,
|
|
599
|
-
private readonly session: http2.ClientHttp2Session,
|
|
600
|
-
private readonly readIdleMs: number,
|
|
601
|
-
private readonly maxBufferedBytes: number,
|
|
602
|
-
) {
|
|
603
|
-
stream.on("response", (headers) => {
|
|
604
|
-
const status = headers[":status"];
|
|
605
|
-
this.status = typeof status === "string" ? Number(status) : (status ?? 0);
|
|
606
|
-
this.responseHeaders = flattenHeaders(headers);
|
|
607
|
-
});
|
|
608
|
-
stream.on("trailers", (headers) => {
|
|
609
|
-
this.responseTrailers = flattenHeaders(headers);
|
|
610
|
-
});
|
|
611
|
-
stream.on("data", (chunk: Buffer) => {
|
|
612
|
-
this.armReadIdle();
|
|
613
|
-
const bytes = new Uint8Array(chunk);
|
|
614
|
-
this.bufferedBytes += bytes.byteLength;
|
|
615
|
-
const waiter = this.waiters.shift();
|
|
616
|
-
if (waiter !== undefined) {
|
|
617
|
-
this.bufferedBytes -= bytes.byteLength;
|
|
618
|
-
waiter.resolve({ done: false, value: bytes });
|
|
619
|
-
return;
|
|
620
|
-
}
|
|
621
|
-
this.chunks.push(bytes);
|
|
622
|
-
// A stream that keeps arriving while nobody reads is unbounded growth,
|
|
623
|
-
// not backpressure: pause the socket rather than accumulating until the
|
|
624
|
-
// process is out of memory.
|
|
625
|
-
if (this.bufferedBytes > this.maxBufferedBytes) {
|
|
626
|
-
this.fail(
|
|
627
|
-
new GatewayError(
|
|
628
|
-
"platform_unavailable",
|
|
629
|
-
502,
|
|
630
|
-
"HTTP/2 response exceeded the buffer bound (request {requestId}). The upstream sent more than we can hold — retry 🌙",
|
|
631
|
-
{},
|
|
632
|
-
"network",
|
|
633
|
-
),
|
|
634
|
-
);
|
|
635
|
-
}
|
|
636
|
-
});
|
|
637
|
-
stream.on("end", () => this.finishInbound());
|
|
638
|
-
stream.on("error", (error) => this.fail(error));
|
|
639
|
-
stream.on("aborted", () =>
|
|
640
|
-
this.fail(new GatewayError("transport_unavailable", 502, "HTTP/2 stream aborted by remote (request {requestId}). The connection ghosted us — come back in a moment 👻", {}, "network")),
|
|
641
|
-
);
|
|
642
|
-
stream.on("close", () => {
|
|
643
|
-
this.finishInbound();
|
|
644
|
-
this.settleTerminal({ kind: "remote-clean-close" });
|
|
645
|
-
});
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
get response(): { status: number; headers: Readonly<Record<string, string>> } {
|
|
649
|
-
return { status: this.status, headers: this.responseHeaders };
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
get trailers(): Readonly<Record<string, string>> {
|
|
653
|
-
return this.responseTrailers;
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
get body(): AsyncIterable<Uint8Array> {
|
|
657
|
-
const self = this;
|
|
658
|
-
return {
|
|
659
|
-
[Symbol.asyncIterator](): AsyncIterator<Uint8Array> {
|
|
660
|
-
return {
|
|
661
|
-
next(): Promise<IteratorResult<Uint8Array>> {
|
|
662
|
-
return self.nextChunk();
|
|
663
|
-
},
|
|
664
|
-
return(): Promise<IteratorResult<Uint8Array>> {
|
|
665
|
-
self.abort();
|
|
666
|
-
return Promise.resolve({ done: true, value: undefined });
|
|
667
|
-
},
|
|
668
|
-
};
|
|
669
|
-
},
|
|
670
|
-
};
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
private nextChunk(): Promise<IteratorResult<Uint8Array>> {
|
|
674
|
-
const queued = this.chunks.shift();
|
|
675
|
-
if (queued !== undefined) {
|
|
676
|
-
this.bufferedBytes -= queued.byteLength;
|
|
677
|
-
return Promise.resolve({ done: false, value: queued });
|
|
678
|
-
}
|
|
679
|
-
if (this.inboundFailure !== undefined) return Promise.reject(this.inboundFailure);
|
|
680
|
-
if (this.inboundEnded) return Promise.resolve({ done: true, value: undefined });
|
|
681
|
-
return new Promise<IteratorResult<Uint8Array>>((resolve, reject) => {
|
|
682
|
-
this.waiters.push({ resolve, reject });
|
|
683
|
-
});
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
private armReadIdle(): void {
|
|
687
|
-
clearTimeout(this.readIdleTimer);
|
|
688
|
-
this.readIdleTimer = setTimeout(() => {
|
|
689
|
-
this.fail(
|
|
690
|
-
new GatewayError(
|
|
691
|
-
"transport_unavailable",
|
|
692
|
-
504,
|
|
693
|
-
`HTTP/2 stream read-idle timeout after ${this.readIdleMs}ms — connection presumed dead (request {requestId}). The connection ghosted us — come back in a moment 👻`,
|
|
694
|
-
{},
|
|
695
|
-
"network",
|
|
696
|
-
),
|
|
697
|
-
);
|
|
698
|
-
}, this.readIdleMs);
|
|
699
|
-
this.readIdleTimer.unref?.();
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
private finishInbound(): void {
|
|
703
|
-
clearTimeout(this.readIdleTimer);
|
|
704
|
-
this.readIdleTimer = undefined;
|
|
705
|
-
if (this.inboundFailure !== undefined || this.inboundEnded) return;
|
|
706
|
-
this.inboundEnded = true;
|
|
707
|
-
const waiter = this.waiters.shift();
|
|
708
|
-
waiter?.resolve({ done: true, value: undefined });
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
private fail(error: Error): void {
|
|
712
|
-
if (this.inboundFailure !== undefined) return;
|
|
713
|
-
this.inboundFailure = error;
|
|
714
|
-
this.chunks.length = 0;
|
|
715
|
-
for (const waiter of this.waiters.splice(0)) waiter.reject(error);
|
|
716
|
-
this.settleTerminal({ kind: "remote-error", error });
|
|
717
|
-
try {
|
|
718
|
-
this.stream.destroy(error);
|
|
719
|
-
} catch {
|
|
720
|
-
// The stream may already be closing.
|
|
721
|
-
}
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
private settleTerminal(reason: Http2BidiEndReason): void {
|
|
725
|
-
if (this.terminalReason !== undefined) return;
|
|
726
|
-
this.terminalReason = reason;
|
|
727
|
-
for (const listener of this.terminalListeners) {
|
|
728
|
-
try {
|
|
729
|
-
listener(reason);
|
|
730
|
-
} catch {
|
|
731
|
-
// An observer cannot destabilize teardown.
|
|
732
|
-
}
|
|
733
|
-
}
|
|
734
|
-
this.terminalListeners.clear();
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
onTerminal(listener: (reason: Http2BidiEndReason) => void): () => void {
|
|
738
|
-
this.terminalListeners.add(listener);
|
|
739
|
-
return () => this.terminalListeners.delete(listener);
|
|
740
|
-
}
|
|
741
|
-
|
|
742
|
-
write(chunk: Uint8Array): Promise<void> {
|
|
743
|
-
if (this.inboundFailure !== undefined) return Promise.reject(this.inboundFailure);
|
|
744
|
-
if (this.ended) return Promise.reject(new Error("HTTP/2 request side already ended"));
|
|
745
|
-
return new Promise<void>((resolve, reject) => {
|
|
746
|
-
const flushed = this.stream.write(chunk, (error) => {
|
|
747
|
-
if (error !== null && error !== undefined) reject(error);
|
|
748
|
-
});
|
|
749
|
-
if (flushed) {
|
|
750
|
-
resolve();
|
|
751
|
-
return;
|
|
752
|
-
}
|
|
753
|
-
// Backpressured: wait for drain. There is no deadline here — a stream
|
|
754
|
-
// error or close settles it, and the read-idle watchdog bounds a stream
|
|
755
|
-
// that stops answering.
|
|
756
|
-
this.stream.once("drain", () => resolve());
|
|
757
|
-
});
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
end(): void {
|
|
761
|
-
if (this.ended) return;
|
|
762
|
-
this.ended = true;
|
|
763
|
-
try {
|
|
764
|
-
this.stream.end();
|
|
765
|
-
} catch {
|
|
766
|
-
// Already closed by the peer or by abort.
|
|
767
|
-
}
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
abort(reason?: unknown): void {
|
|
771
|
-
if (this.ended && this.terminalReason !== undefined) return;
|
|
772
|
-
this.ended = true;
|
|
773
|
-
this.settleTerminal({ kind: "local-close" });
|
|
774
|
-
try {
|
|
775
|
-
this.stream.destroy(reason instanceof Error ? reason : undefined);
|
|
776
|
-
} catch {
|
|
777
|
-
// Already destroyed.
|
|
778
|
-
}
|
|
779
|
-
try {
|
|
780
|
-
this.session.close();
|
|
781
|
-
} catch {
|
|
782
|
-
// Already closing.
|
|
783
|
-
}
|
|
784
|
-
}
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
/** Flattens HTTP/2 header objects into a plain string map. */
|
|
788
|
-
function flattenHeaders(headers: Record<string, unknown>): Record<string, string> {
|
|
789
|
-
const out: Record<string, string> = {};
|
|
790
|
-
for (const [name, value] of Object.entries(headers)) {
|
|
791
|
-
if (value === undefined || value === null) continue;
|
|
792
|
-
if (Array.isArray(value)) out[name] = value.map((entry) => String(entry)).join(", ");
|
|
793
|
-
else out[name] = String(value);
|
|
794
|
-
}
|
|
795
|
-
return out;
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
/** Opens one validated bidi stream over an already-connected socket. */
|
|
799
|
-
function openBidiStream(
|
|
800
|
-
socket: tls.TLSSocket,
|
|
801
|
-
url: URL,
|
|
802
|
-
headers: Readonly<Record<string, string>>,
|
|
803
|
-
options: Http2BidiOpenOptions,
|
|
804
|
-
): Promise<ValidatedHttp2BidiStream> {
|
|
805
|
-
// `createConnection`, not the `socket` option: the socket is already a
|
|
806
|
-
// validated, ALPN-negotiated TLS connection, and handing it through
|
|
807
|
-
// `createConnection` is what makes the session adopt it as-is (Node's
|
|
808
|
-
// `socket` option applies its own TLS expectations on top).
|
|
809
|
-
const session = http2.connect(`https://${url.hostname}`, {
|
|
810
|
-
createConnection: () => socket,
|
|
811
|
-
maxSessionMemory: HTTP2_SESSION_MAX_MEMORY_MB,
|
|
812
|
-
});
|
|
813
|
-
return new Promise<ValidatedHttp2BidiStream>((resolve, reject) => {
|
|
814
|
-
const cleanup = (): void => {
|
|
815
|
-
clearTimeout(timer);
|
|
816
|
-
session.removeListener("error", onError);
|
|
817
|
-
session.removeListener("connect", onConnect);
|
|
818
|
-
};
|
|
819
|
-
const onError = (error: Error): void => {
|
|
820
|
-
cleanup();
|
|
821
|
-
try {
|
|
822
|
-
session.destroy();
|
|
823
|
-
} catch {
|
|
824
|
-
// Already destroying.
|
|
825
|
-
}
|
|
826
|
-
reject(
|
|
827
|
-
new GatewayError(
|
|
828
|
-
"transport_unavailable",
|
|
829
|
-
502,
|
|
830
|
-
`HTTP/2 session failed: ${error.message} (request {requestId}). The connection ghosted us — come back in a moment 👻`,
|
|
831
|
-
{},
|
|
832
|
-
"network",
|
|
833
|
-
),
|
|
834
|
-
);
|
|
835
|
-
};
|
|
836
|
-
const onConnect = (): void => {
|
|
837
|
-
cleanup();
|
|
838
|
-
const stream = session.request(
|
|
839
|
-
{
|
|
840
|
-
":method": "POST",
|
|
841
|
-
":path": `${url.pathname}${url.search}`,
|
|
842
|
-
":authority": url.hostname,
|
|
843
|
-
":scheme": "https",
|
|
844
|
-
...headers,
|
|
845
|
-
} as unknown as http2.OutgoingHttpHeaders,
|
|
846
|
-
{ endStream: false },
|
|
847
|
-
);
|
|
848
|
-
resolve(
|
|
849
|
-
new Http2BidiSession(
|
|
850
|
-
stream,
|
|
851
|
-
session,
|
|
852
|
-
options.readIdleMs ?? HTTP2_READ_IDLE_MS,
|
|
853
|
-
options.maxBufferedBytes ?? HTTP2_MAX_BUFFERED_BYTES,
|
|
854
|
-
),
|
|
855
|
-
);
|
|
856
|
-
};
|
|
857
|
-
const timer = setTimeout(() => {
|
|
858
|
-
cleanup();
|
|
859
|
-
try {
|
|
860
|
-
session.destroy();
|
|
861
|
-
} catch {
|
|
862
|
-
// Already destroying.
|
|
863
|
-
}
|
|
864
|
-
reject(
|
|
865
|
-
new GatewayError("transport_unavailable", 502, "HTTP/2 session timed out (request {requestId}). Some things just take longer 🐢", {}, "network"),
|
|
866
|
-
);
|
|
867
|
-
}, HTTP2_CONNECT_TIMEOUT_MS);
|
|
868
|
-
session.once("error", onError);
|
|
869
|
-
session.once("connect", onConnect);
|
|
870
|
-
});
|
|
871
|
-
}
|
|
872
|
-
|
|
873
|
-
class WsSession implements ProviderWebSocketSession {
|
|
874
|
-
readonly #socket: WebSocket;
|
|
875
|
-
readonly #signal: AbortSignal;
|
|
876
|
-
readonly #messages: string[] = [];
|
|
877
|
-
readonly #waiters: Array<{
|
|
878
|
-
resolve(value: string): void;
|
|
879
|
-
reject(reason: Error): void;
|
|
880
|
-
cleanup(): void;
|
|
881
|
-
}> = [];
|
|
882
|
-
#closed: Error | undefined;
|
|
883
|
-
|
|
884
|
-
constructor(socket: WebSocket, signal: AbortSignal) {
|
|
885
|
-
this.#socket = socket;
|
|
886
|
-
this.#signal = signal;
|
|
887
|
-
socket.on("message", (data: RawData, isBinary: boolean) => {
|
|
888
|
-
if (isBinary) {
|
|
889
|
-
this.#fail(new GatewayError("transport_unavailable", 502, "unexpected binary WebSocket frame (request {requestId}). The connection ghosted us — come back in a moment 👻"));
|
|
890
|
-
return;
|
|
891
|
-
}
|
|
892
|
-
const message = data.toString();
|
|
893
|
-
const waiter = this.#waiters.shift();
|
|
894
|
-
if (waiter) {
|
|
895
|
-
waiter.cleanup();
|
|
896
|
-
waiter.resolve(message);
|
|
897
|
-
} else {
|
|
898
|
-
this.#messages.push(message);
|
|
899
|
-
}
|
|
900
|
-
});
|
|
901
|
-
socket.on("close", () => this.#fail(new GatewayError("transport_closed", 502, "WebSocket connection closed (request {requestId}). The connection ghosted us — come back in a moment 👻")));
|
|
902
|
-
socket.on("error", () => this.#fail(new GatewayError("transport_unavailable", 502, "WebSocket connection failed (request {requestId}). The connection ghosted us — come back in a moment 👻")));
|
|
903
|
-
}
|
|
904
|
-
|
|
905
|
-
send(message: string): void {
|
|
906
|
-
if (this.#closed) throw this.#closed;
|
|
907
|
-
if (this.#socket.readyState !== WebSocket.OPEN) {
|
|
908
|
-
throw new GatewayError("transport_closed", 499, "WebSocket connection is not open (request {requestId}). Send it again whenever ready 🙃");
|
|
909
|
-
}
|
|
910
|
-
this.#socket.send(message, { binary: false });
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
receive(signal: AbortSignal): Promise<string> {
|
|
914
|
-
const queued = this.#messages.shift();
|
|
915
|
-
if (queued !== undefined) return Promise.resolve(queued);
|
|
916
|
-
if (this.#closed) return Promise.reject(this.#closed);
|
|
917
|
-
const combined = AbortSignal.any([this.#signal, signal]);
|
|
918
|
-
return new Promise<string>((resolve, reject) => {
|
|
919
|
-
const timeout = setTimeout(() => {
|
|
920
|
-
cleanup();
|
|
921
|
-
reject(new GatewayError("transport_unavailable", 504, "WebSocket receive timed out (request {requestId}). Some things just take longer 🐢"));
|
|
922
|
-
}, 45_000);
|
|
923
|
-
const onAbort = (): void => {
|
|
924
|
-
cleanup();
|
|
925
|
-
reject(new GatewayError("transport_closed", 499, "WebSocket receive was cancelled (request {requestId}). Send it again whenever ready 🙃"));
|
|
926
|
-
};
|
|
927
|
-
const cleanup = (): void => {
|
|
928
|
-
clearTimeout(timeout);
|
|
929
|
-
combined.removeEventListener("abort", onAbort);
|
|
930
|
-
};
|
|
931
|
-
combined.addEventListener("abort", onAbort, { once: true });
|
|
932
|
-
this.#waiters.push({ resolve, reject, cleanup });
|
|
933
|
-
if (combined.aborted) onAbort();
|
|
934
|
-
});
|
|
935
|
-
}
|
|
936
|
-
|
|
937
|
-
close(code?: number, reason?: string): void {
|
|
938
|
-
if (this.#socket.readyState === WebSocket.CLOSED) return;
|
|
939
|
-
this.#socket.close(code, reason);
|
|
940
|
-
}
|
|
941
|
-
|
|
942
|
-
#fail(error: Error): void {
|
|
943
|
-
if (this.#closed) return;
|
|
944
|
-
this.#closed = error;
|
|
945
|
-
for (const waiter of this.#waiters.splice(0)) {
|
|
946
|
-
waiter.cleanup();
|
|
947
|
-
waiter.reject(error);
|
|
948
|
-
}
|
|
949
|
-
}
|
|
950
|
-
}
|
|
951
|
-
export class ValidatedNetworkBindingFactory {
|
|
952
|
-
private readonly directFetch: ValidatedFetch;
|
|
953
|
-
/**
|
|
954
|
-
* One `ValidatedFetch` per resolved pool agent. Building the closure (dialer,
|
|
955
|
-
* relay setup, redirect loop) on every request through a pool was pure
|
|
956
|
-
* churn; keying by the agent object means a released agent is naturally
|
|
957
|
-
* collected (new builds mint a new agent) so no explicit invalidation is
|
|
958
|
-
* needed.
|
|
959
|
-
*/
|
|
960
|
-
private readonly pooledFetchCache = new WeakMap<PoolAgent, ValidatedFetch>();
|
|
961
|
-
|
|
962
|
-
constructor(
|
|
963
|
-
private readonly policy: SsrfPolicy = {},
|
|
964
|
-
fetchFn?: typeof fetch,
|
|
965
|
-
private readonly poolResolver?: PoolAgentResolver,
|
|
966
|
-
) {
|
|
967
|
-
this.directFetch = createValidatedFetch({ policy, ...(fetchFn ? { fetchFn } : {}) });
|
|
968
|
-
}
|
|
969
|
-
|
|
970
|
-
/**
|
|
971
|
-
* One-shot DNS resolution + SSRF validation for direct egress targets:
|
|
972
|
-
* every resolved address (all families, not just the first answer) must
|
|
973
|
-
* pass the SSRF policy, and the validated destination pins the address so
|
|
974
|
-
* the subsequent socket cannot be re-pointed at a private/link-local host
|
|
975
|
-
* by a DNS rebind between check and connect.
|
|
976
|
-
*/
|
|
977
|
-
resolve(hostname: string, signal: AbortSignal, port = 443): Promise<ValidatedDestination> {
|
|
978
|
-
return resolveAndValidateOnce(hostname, this.policy, signal, port);
|
|
979
|
-
}
|
|
980
|
-
|
|
981
|
-
/**
|
|
982
|
-
* Without `networkPoolId` (or when no pool resolver is configured), dispatch
|
|
983
|
-
* through validated direct egress. With one, route through that pool's
|
|
984
|
-
* validated agent. A pool configuration failure is returned to the caller;
|
|
985
|
-
* configured egress is never silently replaced with direct traffic.
|
|
986
|
-
*/
|
|
987
|
-
fetch(networkPoolId?: string, tenantId?: string): ValidatedFetch {
|
|
988
|
-
if (!networkPoolId || !this.poolResolver || !tenantId) return this.directFetch;
|
|
989
|
-
const { poolResolver, policy } = this;
|
|
990
|
-
return async (input, init) => {
|
|
991
|
-
try {
|
|
992
|
-
const agent = await poolResolver.resolveAgent(networkPoolId, tenantId);
|
|
993
|
-
let validated = this.pooledFetchCache.get(agent);
|
|
994
|
-
if (validated === undefined) {
|
|
995
|
-
validated = createValidatedFetch({ policy, agent });
|
|
996
|
-
this.pooledFetchCache.set(agent, validated);
|
|
997
|
-
}
|
|
998
|
-
return await validated(input, init);
|
|
999
|
-
} catch (error) {
|
|
1000
|
-
if (error instanceof PoolBindingError) {
|
|
1001
|
-
throw new GatewayError(
|
|
1002
|
-
"proxy_pool_unhealthy",
|
|
1003
|
-
503,
|
|
1004
|
-
"Configured proxy pool could not be established (request {requestId}). This bridge looks shaky — repair or reroute 🔧",
|
|
1005
|
-
{ poolId: networkPoolId, reason: error.message.slice(0, 200) },
|
|
1006
|
-
"network",
|
|
1007
|
-
);
|
|
1008
|
-
}
|
|
1009
|
-
throw error;
|
|
1010
|
-
}
|
|
1011
|
-
};
|
|
1012
|
-
}
|
|
1013
|
-
webSocket(networkPoolId?: string, tenantId?: string): ValidatedOutboundWebSocket {
|
|
1014
|
-
return async (url, headers, signal) => {
|
|
1015
|
-
if (url.protocol !== "wss:") {
|
|
1016
|
-
throw new GatewayError("invalid_request", 400, "validated WebSocket egress requires wss (request {requestId}). Check the shape of what you sent — then resend 🪞");
|
|
1017
|
-
}
|
|
1018
|
-
if (url.username || url.password || url.hash || (url.port !== "" && url.port !== "443")) {
|
|
1019
|
-
throw new GatewayError("invalid_request", 400, "invalid validated WebSocket target (request {requestId}). Check the shape of what you sent — then resend 🪞");
|
|
1020
|
-
}
|
|
1021
|
-
const hostname = url.hostname;
|
|
1022
|
-
if (!hostname) throw new GatewayError("invalid_request", 400, "WebSocket target hostname is required (request {requestId}). Check the shape of what you sent — then resend 🪞");
|
|
1023
|
-
const destination = await this.resolve(hostname, signal, 443);
|
|
1024
|
-
if (!isAddressAllowed(destination.resolvedAddress, this.policy)) {
|
|
1025
|
-
throw new GatewayError("invalid_request", 400, "unsafe upstream address rejected (request {requestId}). Check the shape of what you sent — then resend 🪞");
|
|
1026
|
-
}
|
|
1027
|
-
let agent: PoolAgent | undefined;
|
|
1028
|
-
if (networkPoolId || tenantId) {
|
|
1029
|
-
if (!networkPoolId || !tenantId || !this.poolResolver) {
|
|
1030
|
-
throw new GatewayError("proxy_pool_unhealthy", 503, "configured WebSocket proxy pool could not be established (request {requestId}). This bridge looks shaky — repair or reroute 🔧");
|
|
1031
|
-
}
|
|
1032
|
-
try {
|
|
1033
|
-
agent = await this.poolResolver.resolveAgent(networkPoolId, tenantId);
|
|
1034
|
-
} catch (error) {
|
|
1035
|
-
if (error instanceof PoolBindingError) {
|
|
1036
|
-
throw new GatewayError("proxy_pool_unhealthy", 503, "configured WebSocket proxy pool could not be established (request {requestId}). This bridge looks shaky — repair or reroute 🔧");
|
|
1037
|
-
}
|
|
1038
|
-
throw error;
|
|
1039
|
-
}
|
|
1040
|
-
}
|
|
1041
|
-
return connectValidatedWebSocket(url, headers, signal, destination, agent);
|
|
1042
|
-
};
|
|
1043
|
-
}
|
|
1044
|
-
|
|
1045
|
-
/**
|
|
1046
|
-
* Validated bidirectional HTTP/2 egress for one writable streaming request.
|
|
1047
|
-
*
|
|
1048
|
-
* Direct egress resolves and validates *every* DNS answer for the hostname,
|
|
1049
|
-
* pins the chosen public address, and keeps TLS SNI pointed at the validated
|
|
1050
|
-
* hostname — so a rebind between check and connect cannot retarget the socket,
|
|
1051
|
-
* and the certificate still validates against the name the caller asked for.
|
|
1052
|
-
* Pooled egress reuses the selected tenant pool's CONNECT/SOCKS tunnel and
|
|
1053
|
-
* accounts the tunnel's bytes to that pool, exactly as `fetch` does.
|
|
1054
|
-
*
|
|
1055
|
-
* Credentials in the authority, fragments, non-443 ports, IP literals, and
|
|
1056
|
-
* hostnames outside `allowHostname` are refused before anything is dialed: a
|
|
1057
|
-
* bidi stream carries the account credential for its whole lifetime, so the
|
|
1058
|
-
* target is checked once here rather than trusted to each caller.
|
|
1059
|
-
*/
|
|
1060
|
-
http2Bidi(networkPoolId?: string, tenantId?: string): ValidatedOutboundHttp2Bidi {
|
|
1061
|
-
return async (url, headers, options) => {
|
|
1062
|
-
assertBidiTarget(url);
|
|
1063
|
-
const { hostname } = url;
|
|
1064
|
-
if (options?.allowHostname !== undefined && !options.allowHostname(hostname)) {
|
|
1065
|
-
throw new GatewayError("invalid_request", 400, "HTTP/2 target host is not allowlisted (request {requestId}). All roads blocked — time to find a new route 🗺️");
|
|
1066
|
-
}
|
|
1067
|
-
const signal = options?.signal ?? AbortSignal.timeout(HTTP2_CONNECT_TIMEOUT_MS);
|
|
1068
|
-
// Validation runs on every new connection, not once per capability: a
|
|
1069
|
-
// long-lived Run is re-opened after a session dies, and a hostname that
|
|
1070
|
-
// resolved safely the first time is not safe the twentieth.
|
|
1071
|
-
const destination = await this.resolve(hostname, signal, 443);
|
|
1072
|
-
if (!isAddressAllowed(destination.resolvedAddress, this.policy)) {
|
|
1073
|
-
throw new GatewayError("invalid_request", 400, "unsafe upstream address rejected (request {requestId}). All roads blocked — time to find a new route 🗺️");
|
|
1074
|
-
}
|
|
1075
|
-
// Mirrors `fetch`: no pool selected (or none configured) means validated
|
|
1076
|
-
// *direct* egress, which is already resolved and SSRF-checked above.
|
|
1077
|
-
// Requiring a pool whenever a tenant id was supplied made bidi-only
|
|
1078
|
-
// providers (Cursor) unreachable on every install without a pool, even
|
|
1079
|
-
// though the equivalent `fetch` path dials direct there.
|
|
1080
|
-
if (networkPoolId !== undefined || tenantId !== undefined) {
|
|
1081
|
-
if (networkPoolId === undefined || tenantId === undefined || this.poolResolver === undefined) {
|
|
1082
|
-
const socket = await dialDirect(destination, signal);
|
|
1083
|
-
return openBidiStream(socket, url, headers, options ?? {});
|
|
1084
|
-
}
|
|
1085
|
-
let agent: PoolAgent;
|
|
1086
|
-
try {
|
|
1087
|
-
agent = await this.poolResolver.resolveAgent(networkPoolId, tenantId);
|
|
1088
|
-
} catch (error) {
|
|
1089
|
-
if (error instanceof PoolBindingError) {
|
|
1090
|
-
throw new GatewayError(
|
|
1091
|
-
"proxy_pool_unhealthy",
|
|
1092
|
-
503,
|
|
1093
|
-
"Configured proxy pool could not be established (request {requestId}). This bridge looks shaky — repair or reroute 🔧",
|
|
1094
|
-
{ poolId: networkPoolId, reason: error.message.slice(0, 200) },
|
|
1095
|
-
"network",
|
|
1096
|
-
);
|
|
1097
|
-
}
|
|
1098
|
-
throw error;
|
|
1099
|
-
}
|
|
1100
|
-
const socket = await dialThroughPool(agent, hostname, signal);
|
|
1101
|
-
return openBidiStream(socket, url, headers, options ?? {});
|
|
1102
|
-
}
|
|
1103
|
-
const socket = await dialDirect(destination, signal);
|
|
1104
|
-
return openBidiStream(socket, url, headers, options ?? {});
|
|
1105
|
-
};
|
|
1106
|
-
}
|
|
1107
|
-
}
|
|
1108
|
-
|
|
269
|
+
export type { ValidatedOutboundHttp2Bidi };
|