cartethyia 2.8.0 → 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 +3 -3
- package/dist/dashboard/assets/{BannedPage-Cc0vKpMG.js → BannedPage-Dq530awD.js} +1 -1
- package/dist/dashboard/assets/BannedPage-Dq530awD.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolDetailPage-Bqj2aa1T.js → CliToolDetailPage-DZzaS-7S.js} +1 -1
- package/dist/dashboard/assets/CliToolDetailPage-DZzaS-7S.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolsPage-CXsaOAO2.js → CliToolsPage-DaZZ3cqP.js} +1 -1
- package/dist/dashboard/assets/CliToolsPage-DaZZ3cqP.js.gz +0 -0
- package/dist/dashboard/assets/{CombosPage-BeLpJ4ma.js → CombosPage-BlC_xdkX.js} +1 -1
- package/dist/dashboard/assets/CombosPage-BlC_xdkX.js.gz +0 -0
- package/dist/dashboard/assets/{ConsoleLogPage-DnV0uZ3m.js → ConsoleLogPage-D-rqeVtG.js} +1 -1
- package/dist/dashboard/assets/ConsoleLogPage-D-rqeVtG.js.gz +0 -0
- package/dist/dashboard/assets/{CustomizationPage-CBxAjo63.js → CustomizationPage-7xT5yg2E.js} +1 -1
- package/dist/dashboard/assets/CustomizationPage-7xT5yg2E.js.gz +0 -0
- package/dist/dashboard/assets/{HealthEventsModal-B5yEE0k6.js → HealthEventsModal-Clq8E9cW.js} +1 -1
- package/dist/dashboard/assets/HealthEventsModal-Clq8E9cW.js.gz +0 -0
- package/dist/dashboard/assets/{InjectSystemPromptPanel-BWFLECmz.js → InjectSystemPromptPanel-6Teoq9Se.js} +1 -1
- package/dist/dashboard/assets/InjectSystemPromptPanel-6Teoq9Se.js.gz +0 -0
- package/dist/dashboard/assets/{LoginPage-CgoPc5B9.js → LoginPage-CZuI0MAR.js} +1 -1
- package/dist/dashboard/assets/LoginPage-CZuI0MAR.js.gz +0 -0
- package/dist/dashboard/assets/{ModelPicker-D9bFQern.js → ModelPicker-C1hNTlkr.js} +1 -1
- package/dist/dashboard/assets/ModelPicker-C1hNTlkr.js.gz +0 -0
- package/dist/dashboard/assets/{OverviewPage-B_p4tL8k.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-D_0PiZtg.js → ProviderIcon-Ddhm1InL.js} +1 -1
- package/dist/dashboard/assets/ProviderIcon-Ddhm1InL.js.gz +0 -0
- package/dist/dashboard/assets/{ProvidersPage-CLI2bJTK.js → ProvidersPage-C3fYuu6i.js} +1 -1
- 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-DA-a1iSN.js → QuotaPage-D1RV_2BX.js} +1 -1
- package/dist/dashboard/assets/QuotaPage-D1RV_2BX.js.gz +0 -0
- package/dist/dashboard/assets/{SettingsPage-CctTga2C.js → SettingsPage-BzZAvYSX.js} +1 -1
- package/dist/dashboard/assets/SettingsPage-BzZAvYSX.js.gz +0 -0
- package/dist/dashboard/assets/{SetupPage-febLU4r1.js → SetupPage--s-EfEIL.js} +1 -1
- package/dist/dashboard/assets/SetupPage--s-EfEIL.js.gz +0 -0
- package/dist/dashboard/assets/{StudioPage-M6fvR5Wz.js → StudioPage-BjXFGSK4.js} +1 -1
- package/dist/dashboard/assets/StudioPage-BjXFGSK4.js.gz +0 -0
- package/dist/dashboard/assets/{UsagePage-nyeuRqz9.js → UsagePage-CP8F8BN7.js} +1 -1
- package/dist/dashboard/assets/UsagePage-CP8F8BN7.js.gz +0 -0
- package/dist/dashboard/assets/{api-keys-BFOlvrJC.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-CqTLw5e7.js → assert-BMcj1xZE.js} +1 -1
- package/dist/dashboard/assets/assert-BMcj1xZE.js.gz +0 -0
- package/dist/dashboard/assets/{badge-C83EqLB0.js → badge-DkRUuVfz.js} +1 -1
- package/dist/dashboard/assets/badge-DkRUuVfz.js.gz +0 -0
- package/dist/dashboard/assets/{banner-D1xn7pIe.js → banner-CDjW841Q.js} +1 -1
- package/dist/dashboard/assets/banner-CDjW841Q.js.gz +0 -0
- package/dist/dashboard/assets/{card-DU3Lq5Lm.js → card-Bto5UlL9.js} +1 -1
- package/dist/dashboard/assets/card-Bto5UlL9.js.gz +0 -0
- package/dist/dashboard/assets/{cli-tools-Dw1U_drK.js → cli-tools-DaH6g9hL.js} +1 -1
- package/dist/dashboard/assets/cli-tools-DaH6g9hL.js.gz +0 -0
- package/dist/dashboard/assets/{clipboard-button-CEL5Sg_J.js → clipboard-button-BYKBCzjk.js} +1 -1
- package/dist/dashboard/assets/clipboard-button-BYKBCzjk.js.gz +0 -0
- package/dist/dashboard/assets/{dialog-BjFR5UHy.js → dialog-DL0Zftwv.js} +1 -1
- package/dist/dashboard/assets/dialog-DL0Zftwv.js.gz +0 -0
- package/dist/dashboard/assets/{drawer-HvBW0AjI.js → drawer-Dpx_UXaS.js} +1 -1
- package/dist/dashboard/assets/drawer-Dpx_UXaS.js.gz +0 -0
- package/dist/dashboard/assets/{entry-CECVELyy.js → entry-BV7-1NoM.js} +1 -1
- package/dist/dashboard/assets/entry-BV7-1NoM.js.gz +0 -0
- package/dist/dashboard/assets/{entry-DYYofQ7W.js → entry-CGvdNU6r.js} +1 -1
- package/dist/dashboard/assets/entry-CGvdNU6r.js.gz +0 -0
- package/dist/dashboard/assets/{entry-BSu3gFlt.js → entry-DScKYvZj.js} +3 -3
- package/dist/dashboard/assets/entry-DScKYvZj.js.gz +0 -0
- package/dist/dashboard/assets/{formatters-DLW4i8zK.js → formatters-BS3DZlZj.js} +1 -1
- package/dist/dashboard/assets/formatters-BS3DZlZj.js.gz +0 -0
- package/dist/dashboard/assets/{index-1-fCqe64.js → index-SQnvvTWX.js} +2 -2
- package/dist/dashboard/assets/index-SQnvvTWX.js.gz +0 -0
- package/dist/dashboard/assets/{inline-DGvU2oLK.js → inline-Dl-cuLK0.js} +1 -1
- package/dist/dashboard/assets/inline-Dl-cuLK0.js.gz +0 -0
- package/dist/dashboard/assets/{input-iNnO1X2K.js → input-Bfx0EzRc.js} +1 -1
- package/dist/dashboard/assets/input-Bfx0EzRc.js.gz +0 -0
- package/dist/dashboard/assets/{layout-0q8UGypb.js → layout-BxhbQ_JS.js} +1 -1
- package/dist/dashboard/assets/layout-BxhbQ_JS.js.gz +0 -0
- package/dist/dashboard/assets/{list-row-C0w2bQjJ.js → list-row-D49Y4ptn.js} +1 -1
- package/dist/dashboard/assets/list-row-D49Y4ptn.js.gz +0 -0
- package/dist/dashboard/assets/{live-M5aJ_xCa.js → live-CbVTdX9V.js} +1 -1
- package/dist/dashboard/assets/live-CbVTdX9V.js.gz +0 -0
- package/dist/dashboard/assets/{network-BKeQGT3K.js → network-JGvDveof.js} +1 -1
- package/dist/dashboard/assets/network-JGvDveof.js.gz +0 -0
- package/dist/dashboard/assets/{numbers-CivAk3da.js → numbers-Dw6kiew-.js} +1 -1
- package/dist/dashboard/assets/numbers-Dw6kiew-.js.gz +0 -0
- package/dist/dashboard/assets/{pagination-CpzvI5ep.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/{routing-BFQX2VBc.js → routing-8jZr-3WE.js} +1 -1
- package/dist/dashboard/assets/routing-8jZr-3WE.js.gz +0 -0
- package/dist/dashboard/assets/{select-CXbck0jG.js → select-oO1FxMVb.js} +1 -1
- package/dist/dashboard/assets/select-oO1FxMVb.js.gz +0 -0
- package/dist/dashboard/assets/{settings-C_XvMHh9.js → settings-CLr3kdIx.js} +1 -1
- package/dist/dashboard/assets/settings-CLr3kdIx.js.gz +0 -0
- package/dist/dashboard/assets/{stack-DggRiCxE.js → stack-Y3pDteZr.js} +1 -1
- package/dist/dashboard/assets/stack-Y3pDteZr.js.gz +0 -0
- package/dist/dashboard/assets/{state-ClFzfbYp.js → state-CArX_Iak.js} +1 -1
- package/dist/dashboard/assets/state-CArX_Iak.js.gz +0 -0
- package/dist/dashboard/assets/{switch-CTIFxKqS.js → switch-DwlwmDO-.js} +1 -1
- package/dist/dashboard/assets/switch-DwlwmDO-.js.gz +0 -0
- package/dist/dashboard/assets/{useMutation-BClG3yWj.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 +51 -51
- package/migrations/full-postgres/0047_add_provider_device_flow_proxy.sql +17 -0
- package/migrations/lite-sqlite3/0047_add_provider_device_flow_proxy.sql +19 -0
- package/package.json +1 -1
- package/src/boot/app.ts +1 -1
- package/src/boot/dependencies.ts +4 -6
- package/src/config/providers.ts +6 -5
- package/src/config/spec.ts +8 -8
- package/src/console/backup/nine-router.ts +71 -10
- package/src/console/backup/service.ts +8 -1
- package/src/console/console-router.ts +1 -1
- package/src/console/domain-registration.ts +7 -2
- package/src/console/inferhub/client.ts +1 -1
- package/src/console/observability/live.ts +2 -2
- 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 +2 -2
- package/src/console/providers/catalog/store.ts +1914 -1907
- 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-tenant.ts +5 -5
- package/src/console/quota/account-test-egress.ts +2 -2
- package/src/console/quota/daily-growth-job.ts +1 -1
- package/src/console/routing/pools/contracts.ts +1 -1
- package/src/console/routing/pools/operations.ts +1 -1
- 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/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/{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} +7 -7
- 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 +2 -2
- package/src/network/ssrf.ts +1 -1
- package/src/network/{retry.ts → util/retry.ts} +1 -1
- package/src/observability/telemetry-buffer.ts +1 -1
- package/src/persistence/schema.ts +9 -0
- package/src/persistence/ttl-cache.ts +1 -1
- package/src/protocol/codecs.ts +58 -1
- package/src/protocol/request/messages.ts +31 -18
- package/src/protocol/response/messages.ts +34 -7
- 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 +5 -0
- package/src/providers/authentication/oauth-client.ts +40 -6
- package/src/providers/authentication/oauth-device-flow.ts +7 -3
- package/src/providers/authentication/oauth-flow-store.ts +82 -1
- package/src/providers/authentication/oauth-refresh-service.ts +766 -739
- package/src/providers/default-registry.ts +76 -0
- package/src/providers/discovery/discovery-types.ts +7 -0
- package/src/providers/discovery/probe-phases.ts +6 -2
- package/src/providers/discovery/probing-service.ts +7 -0
- package/src/providers/integrations/antigravity/antigravity-protocol.ts +11 -0
- package/src/providers/integrations/buddy/account.ts +969 -0
- package/src/providers/integrations/buddy/capabilities.ts +10 -10
- package/src/providers/integrations/buddy/{buddy-catalog-shared.ts → catalog.ts} +198 -136
- package/src/providers/integrations/buddy/{buddy-chat-shared.ts → chat.ts} +5 -5
- package/src/providers/integrations/buddy/codebuddy/index.ts +429 -0
- package/src/providers/integrations/buddy/codebuddy/shared.ts +4 -4
- package/src/providers/integrations/buddy/{buddy-identity.ts → headers.ts} +138 -140
- 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/cline/cline-oauth.ts +10 -5
- package/src/providers/integrations/cline/cline.ts +3 -4
- 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/cursor/cursor-auth.ts +1 -1
- 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-shared.ts +1 -1
- package/src/providers/integrations/cursor/cursor.ts +485 -70
- 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/factory/factory-oauth.ts +16 -7
- package/src/providers/integrations/freebuff/freebuff.ts +2 -2
- 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 +4 -4
- package/src/providers/integrations/hermes/hermes-oauth.ts +3 -3
- package/src/providers/integrations/hermes/hermes.ts +34 -5
- 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-oauth.ts +2 -2
- package/src/providers/integrations/muse/muse-oauth.ts +5 -5
- package/src/providers/integrations/opencode/fingerprint.ts +1 -1
- package/src/providers/integrations/openrouter/provider.ts +1 -1
- package/src/providers/integrations/perch/perch-protocol.ts +0 -3
- package/src/providers/integrations/perch/perch.ts +3 -3
- package/src/providers/integrations/qoder/claim-qoder-credits.ts +1 -1
- package/src/providers/integrations/qoder/qoder-adapter.ts +19 -113
- package/src/providers/integrations/qoder/qoder-auth.ts +8 -1
- package/src/providers/integrations/qoder/qoder-catalog.ts +1 -1
- package/src/providers/integrations/qoder/qoder-chat.ts +25 -8
- package/src/providers/integrations/qoder/qoder-checkin.ts +1 -1
- 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 -25
- package/src/providers/integrations/sensenova/provider.ts +144 -0
- package/src/providers/integrations/trae/trae-quota.ts +15 -1
- 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/zro/zro.ts +2 -1
- package/src/providers/operations/account-refresh-status.ts +14 -0
- package/src/providers/operations/client-versions.ts +95 -30
- package/src/providers/operations/oauth-refresh-lead.ts +46 -22
- package/src/providers/operations/provider-caches.ts +1 -1
- package/src/providers/operations/provider-credential-service.ts +22 -25
- package/src/providers/operations/session-resolution.ts +17 -1
- package/src/providers/provider-contracts.ts +2 -2
- package/src/providers/provider-metadata.ts +30 -0
- package/src/providers/quota/quota-contracts.ts +2 -0
- package/src/providers/quota/refresh.ts +25 -3
- package/src/security/ip-boundary.ts +1 -1
- package/src/transport/dispatch/attempt-finalize.ts +1 -1
- package/src/transport/dispatch/attempt-loop.ts +3 -3
- package/src/transport/dispatch/handler-deps.ts +1 -1
- package/src/transport/dispatch/leases.ts +1 -1
- package/src/transport/dispatch/retry-policy.ts +1 -1
- package/src/transport/dispatch/streaming/finalize.ts +2 -2
- package/src/transport/dispatch/streaming/stream.ts +1 -1
- package/src/transport/dispatch/upstream.ts +1 -1
- package/src/transport/shutdown-notice.ts +1 -1
- 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-Cc0vKpMG.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-Bqj2aa1T.js.gz +0 -0
- package/dist/dashboard/assets/CliToolsPage-CXsaOAO2.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-BeLpJ4ma.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-DnV0uZ3m.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-CBxAjo63.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-B5yEE0k6.js.gz +0 -0
- package/dist/dashboard/assets/InjectSystemPromptPanel-BWFLECmz.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-CgoPc5B9.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-D9bFQern.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-B_p4tL8k.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-rs5E2rxv.js +0 -8
- package/dist/dashboard/assets/ProviderDetailPage-rs5E2rxv.js.gz +0 -0
- package/dist/dashboard/assets/ProviderIcon-D_0PiZtg.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-CLI2bJTK.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-BwNC_-Cx.js +0 -9
- package/dist/dashboard/assets/ProxyPage-BwNC_-Cx.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-DA-a1iSN.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-CctTga2C.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-febLU4r1.js.gz +0 -0
- package/dist/dashboard/assets/StudioPage-M6fvR5Wz.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-nyeuRqz9.js.gz +0 -0
- package/dist/dashboard/assets/api-keys-BFOlvrJC.js.gz +0 -0
- package/dist/dashboard/assets/app-Dx2QgsNj.css +0 -2
- package/dist/dashboard/assets/app-Dx2QgsNj.css.gz +0 -0
- package/dist/dashboard/assets/assert-CqTLw5e7.js.gz +0 -0
- package/dist/dashboard/assets/badge-C83EqLB0.js.gz +0 -0
- package/dist/dashboard/assets/banner-D1xn7pIe.js.gz +0 -0
- package/dist/dashboard/assets/card-DU3Lq5Lm.js.gz +0 -0
- package/dist/dashboard/assets/cli-tools-Dw1U_drK.js.gz +0 -0
- package/dist/dashboard/assets/clipboard-button-CEL5Sg_J.js.gz +0 -0
- package/dist/dashboard/assets/dialog-BjFR5UHy.js.gz +0 -0
- package/dist/dashboard/assets/drawer-HvBW0AjI.js.gz +0 -0
- package/dist/dashboard/assets/entry-BSu3gFlt.js.gz +0 -0
- package/dist/dashboard/assets/entry-CECVELyy.js.gz +0 -0
- package/dist/dashboard/assets/entry-DYYofQ7W.js.gz +0 -0
- package/dist/dashboard/assets/formatters-DLW4i8zK.js.gz +0 -0
- package/dist/dashboard/assets/index-1-fCqe64.js.gz +0 -0
- package/dist/dashboard/assets/inline-DGvU2oLK.js.gz +0 -0
- package/dist/dashboard/assets/input-iNnO1X2K.js.gz +0 -0
- package/dist/dashboard/assets/layout-0q8UGypb.js.gz +0 -0
- package/dist/dashboard/assets/list-row-C0w2bQjJ.js.gz +0 -0
- package/dist/dashboard/assets/live-M5aJ_xCa.js.gz +0 -0
- package/dist/dashboard/assets/network-BKeQGT3K.js.gz +0 -0
- package/dist/dashboard/assets/numbers-CivAk3da.js.gz +0 -0
- package/dist/dashboard/assets/pagination-CpzvI5ep.js.gz +0 -0
- package/dist/dashboard/assets/routing-BFQX2VBc.js.gz +0 -0
- package/dist/dashboard/assets/select-CXbck0jG.js.gz +0 -0
- package/dist/dashboard/assets/settings-C_XvMHh9.js.gz +0 -0
- package/dist/dashboard/assets/stack-DggRiCxE.js.gz +0 -0
- package/dist/dashboard/assets/state-ClFzfbYp.js.gz +0 -0
- package/dist/dashboard/assets/switch-CTIFxKqS.js.gz +0 -0
- package/dist/dashboard/assets/useMutation-BClG3yWj.js.gz +0 -0
- package/src/console/providers/catalog/discover-constants.ts +0 -6
- package/src/network/pool-health.ts +0 -198
- 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-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 -188
- package/src/providers/integrations/buddy/codebuddy/oauth.ts +0 -47
- package/src/providers/integrations/buddy/codebuddy/provider.ts +0 -179
- 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 -870
- package/src/providers/integrations/qoder/qoder-ide.ts +0 -481
- /package/dist/dashboard/assets/{app-DIXLjojg.js → app-C0gTXEoN.js} +0 -0
- /package/dist/dashboard/assets/{app-DIXLjojg.js.gz → app-C0gTXEoN.js.gz} +0 -0
- /package/src/network/{http2-bidi.ts → bidi.ts} +0 -0
- /package/src/network/pool/{byte-accounting.ts → accounting.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
- /package/src/network/{rate-limit-verdict.ts → verdict.ts} +0 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified network egress client.
|
|
3
|
+
*
|
|
4
|
+
* One entry point for every outbound transport the gateway can dial:
|
|
5
|
+
*
|
|
6
|
+
* - `fetch(poolId?, tenantId?)` — validated HTTP fetch (SSRF-guarded), with
|
|
7
|
+
* optional TLS ClientHello profile support (`options.tlsProfile`).
|
|
8
|
+
* - `bidi(poolId?, tenantId?)` — validated bidirectional HTTP/2 stream
|
|
9
|
+
* (Connect-RPC-style request/response over one long-lived stream).
|
|
10
|
+
* - `webSocket(poolId?, tenantId?)` — validated WSS session.
|
|
11
|
+
* - `resolve(hostname, signal, port?)` — one-shot DNS resolution + SSRF
|
|
12
|
+
* validation, pinned so a rebind cannot retarget the socket.
|
|
13
|
+
*
|
|
14
|
+
* All four bind to the same selected network pool (when one is given) or to
|
|
15
|
+
* validated direct egress. Provider adapters call this and never dial a
|
|
16
|
+
* socket themselves.
|
|
17
|
+
*/
|
|
18
|
+
import { ValidatedNetworkBindingFactory } from "./egress/factory";
|
|
19
|
+
import type { ValidatedOutboundHttp2Bidi } from "./bidi";
|
|
20
|
+
import type { ValidatedOutboundWebSocket } from "../providers/provider-contracts";
|
|
21
|
+
import type { ValidatedDestination } from "./ssrf";
|
|
22
|
+
import type { SsrfPolicy } from "../config/network";
|
|
23
|
+
import type { TlsProfile } from "./egress/tls-profile";
|
|
24
|
+
import type { ValidatedFetch } from "./egress/fetch";
|
|
25
|
+
import type { PoolAgentResolver } from "./pool/resolver";
|
|
26
|
+
|
|
27
|
+
export interface NetworkClientOptions {
|
|
28
|
+
/** SSRF policy applied to every dial. Defaults to the empty (block-all-loopback) policy. */
|
|
29
|
+
readonly policy?: SsrfPolicy;
|
|
30
|
+
/** Injected fetch for offline tests/recordings. */
|
|
31
|
+
readonly fetchFn?: typeof fetch;
|
|
32
|
+
/** Optional pool-agent resolver; without it every dial is direct. */
|
|
33
|
+
readonly poolResolver?: PoolAgentResolver;
|
|
34
|
+
/**
|
|
35
|
+
* Default TLS ClientHello profile for direct https dials. `"default"` keeps
|
|
36
|
+
* the runtime's own TLS; a named profile (see `tls-profile.ts`) dials with
|
|
37
|
+
* that profile's OpenSSL options so the wire fingerprint matches the
|
|
38
|
+
* impersonated client.
|
|
39
|
+
*/
|
|
40
|
+
readonly tlsProfile?: TlsProfile;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface NetworkClient {
|
|
44
|
+
/** Validated HTTP fetch bound to a pool slot (or direct when omitted). */
|
|
45
|
+
readonly fetch: (poolId?: string, tenantId?: string) => ValidatedFetch;
|
|
46
|
+
/** Validated bidirectional HTTP/2 stream. */
|
|
47
|
+
readonly bidi: (poolId?: string, tenantId?: string) => ValidatedOutboundHttp2Bidi;
|
|
48
|
+
/** Validated WSS session. */
|
|
49
|
+
readonly webSocket: (poolId?: string, tenantId?: string) => ValidatedOutboundWebSocket;
|
|
50
|
+
/** One-shot DNS resolution + SSRF validation for direct egress targets. */
|
|
51
|
+
readonly resolve: (hostname: string, signal: AbortSignal, port?: number) => Promise<ValidatedDestination>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function createNetworkClient(options: NetworkClientOptions = {}): NetworkClient {
|
|
55
|
+
const factory = new ValidatedNetworkBindingFactory(
|
|
56
|
+
options.policy,
|
|
57
|
+
options.fetchFn,
|
|
58
|
+
options.poolResolver,
|
|
59
|
+
options.tlsProfile,
|
|
60
|
+
);
|
|
61
|
+
return {
|
|
62
|
+
fetch: (poolId, tenantId) => factory.fetch(poolId, tenantId),
|
|
63
|
+
bidi: (poolId, tenantId) => factory.http2Bidi(poolId, tenantId),
|
|
64
|
+
webSocket: (poolId, tenantId) => factory.webSocket(poolId, tenantId),
|
|
65
|
+
resolve: (hostname, signal, port) => factory.resolve(hostname, signal, port),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP/2 bidi session — one validated bidirectional stream over a dialed TLS
|
|
3
|
+
* socket, plus the session wrapper that drives it.
|
|
4
|
+
*
|
|
5
|
+
* Inbound bytes are exposed as an async iterable whose consumer pace is the
|
|
6
|
+
* backpressure: a paused consumer leaves the data unread and lets the HTTP/2
|
|
7
|
+
* flow-control window close, which is what keeps a multi-megabyte Run from
|
|
8
|
+
* buffering entirely in memory.
|
|
9
|
+
*/
|
|
10
|
+
import * as http2 from "node:http2";
|
|
11
|
+
import type { TLSSocket } from "node:tls";
|
|
12
|
+
import { GatewayError } from "../../protocol/gateway-error";
|
|
13
|
+
import type { Http2BidiEndReason, Http2BidiOpenOptions, ValidatedHttp2BidiStream } from "../bidi";
|
|
14
|
+
|
|
15
|
+
export const HTTP2_CONNECT_TIMEOUT_MS = 15_000;
|
|
16
|
+
/** Read-idle bound after which a bidi stream is declared dead. */
|
|
17
|
+
export const HTTP2_READ_IDLE_MS = 120_000;
|
|
18
|
+
/** Buffered inbound bytes before a bidi stream is refused as unbounded. */
|
|
19
|
+
export const HTTP2_MAX_BUFFERED_BYTES = 64 * 1024 * 1024;
|
|
20
|
+
/**
|
|
21
|
+
* Session memory ceiling for one HTTP/2 session.
|
|
22
|
+
*
|
|
23
|
+
* Node and Bun count a session's unsent bytes against `maxSessionMemory`
|
|
24
|
+
* (10 MB by default) and, once over it, fail every new stream with
|
|
25
|
+
* `ENHANCE_YOUR_CALM`. A bidi Run request and its KV replies run to many
|
|
26
|
+
* megabytes and the caller holds those bytes anyway, so the default only adds
|
|
27
|
+
* a failure mode.
|
|
28
|
+
*/
|
|
29
|
+
export const HTTP2_SESSION_MAX_MEMORY_MB = 256;
|
|
30
|
+
|
|
31
|
+
class Http2BidiSession implements ValidatedHttp2BidiStream {
|
|
32
|
+
private status = 0;
|
|
33
|
+
private responseHeaders: Record<string, string> = {};
|
|
34
|
+
private responseTrailers: Record<string, string> = {};
|
|
35
|
+
private readonly chunks: Uint8Array[] = [];
|
|
36
|
+
private readonly waiters: Array<{
|
|
37
|
+
resolve(value: IteratorResult<Uint8Array>): void;
|
|
38
|
+
reject(reason: unknown): void;
|
|
39
|
+
}> = [];
|
|
40
|
+
private inboundEnded = false;
|
|
41
|
+
private inboundFailure: Error | undefined;
|
|
42
|
+
private ended = false;
|
|
43
|
+
private terminalReason: Http2BidiEndReason | undefined;
|
|
44
|
+
private readonly terminalListeners = new Set<(reason: Http2BidiEndReason) => void>();
|
|
45
|
+
private readIdleTimer: ReturnType<typeof setTimeout> | undefined;
|
|
46
|
+
private bufferedBytes = 0;
|
|
47
|
+
|
|
48
|
+
constructor(
|
|
49
|
+
private readonly stream: http2.ClientHttp2Stream,
|
|
50
|
+
private readonly session: http2.ClientHttp2Session,
|
|
51
|
+
private readonly readIdleMs: number,
|
|
52
|
+
private readonly maxBufferedBytes: number,
|
|
53
|
+
) {
|
|
54
|
+
stream.on("response", (headers) => {
|
|
55
|
+
const status = headers[":status"];
|
|
56
|
+
this.status = typeof status === "string" ? Number(status) : (status ?? 0);
|
|
57
|
+
this.responseHeaders = flattenHeaders(headers);
|
|
58
|
+
});
|
|
59
|
+
stream.on("trailers", (headers) => {
|
|
60
|
+
this.responseTrailers = flattenHeaders(headers);
|
|
61
|
+
});
|
|
62
|
+
stream.on("data", (chunk: Buffer) => {
|
|
63
|
+
this.armReadIdle();
|
|
64
|
+
const bytes = new Uint8Array(chunk);
|
|
65
|
+
this.bufferedBytes += bytes.byteLength;
|
|
66
|
+
const waiter = this.waiters.shift();
|
|
67
|
+
if (waiter !== undefined) {
|
|
68
|
+
this.bufferedBytes -= bytes.byteLength;
|
|
69
|
+
waiter.resolve({ done: false, value: bytes });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
this.chunks.push(bytes);
|
|
73
|
+
// A stream that keeps arriving while nobody reads is unbounded growth,
|
|
74
|
+
// not backpressure: pause the socket rather than accumulating until the
|
|
75
|
+
// process is out of memory.
|
|
76
|
+
if (this.bufferedBytes > this.maxBufferedBytes) {
|
|
77
|
+
this.fail(
|
|
78
|
+
new GatewayError(
|
|
79
|
+
"platform_unavailable",
|
|
80
|
+
502,
|
|
81
|
+
"HTTP/2 response exceeded the buffer bound (request {requestId}). The upstream sent more than we can hold — retry 🌙",
|
|
82
|
+
{},
|
|
83
|
+
"network",
|
|
84
|
+
),
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
stream.on("end", () => this.finishInbound());
|
|
89
|
+
stream.on("error", (error) => this.fail(error));
|
|
90
|
+
stream.on("aborted", () =>
|
|
91
|
+
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")),
|
|
92
|
+
);
|
|
93
|
+
stream.on("close", () => {
|
|
94
|
+
this.finishInbound();
|
|
95
|
+
this.settleTerminal({ kind: "remote-clean-close" });
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
get response(): { status: number; headers: Readonly<Record<string, string>> } {
|
|
100
|
+
return { status: this.status, headers: this.responseHeaders };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
get trailers(): Readonly<Record<string, string>> {
|
|
104
|
+
return this.responseTrailers;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
get body(): AsyncIterable<Uint8Array> {
|
|
108
|
+
const self = this;
|
|
109
|
+
return {
|
|
110
|
+
[Symbol.asyncIterator](): AsyncIterator<Uint8Array> {
|
|
111
|
+
return {
|
|
112
|
+
next(): Promise<IteratorResult<Uint8Array>> {
|
|
113
|
+
return self.nextChunk();
|
|
114
|
+
},
|
|
115
|
+
return(): Promise<IteratorResult<Uint8Array>> {
|
|
116
|
+
self.abort();
|
|
117
|
+
return Promise.resolve({ done: true, value: undefined });
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private nextChunk(): Promise<IteratorResult<Uint8Array>> {
|
|
125
|
+
const queued = this.chunks.shift();
|
|
126
|
+
if (queued !== undefined) {
|
|
127
|
+
this.bufferedBytes -= queued.byteLength;
|
|
128
|
+
return Promise.resolve({ done: false, value: queued });
|
|
129
|
+
}
|
|
130
|
+
if (this.inboundFailure !== undefined) return Promise.reject(this.inboundFailure);
|
|
131
|
+
if (this.inboundEnded) return Promise.resolve({ done: true, value: undefined });
|
|
132
|
+
return new Promise<IteratorResult<Uint8Array>>((resolve, reject) => {
|
|
133
|
+
this.waiters.push({ resolve, reject });
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private armReadIdle(): void {
|
|
138
|
+
clearTimeout(this.readIdleTimer);
|
|
139
|
+
this.readIdleTimer = setTimeout(() => {
|
|
140
|
+
this.fail(
|
|
141
|
+
new GatewayError(
|
|
142
|
+
"transport_unavailable",
|
|
143
|
+
504,
|
|
144
|
+
`HTTP/2 stream read-idle timeout after ${this.readIdleMs}ms — connection presumed dead (request {requestId}). The connection ghosted us — come back in a moment 👻`,
|
|
145
|
+
{},
|
|
146
|
+
"network",
|
|
147
|
+
),
|
|
148
|
+
);
|
|
149
|
+
}, this.readIdleMs);
|
|
150
|
+
this.readIdleTimer.unref?.();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private finishInbound(): void {
|
|
154
|
+
clearTimeout(this.readIdleTimer);
|
|
155
|
+
this.readIdleTimer = undefined;
|
|
156
|
+
if (this.inboundFailure !== undefined || this.inboundEnded) return;
|
|
157
|
+
this.inboundEnded = true;
|
|
158
|
+
const waiter = this.waiters.shift();
|
|
159
|
+
waiter?.resolve({ done: true, value: undefined });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private fail(error: Error): void {
|
|
163
|
+
if (this.inboundFailure !== undefined) return;
|
|
164
|
+
this.inboundFailure = error;
|
|
165
|
+
this.chunks.length = 0;
|
|
166
|
+
for (const waiter of this.waiters.splice(0)) waiter.reject(error);
|
|
167
|
+
this.settleTerminal({ kind: "remote-error", error });
|
|
168
|
+
try {
|
|
169
|
+
this.stream.destroy(error);
|
|
170
|
+
} catch {
|
|
171
|
+
// The stream may already be closing.
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private settleTerminal(reason: Http2BidiEndReason): void {
|
|
176
|
+
if (this.terminalReason !== undefined) return;
|
|
177
|
+
this.terminalReason = reason;
|
|
178
|
+
for (const listener of this.terminalListeners) {
|
|
179
|
+
try {
|
|
180
|
+
listener(reason);
|
|
181
|
+
} catch {
|
|
182
|
+
// An observer cannot destabilize teardown.
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
this.terminalListeners.clear();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
onTerminal(listener: (reason: Http2BidiEndReason) => void): () => void {
|
|
189
|
+
this.terminalListeners.add(listener);
|
|
190
|
+
return () => this.terminalListeners.delete(listener);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
write(chunk: Uint8Array): Promise<void> {
|
|
194
|
+
if (this.inboundFailure !== undefined) return Promise.reject(this.inboundFailure);
|
|
195
|
+
if (this.ended) return Promise.reject(new Error("HTTP/2 request side already ended"));
|
|
196
|
+
return new Promise<void>((resolve, reject) => {
|
|
197
|
+
const flushed = this.stream.write(chunk, (error) => {
|
|
198
|
+
if (error !== null && error !== undefined) reject(error);
|
|
199
|
+
});
|
|
200
|
+
if (flushed) {
|
|
201
|
+
resolve();
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
// Backpressured: wait for drain. There is no deadline here — a stream
|
|
205
|
+
// error or close settles it, and the read-idle watchdog bounds a stream
|
|
206
|
+
// that stops answering.
|
|
207
|
+
this.stream.once("drain", () => resolve());
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
end(): void {
|
|
212
|
+
if (this.ended) return;
|
|
213
|
+
this.ended = true;
|
|
214
|
+
try {
|
|
215
|
+
this.stream.end();
|
|
216
|
+
} catch {
|
|
217
|
+
// Already closed by the peer or by abort.
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
abort(reason?: unknown): void {
|
|
222
|
+
if (this.ended && this.terminalReason !== undefined) return;
|
|
223
|
+
this.ended = true;
|
|
224
|
+
this.settleTerminal({ kind: "local-close" });
|
|
225
|
+
try {
|
|
226
|
+
this.stream.destroy(reason instanceof Error ? reason : undefined);
|
|
227
|
+
} catch {
|
|
228
|
+
// Already destroyed.
|
|
229
|
+
}
|
|
230
|
+
try {
|
|
231
|
+
this.session.close();
|
|
232
|
+
} catch {
|
|
233
|
+
// Already closing.
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Flattens HTTP/2 header objects into a plain string map. */
|
|
239
|
+
function flattenHeaders(headers: Record<string, unknown>): Record<string, string> {
|
|
240
|
+
const out: Record<string, string> = {};
|
|
241
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
242
|
+
if (value === undefined || value === null) continue;
|
|
243
|
+
if (Array.isArray(value)) out[name] = value.map((entry) => String(entry)).join(", ");
|
|
244
|
+
else out[name] = String(value);
|
|
245
|
+
}
|
|
246
|
+
return out;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Opens one validated bidi stream over an already-connected socket. */
|
|
250
|
+
export function openBidiStream(
|
|
251
|
+
socket: TLSSocket,
|
|
252
|
+
url: URL,
|
|
253
|
+
headers: Readonly<Record<string, string>>,
|
|
254
|
+
options: Http2BidiOpenOptions,
|
|
255
|
+
): Promise<ValidatedHttp2BidiStream> {
|
|
256
|
+
// `createConnection`, not the `socket` option: the socket is already a
|
|
257
|
+
// validated, ALPN-negotiated TLS connection, and handing it through
|
|
258
|
+
// `createConnection` is what makes the session adopt it as-is (Node's
|
|
259
|
+
// `socket` option applies its own TLS expectations on top).
|
|
260
|
+
const session = http2.connect(`https://${url.hostname}`, {
|
|
261
|
+
createConnection: () => socket,
|
|
262
|
+
maxSessionMemory: HTTP2_SESSION_MAX_MEMORY_MB,
|
|
263
|
+
});
|
|
264
|
+
return new Promise<ValidatedHttp2BidiStream>((resolve, reject) => {
|
|
265
|
+
const cleanup = (): void => {
|
|
266
|
+
clearTimeout(timer);
|
|
267
|
+
session.removeListener("error", onError);
|
|
268
|
+
session.removeListener("connect", onConnect);
|
|
269
|
+
};
|
|
270
|
+
const onError = (error: Error): void => {
|
|
271
|
+
cleanup();
|
|
272
|
+
try {
|
|
273
|
+
session.destroy();
|
|
274
|
+
} catch {
|
|
275
|
+
// Already destroying.
|
|
276
|
+
}
|
|
277
|
+
reject(
|
|
278
|
+
new GatewayError(
|
|
279
|
+
"transport_unavailable",
|
|
280
|
+
502,
|
|
281
|
+
`HTTP/2 session failed: ${error.message} (request {requestId}). The connection ghosted us — come back in a moment 👻`,
|
|
282
|
+
{},
|
|
283
|
+
"network",
|
|
284
|
+
),
|
|
285
|
+
);
|
|
286
|
+
};
|
|
287
|
+
const onConnect = (): void => {
|
|
288
|
+
cleanup();
|
|
289
|
+
const stream = session.request(
|
|
290
|
+
{
|
|
291
|
+
":method": "POST",
|
|
292
|
+
":path": `${url.pathname}${url.search}`,
|
|
293
|
+
":authority": url.hostname,
|
|
294
|
+
":scheme": "https",
|
|
295
|
+
...headers,
|
|
296
|
+
} as unknown as http2.OutgoingHttpHeaders,
|
|
297
|
+
{ endStream: false },
|
|
298
|
+
);
|
|
299
|
+
resolve(
|
|
300
|
+
new Http2BidiSession(
|
|
301
|
+
stream,
|
|
302
|
+
session,
|
|
303
|
+
options.readIdleMs ?? HTTP2_READ_IDLE_MS,
|
|
304
|
+
options.maxBufferedBytes ?? HTTP2_MAX_BUFFERED_BYTES,
|
|
305
|
+
),
|
|
306
|
+
);
|
|
307
|
+
};
|
|
308
|
+
const timer = setTimeout(() => {
|
|
309
|
+
cleanup();
|
|
310
|
+
try {
|
|
311
|
+
session.destroy();
|
|
312
|
+
} catch {
|
|
313
|
+
// Already destroying.
|
|
314
|
+
}
|
|
315
|
+
reject(
|
|
316
|
+
new GatewayError("transport_unavailable", 502, "HTTP/2 session timed out (request {requestId}). Some things just take longer 🐢", {}, "network"),
|
|
317
|
+
);
|
|
318
|
+
}, HTTP2_CONNECT_TIMEOUT_MS);
|
|
319
|
+
session.once("error", onError);
|
|
320
|
+
session.once("connect", onConnect);
|
|
321
|
+
});
|
|
322
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Raw validated TLS dials — direct and through a pool tunnel.
|
|
3
|
+
*
|
|
4
|
+
* Direct egress dials the pinned address with SNI set to the *validated
|
|
5
|
+
* hostname*: pinning is what stops a DNS rebind between check and connect, and
|
|
6
|
+
* preserving SNI is what makes the certificate validate against the name the
|
|
7
|
+
* caller asked for rather than against an address.
|
|
8
|
+
*
|
|
9
|
+
* A pool tunnel dial runs the target TLS handshake inside the pool's
|
|
10
|
+
* CONNECT/SOCKS tunnel with SNI set to the validated target hostname.
|
|
11
|
+
*
|
|
12
|
+
* Both accept an optional TLS ClientHello profile (`tls-profile.ts`) so the
|
|
13
|
+
* dial mirrors a target client's transport fingerprint when one is requested.
|
|
14
|
+
*/
|
|
15
|
+
import { isIP } from "node:net";
|
|
16
|
+
import * as tls from "node:tls";
|
|
17
|
+
import type { Duplex } from "node:stream";
|
|
18
|
+
import { Agent as HttpsAgent } from "node:https";
|
|
19
|
+
import { GatewayError } from "../../protocol/gateway-error";
|
|
20
|
+
import { isProxyAgentPair, PoolBindingError, type PoolAgent } from "../pool/agent";
|
|
21
|
+
import { nodeTlsOptions, type TlsProfileConfig } from "./tls-profile";
|
|
22
|
+
import type { ValidatedDestination } from "../ssrf";
|
|
23
|
+
|
|
24
|
+
/** Time allowed for one HTTP/2 connection (TCP + TLS + preface) to establish. */
|
|
25
|
+
export const HTTP2_CONNECT_TIMEOUT_MS = 15_000;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Rejects a bidi target that cannot be dialed safely.
|
|
29
|
+
*
|
|
30
|
+
* Every one of these is a property of the URL, not of the DNS answer, so it is
|
|
31
|
+
* checked before any resolution: a credential in the authority would be sent to
|
|
32
|
+
* whatever host resolves, a non-443 port is not the TLS endpoint the policy
|
|
33
|
+
* validated, and an IP literal bypasses the hostname the caller allowlists.
|
|
34
|
+
*/
|
|
35
|
+
export function assertBidiTarget(url: URL): void {
|
|
36
|
+
if (url.protocol !== "https:") {
|
|
37
|
+
throw new GatewayError("invalid_request", 400, "validated HTTP/2 egress requires https (request {requestId}). Check the shape of what you sent — then resend 🪞");
|
|
38
|
+
}
|
|
39
|
+
if (url.username || url.password || url.hash) {
|
|
40
|
+
throw new GatewayError("invalid_request", 400, "invalid validated HTTP/2 target (request {requestId}). Check the shape of what you sent — then resend 🪞");
|
|
41
|
+
}
|
|
42
|
+
if (url.port !== "" && url.port !== "443") {
|
|
43
|
+
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 🪞");
|
|
44
|
+
}
|
|
45
|
+
const hostname = url.hostname;
|
|
46
|
+
if (hostname.length === 0) {
|
|
47
|
+
throw new GatewayError("invalid_request", 400, "HTTP/2 target hostname is required (request {requestId}). Check the shape of what you sent — then resend 🪞");
|
|
48
|
+
}
|
|
49
|
+
if (isIP(hostname) !== 0) {
|
|
50
|
+
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 🪞");
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Opens one TLS socket to a validated destination.
|
|
56
|
+
*/
|
|
57
|
+
export function dialDirect(
|
|
58
|
+
destination: ValidatedDestination,
|
|
59
|
+
signal: AbortSignal,
|
|
60
|
+
tlsProfile?: TlsProfileConfig,
|
|
61
|
+
): Promise<tls.TLSSocket> {
|
|
62
|
+
return new Promise<tls.TLSSocket>((resolve, reject) => {
|
|
63
|
+
const socket = tls.connect({
|
|
64
|
+
host: destination.resolvedAddress,
|
|
65
|
+
port: destination.port,
|
|
66
|
+
servername: destination.hostname,
|
|
67
|
+
rejectUnauthorized: true,
|
|
68
|
+
ALPNProtocols: ["h2"],
|
|
69
|
+
...(tlsProfile === undefined ? {} : { ...nodeTlsOptions(tlsProfile, false) }),
|
|
70
|
+
} as tls.ConnectionOptions);
|
|
71
|
+
const cleanup = (): void => {
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
signal.removeEventListener("abort", onAbort);
|
|
74
|
+
};
|
|
75
|
+
const onAbort = (): void => {
|
|
76
|
+
cleanup();
|
|
77
|
+
socket.destroy(new Error("HTTP/2 connection aborted"));
|
|
78
|
+
reject(new GatewayError("transport_closed", 499, "HTTP/2 connection was cancelled (request {requestId}). Send it again whenever ready 🙃"));
|
|
79
|
+
};
|
|
80
|
+
const timer = setTimeout(() => {
|
|
81
|
+
cleanup();
|
|
82
|
+
socket.destroy();
|
|
83
|
+
reject(
|
|
84
|
+
new GatewayError("transport_unavailable", 502, "HTTP/2 connection timed out (request {requestId}). Some things just take longer 🐢", {}, "network"),
|
|
85
|
+
);
|
|
86
|
+
}, HTTP2_CONNECT_TIMEOUT_MS);
|
|
87
|
+
socket.once("secureConnect", () => {
|
|
88
|
+
cleanup();
|
|
89
|
+
resolve(socket);
|
|
90
|
+
});
|
|
91
|
+
socket.once("error", (error) => {
|
|
92
|
+
cleanup();
|
|
93
|
+
reject(
|
|
94
|
+
new GatewayError(
|
|
95
|
+
"transport_unavailable",
|
|
96
|
+
502,
|
|
97
|
+
`HTTP/2 connection failed: ${error.message} (request {requestId}). The connection ghosted us — come back in a moment 👻`,
|
|
98
|
+
{},
|
|
99
|
+
"network",
|
|
100
|
+
),
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
104
|
+
if (signal.aborted) onAbort();
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Opens one TLS socket through a pool agent's CONNECT/SOCKS tunnel.
|
|
110
|
+
*
|
|
111
|
+
* The agent owns the dial and re-validates the proxy host per connect, so the
|
|
112
|
+
* tunnel is the pool's egress; the target TLS handshake then runs inside it
|
|
113
|
+
* with SNI set to the validated target hostname.
|
|
114
|
+
*/
|
|
115
|
+
export function dialThroughPool(
|
|
116
|
+
agent: PoolAgent,
|
|
117
|
+
hostname: string,
|
|
118
|
+
signal: AbortSignal,
|
|
119
|
+
tlsProfile?: TlsProfileConfig,
|
|
120
|
+
): Promise<tls.TLSSocket> {
|
|
121
|
+
const pair = isProxyAgentPair(agent)
|
|
122
|
+
? agent
|
|
123
|
+
: agent instanceof HttpsAgent
|
|
124
|
+
? { https: agent }
|
|
125
|
+
: undefined;
|
|
126
|
+
if (pair === undefined) {
|
|
127
|
+
throw new GatewayError(
|
|
128
|
+
"proxy_pool_unhealthy",
|
|
129
|
+
503,
|
|
130
|
+
"configured proxy pool does not support HTTPS tunneling (request {requestId}). This bridge doesn't speak HTTPS tunnels — reconfigure the pool 🔧",
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
return new Promise<tls.TLSSocket>((resolve, reject) => {
|
|
134
|
+
pair.https.createConnection(
|
|
135
|
+
{ host: hostname, port: 443, servername: hostname },
|
|
136
|
+
(error: Error | null, socket: Duplex) => {
|
|
137
|
+
if (error !== null || socket === null) {
|
|
138
|
+
reject(
|
|
139
|
+
error instanceof PoolBindingError
|
|
140
|
+
? new GatewayError(
|
|
141
|
+
"proxy_pool_unhealthy",
|
|
142
|
+
503,
|
|
143
|
+
"Configured proxy pool could not be established (request {requestId}). This bridge looks shaky — repair or reroute 🔧",
|
|
144
|
+
{ reason: error.message.slice(0, 200) },
|
|
145
|
+
"network",
|
|
146
|
+
)
|
|
147
|
+
: new GatewayError(
|
|
148
|
+
"transport_unavailable",
|
|
149
|
+
502,
|
|
150
|
+
`HTTP/2 tunnel failed: ${error?.message ?? "no socket"} (request {requestId}). The connection ghosted us — come back in a moment 👻`,
|
|
151
|
+
{},
|
|
152
|
+
"network",
|
|
153
|
+
),
|
|
154
|
+
);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const secured = tls.connect({
|
|
158
|
+
socket: socket as tls.TLSSocket,
|
|
159
|
+
servername: hostname,
|
|
160
|
+
rejectUnauthorized: true,
|
|
161
|
+
ALPNProtocols: ["h2"],
|
|
162
|
+
...(tlsProfile === undefined ? {} : { ...nodeTlsOptions(tlsProfile, false) }),
|
|
163
|
+
} as tls.ConnectionOptions);
|
|
164
|
+
const cleanup = (): void => {
|
|
165
|
+
clearTimeout(timer);
|
|
166
|
+
signal.removeEventListener("abort", onAbort);
|
|
167
|
+
};
|
|
168
|
+
const onAbort = (): void => {
|
|
169
|
+
cleanup();
|
|
170
|
+
secured.destroy();
|
|
171
|
+
reject(new GatewayError("transport_closed", 499, "HTTP/2 connection was cancelled (request {requestId}). Send it again whenever ready 🙃"));
|
|
172
|
+
};
|
|
173
|
+
const timer = setTimeout(() => {
|
|
174
|
+
cleanup();
|
|
175
|
+
secured.destroy();
|
|
176
|
+
reject(
|
|
177
|
+
new GatewayError("transport_unavailable", 502, "HTTP/2 connection timed out (request {requestId}). Some things just take longer 🐢", {}, "network"),
|
|
178
|
+
);
|
|
179
|
+
}, HTTP2_CONNECT_TIMEOUT_MS);
|
|
180
|
+
secured.once("secureConnect", () => {
|
|
181
|
+
cleanup();
|
|
182
|
+
resolve(secured);
|
|
183
|
+
});
|
|
184
|
+
secured.once("error", (tlsError) => {
|
|
185
|
+
cleanup();
|
|
186
|
+
reject(
|
|
187
|
+
new GatewayError(
|
|
188
|
+
"transport_unavailable",
|
|
189
|
+
502,
|
|
190
|
+
`HTTP/2 TLS handshake failed: ${tlsError.message} (request {requestId}). The connection ghosted us — come back in a moment 👻`,
|
|
191
|
+
{},
|
|
192
|
+
"network",
|
|
193
|
+
),
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
197
|
+
if (signal.aborted) onAbort();
|
|
198
|
+
},
|
|
199
|
+
);
|
|
200
|
+
});
|
|
201
|
+
}
|