cartethyia 2.7.0 → 2.7.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 +107 -106
- package/README.md +25 -2
- package/dist/dashboard/assets/{BannedPage-P76QLGJA.js → BannedPage-D-J75hBM.js} +1 -1
- package/dist/dashboard/assets/BannedPage-D-J75hBM.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolDetailPage-DdBggJS9.js → CliToolDetailPage-CVe6RaBH.js} +1 -1
- package/dist/dashboard/assets/CliToolDetailPage-CVe6RaBH.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolsPage-aN1iXhhP.js → CliToolsPage-Dd8ZSamp.js} +1 -1
- package/dist/dashboard/assets/CliToolsPage-Dd8ZSamp.js.gz +0 -0
- package/dist/dashboard/assets/{CombosPage-DE-F1nxZ.js → CombosPage-QoJ6foxo.js} +1 -1
- package/dist/dashboard/assets/CombosPage-QoJ6foxo.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-DpNRay4C.js +1 -0
- package/dist/dashboard/assets/ConsoleLogPage-DpNRay4C.js.gz +0 -0
- package/dist/dashboard/assets/{CustomizationPage-3CUxMy2X.js → CustomizationPage-CKUFTcW4.js} +1 -1
- package/dist/dashboard/assets/CustomizationPage-CKUFTcW4.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-CzSLiDzr.js +1 -0
- package/dist/dashboard/assets/HealthEventsModal-CzSLiDzr.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-DubdQcvX.js +1 -0
- package/dist/dashboard/assets/LoginPage-DubdQcvX.js.gz +0 -0
- package/dist/dashboard/assets/{ModelPicker-DKVslqG3.js → ModelPicker-BH8Tehdi.js} +1 -1
- package/dist/dashboard/assets/ModelPicker-BH8Tehdi.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-DuWzAQFL.js +1 -0
- package/dist/dashboard/assets/OverviewPage-DuWzAQFL.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-BKEKe2Fm.js +4 -0
- package/dist/dashboard/assets/ProviderDetailPage-BKEKe2Fm.js.gz +0 -0
- package/dist/dashboard/assets/ProviderIcon-FB6yS4v1.js +1 -0
- package/dist/dashboard/assets/ProviderIcon-FB6yS4v1.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-Bwotu8UJ.js +1 -0
- package/dist/dashboard/assets/ProvidersPage-Bwotu8UJ.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-D7hpWbg9.js +9 -0
- package/dist/dashboard/assets/ProxyPage-D7hpWbg9.js.gz +0 -0
- package/dist/dashboard/assets/{QuotaPage-BjldQwIU.js → QuotaPage-ATpaxrbl.js} +1 -1
- package/dist/dashboard/assets/QuotaPage-ATpaxrbl.js.gz +0 -0
- package/dist/dashboard/assets/{SettingsPage-DUIoLRxk.js → SettingsPage-CETophpx.js} +1 -1
- package/dist/dashboard/assets/SettingsPage-CETophpx.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-BdgECjqq.js +1 -0
- package/dist/dashboard/assets/SetupPage-BdgECjqq.js.gz +0 -0
- package/dist/dashboard/assets/{SortableList-fI7JkdIB.js → SortableList-CAK-EN9z.js} +1 -1
- package/dist/dashboard/assets/SortableList-CAK-EN9z.js.gz +0 -0
- package/dist/dashboard/assets/{StudioPage-CHVZqskZ.js → StudioPage-B7W2_RhT.js} +3 -3
- package/dist/dashboard/assets/StudioPage-B7W2_RhT.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-6T4EFDjW.js +56 -0
- package/dist/dashboard/assets/UsagePage-6T4EFDjW.js.gz +0 -0
- package/dist/dashboard/assets/{api-keys-DFY_fZU4.js → api-keys-CUi6UXsR.js} +1 -1
- package/dist/dashboard/assets/api-keys-CUi6UXsR.js.gz +0 -0
- package/dist/dashboard/assets/app-KvsUJUMF.css +2 -0
- package/dist/dashboard/assets/app-KvsUJUMF.css.gz +0 -0
- package/dist/dashboard/assets/{assert-CwdnZARt.js → assert-67xXH5rf.js} +1 -1
- package/dist/dashboard/assets/assert-67xXH5rf.js.gz +0 -0
- package/dist/dashboard/assets/{badge-Dr-uf0Au.js → badge-DC2L43Mj.js} +1 -1
- package/dist/dashboard/assets/badge-DC2L43Mj.js.gz +0 -0
- package/dist/dashboard/assets/{banner-BddmMdoS.js → banner-BBVcb0J3.js} +1 -1
- package/dist/dashboard/assets/banner-BBVcb0J3.js.gz +0 -0
- package/dist/dashboard/assets/{card-CK0PXWlX.js → card-Cca1XC6G.js} +1 -1
- package/dist/dashboard/assets/card-Cca1XC6G.js.gz +0 -0
- package/dist/dashboard/assets/{cli-tools-CcB6mGh2.js → cli-tools-KvsWa1Jl.js} +1 -1
- package/dist/dashboard/assets/cli-tools-KvsWa1Jl.js.gz +0 -0
- package/dist/dashboard/assets/{clipboard-button-DGb2P9Yx.js → clipboard-button-DUzEbynq.js} +1 -1
- package/dist/dashboard/assets/clipboard-button-DUzEbynq.js.gz +0 -0
- package/dist/dashboard/assets/{dialog-LkfuL50u.js → dialog-DFvWhwTa.js} +1 -1
- package/dist/dashboard/assets/dialog-DFvWhwTa.js.gz +0 -0
- package/dist/dashboard/assets/{drawer-ZbEZ_84c.js → drawer-CFM2sMX1.js} +1 -1
- package/dist/dashboard/assets/drawer-CFM2sMX1.js.gz +0 -0
- package/dist/dashboard/assets/{entry-CWBY5A0f.js → entry-BLBLxX0h.js} +3 -3
- package/dist/dashboard/assets/entry-BLBLxX0h.js.gz +0 -0
- package/dist/dashboard/assets/{entry-BJfsVMvA.js → entry-Bqy8RFvM.js} +1 -1
- package/dist/dashboard/assets/entry-Bqy8RFvM.js.gz +0 -0
- package/dist/dashboard/assets/{entry-CvC1-Zqx.js → entry-_xHo5x6r.js} +1 -1
- package/dist/dashboard/assets/entry-_xHo5x6r.js.gz +0 -0
- package/dist/dashboard/assets/formatters-mpBPqAQ8.js +1 -0
- package/dist/dashboard/assets/formatters-mpBPqAQ8.js.gz +0 -0
- package/dist/dashboard/assets/{github-badge-BxpznFLt.js → github-badge-C9e8Ts7e.js} +1 -1
- package/dist/dashboard/assets/github-badge-C9e8Ts7e.js.gz +0 -0
- package/dist/dashboard/assets/{index-CDzP3ovJ.js → index-D6QzEC_n.js} +2 -2
- package/dist/dashboard/assets/index-D6QzEC_n.js.gz +0 -0
- package/dist/dashboard/assets/{inline-Ae13MQ5w.js → inline-BFUcvllj.js} +1 -1
- package/dist/dashboard/assets/inline-BFUcvllj.js.gz +0 -0
- package/dist/dashboard/assets/{input-D23fo7cp.js → input-C_ILAvBb.js} +1 -1
- package/dist/dashboard/assets/input-C_ILAvBb.js.gz +0 -0
- package/dist/dashboard/assets/{layout-CSsxjj5P.js → layout-CBIyT6Rt.js} +1 -1
- package/dist/dashboard/assets/layout-CBIyT6Rt.js.gz +0 -0
- package/dist/dashboard/assets/{list-row-dmbwZMDY.js → list-row-ebJ_-Nsb.js} +1 -1
- package/dist/dashboard/assets/list-row-ebJ_-Nsb.js.gz +0 -0
- package/dist/dashboard/assets/{live-BG1oUsZZ.js → live-D0mm--ME.js} +1 -1
- package/dist/dashboard/assets/live-D0mm--ME.js.gz +0 -0
- package/dist/dashboard/assets/{mutation-DSF-e4ho.js → mutation-sMD965oO.js} +1 -1
- package/dist/dashboard/assets/mutation-sMD965oO.js.gz +0 -0
- package/dist/dashboard/assets/{numbers-Bm_aECY7.js → numbers-BO9dKaFr.js} +1 -1
- package/dist/dashboard/assets/numbers-BO9dKaFr.js.gz +0 -0
- package/dist/dashboard/assets/providers/inception.svg +5 -0
- package/dist/dashboard/assets/providers/inception.svg.gz +0 -0
- package/dist/dashboard/assets/{query-keys-CF56nYIV.js → query-keys-CfL_Zdoh.js} +1 -1
- package/dist/dashboard/assets/query-keys-CfL_Zdoh.js.gz +0 -0
- package/dist/dashboard/assets/{routing-CeA71n7G.js → routing-BnIpi1IJ.js} +1 -1
- package/dist/dashboard/assets/routing-BnIpi1IJ.js.gz +0 -0
- package/dist/dashboard/assets/{select-KIT0rC6G.js → select-Cj5Nw43g.js} +1 -1
- package/dist/dashboard/assets/select-Cj5Nw43g.js.gz +0 -0
- package/dist/dashboard/assets/{settings-B_2gkePf.js → settings-BAFhDgX8.js} +1 -1
- package/dist/dashboard/assets/settings-BAFhDgX8.js.gz +0 -0
- package/dist/dashboard/assets/{stack-D7WOSzcX.js → stack-EMrOMt4I.js} +1 -1
- package/dist/dashboard/assets/stack-EMrOMt4I.js.gz +0 -0
- package/dist/dashboard/assets/{state-BBxtKZOQ.js → state-CWoo7ty5.js} +1 -1
- package/dist/dashboard/assets/state-CWoo7ty5.js.gz +0 -0
- package/dist/dashboard/assets/{switch-DNS_D5Ea.js → switch-CCjT6IkF.js} +1 -1
- package/dist/dashboard/assets/switch-CCjT6IkF.js.gz +0 -0
- package/dist/dashboard/assets/{useMutation-BgoxdoMk.js → useMutation-DjkLHUcE.js} +1 -1
- package/dist/dashboard/assets/useMutation-DjkLHUcE.js.gz +0 -0
- package/dist/dashboard/index.html +1 -1
- package/dist/dashboard/index.html.gz +0 -0
- package/dist/dashboard/precompressed-assets.json +54 -53
- package/migrations/full-postgres/0024_add_pool_provider_cooldowns.sql +12 -0
- package/migrations/full-postgres/0025_add_pool_random_strategy.sql +3 -0
- package/migrations/lite-sqlite3/0024_add_pool_provider_cooldowns.sql +12 -0
- package/migrations/lite-sqlite3/0025_add_pool_random_strategy.sql +20 -0
- package/package.json +96 -96
- package/scripts/cli/console-menu.ts +5 -2
- package/scripts/cli/tray.ts +21 -6
- package/src/boot/app.ts +684 -714
- package/src/boot/dependencies.ts +65 -62
- package/src/config/cooldowns.ts +16 -0
- package/src/config/providers.ts +13 -1
- package/src/config/resources.ts +70 -70
- package/src/config/security.ts +37 -37
- package/src/config/spec.ts +371 -358
- package/src/console/console-router.ts +233 -225
- package/src/console/domain-registration.ts +398 -381
- package/src/console/providers/catalog/catalog-projections.ts +2 -6
- package/src/console/providers/catalog/contracts.ts +19 -0
- package/src/console/providers/catalog/provider-operations.ts +38 -0
- package/src/console/providers/catalog/routes.ts +14 -0
- package/src/console/providers/catalog/store-helpers.ts +326 -0
- package/src/console/providers/catalog/store.ts +1292 -1563
- package/src/console/quota/account-quota-global.ts +2 -2
- package/src/console/quota/account-quota-shared.ts +1 -1
- package/src/console/quota/account-quota-tenant.ts +35 -12
- package/src/console/quota/quota-view.ts +2 -2
- package/src/console/routing/pools/contracts.ts +41 -1
- package/src/console/routing/pools/operations.ts +183 -4
- package/src/console/routing/pools/relay-deploy.ts +17 -4
- package/src/console/routing/pools/routes.ts +30 -0
- package/src/console/routing/pools/store.ts +98 -8
- package/src/main.ts +183 -167
- package/src/network/outbound-fetch.ts +226 -365
- package/src/network/pool/agent.ts +8 -0
- package/src/network/pool/pool-state.ts +7 -0
- package/src/network/pool/selector.ts +110 -2
- package/src/network/pool-cooldown-store.ts +252 -0
- package/src/network/pool-health-machine.ts +5 -3
- package/src/network/pool-health.ts +36 -9
- package/src/observability/event-query.ts +57 -0
- package/src/observability/metrics.ts +467 -467
- package/src/observability/runtime-metrics.ts +269 -269
- package/src/persistence/health-event-flush.ts +2 -0
- package/src/persistence/schema.ts +19 -0
- package/src/persistence/usage-flush.ts +8 -3
- package/src/protocol/canonical-model.ts +1 -1
- package/src/protocol/dsml.ts +377 -0
- package/src/protocol/gateway-error.ts +232 -232
- package/src/{providers/provider-reasoning.ts → protocol/reasoning.ts} +10 -9
- package/src/protocol/request/chat.ts +7 -7
- package/src/protocol/request/codex.ts +2 -2
- package/src/protocol/request/gemini.ts +1 -1
- package/src/protocol/request/messages.ts +6 -6
- package/src/protocol/request/responses.ts +7 -7
- package/src/protocol/response/dsml.ts +12 -133
- package/src/{transport → protocol}/translation/attachment-integrity.ts +2 -2
- package/src/{transport → protocol}/translation/cache-controls.ts +1 -1
- package/src/{transport → protocol}/translation/capabilities.ts +2 -2
- package/src/{transport → protocol}/translation/normalize.ts +1 -1
- package/src/{transport → protocol}/translation/quirks.ts +1 -1
- package/src/{transport → protocol}/translation/search.ts +1 -1
- package/src/{transport → protocol}/translation/thinking.ts +23 -1
- package/src/{transport → protocol}/translation/tool-id.ts +1 -1
- package/src/{transport → protocol}/translation/tool-repair.ts +2 -2
- package/src/protocol/upstream-parsing.ts +176 -0
- package/src/providers/adapters/compatible-adapter.ts +11 -2
- package/src/providers/authentication/oauth-refresh-service.ts +9 -5
- package/src/providers/default-registry.ts +21 -5
- package/src/providers/discovery/probe-phases.ts +16 -3
- package/src/providers/discovery/probing-service.ts +4 -1
- package/src/providers/integrations/anthropic.ts +1 -1
- package/src/providers/integrations/buddy/buddy-catalog-shared.ts +1 -4
- package/src/providers/integrations/buddy/buddy-chat-shared.ts +141 -8
- package/src/providers/integrations/buddy/buddy-checkin.ts +5 -1
- package/src/providers/integrations/buddy/buddy-growth-shared.ts +0 -82
- package/src/providers/integrations/buddy/buddy-identity.ts +25 -0
- package/src/providers/integrations/buddy/buddy-server-tools.ts +240 -0
- package/src/providers/integrations/buddy/codebuddy/cn-catalog.ts +36 -0
- package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +6 -31
- package/src/providers/integrations/buddy/codebuddy/provider.ts +2 -0
- package/src/providers/integrations/buddy/codebuddy/shared.ts +19 -4
- package/src/providers/integrations/buddy/turing-device-token.ts +262 -0
- package/src/providers/integrations/buddy/workbuddy/provider.ts +2 -0
- package/src/providers/integrations/buddy/workbuddy/shared.ts +56 -3
- package/src/providers/integrations/claude/claude.ts +1 -1
- package/src/providers/integrations/cloudflareai/cloudflareai.ts +1 -1
- package/src/providers/integrations/codex/codex-errors.ts +1 -1
- package/src/providers/integrations/codex/codex.ts +1 -1
- package/src/providers/integrations/factory/factory.ts +1 -1
- package/src/providers/integrations/github/github.ts +1 -1
- package/src/providers/integrations/grok/grok.ts +1 -1
- package/src/providers/integrations/inception/provider.ts +91 -0
- package/src/providers/integrations/inferhub.ts +1 -1
- package/src/providers/integrations/kiro/kiro-request.ts +1 -1
- package/src/providers/integrations/mistral.ts +193 -19
- package/src/providers/integrations/opencode/fingerprint.ts +19 -2
- package/src/providers/integrations/opencode/prompt.ts +111 -0
- package/src/providers/integrations/opencode/provider.ts +101 -23
- package/src/providers/integrations/opencode/tool-definitions.ts +4 -4
- package/src/providers/integrations/qoder/qoder-checkin.ts +303 -0
- package/src/providers/integrations/qoder/qoder.ts +1641 -1458
- package/src/providers/integrations/shared/install-id.ts +2 -1
- package/src/providers/integrations/trae/trae-cache.ts +110 -0
- package/src/providers/integrations/trae/trae-oauth.ts +29 -4
- package/src/providers/integrations/trae/trae-protocol.ts +2 -1
- package/src/providers/integrations/trae/trae.ts +106 -23
- package/src/providers/operations/account-error-classification.ts +1660 -0
- package/src/providers/operations/account-health-service.ts +621 -2097
- package/src/providers/operations/account-reset-service.ts +2 -0
- package/src/providers/provider-contracts.ts +0 -2
- package/src/providers/provider-metadata.ts +25 -4
- package/src/providers/provider-probe.ts +2 -2
- package/src/{console → providers}/quota/cache.ts +1 -1
- package/src/{console → providers}/quota/refresh.ts +5 -5
- package/src/transport/dispatch/attempt-finalize.ts +114 -12
- package/src/transport/dispatch/attempt-loop.ts +3 -0
- package/src/transport/dispatch/empty-stream.ts +224 -217
- package/src/transport/dispatch/generation.ts +37 -55
- package/src/transport/dispatch/handler-deps.ts +32 -0
- package/src/transport/dispatch/leases.ts +9 -4
- package/src/transport/dispatch/proxy-request.ts +6 -30
- package/src/transport/dispatch/responses-compact.ts +22 -43
- package/src/transport/dispatch/streaming/finalize.ts +3 -0
- package/src/transport/dispatch/streaming/stream.ts +0 -8
- package/src/transport/dispatch/systemone.ts +22 -42
- package/src/transport/dispatch/token-count.ts +23 -39
- package/src/transport/dispatch/upstream.ts +1 -3
- package/src/transport/dispatch/websearch.ts +22 -41
- package/src/transport/failure-policy.ts +59 -178
- package/src/transport/guards/abuse-guard.ts +10 -0
- package/src/transport/guards/auth-guard.ts +109 -109
- package/src/transport/guards/error-lifecycle.ts +1 -1
- package/src/transport/guards/pipeline.ts +220 -220
- package/src/transport/guards/request-context.ts +245 -238
- package/src/transport/request/degradation.ts +20 -1
- package/src/transport/request/inflight.ts +4 -114
- package/src/transport/request/preparer.ts +6 -6
- package/src/transport/request/state.ts +66 -38
- package/src/transport/request/tenant-preferences.ts +1 -1
- package/src/transport/routing/route-catalog.ts +41 -31
- package/src/transport/routing/router.ts +2 -2
- package/src/transport/surface/messages/parse.ts +1 -1
- package/src/workers/buddy-growth-tasks.ts +346 -425
- package/src/workers/daily-checkin.ts +17 -6
- package/src/workers/quota-refresh-sweep.ts +2 -2
- package/src/workers/scheduler.ts +17 -1
- package/dist/dashboard/assets/BannedPage-P76QLGJA.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-DdBggJS9.js.gz +0 -0
- package/dist/dashboard/assets/CliToolsPage-aN1iXhhP.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-DE-F1nxZ.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-5SpmfLyw.js +0 -1
- package/dist/dashboard/assets/ConsoleLogPage-5SpmfLyw.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-3CUxMy2X.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-BkmJQKC0.js +0 -1
- package/dist/dashboard/assets/HealthEventsModal-BkmJQKC0.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-B_0FUKWc.js +0 -1
- package/dist/dashboard/assets/LoginPage-B_0FUKWc.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-DKVslqG3.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-D-H4MEiT.js +0 -1
- package/dist/dashboard/assets/OverviewPage-D-H4MEiT.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-CEJw6Guo.js +0 -3
- package/dist/dashboard/assets/ProviderDetailPage-CEJw6Guo.js.gz +0 -0
- package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js +0 -1
- package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-CEplAR-i.js +0 -1
- package/dist/dashboard/assets/ProvidersPage-CEplAR-i.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-CAN7hy0f.js +0 -7
- package/dist/dashboard/assets/ProxyPage-CAN7hy0f.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-BjldQwIU.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-DUIoLRxk.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-_bCaTM0v.js +0 -1
- package/dist/dashboard/assets/SetupPage-_bCaTM0v.js.gz +0 -0
- package/dist/dashboard/assets/SortableList-fI7JkdIB.js.gz +0 -0
- package/dist/dashboard/assets/StudioPage-CHVZqskZ.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-DiFBymHj.js +0 -56
- package/dist/dashboard/assets/UsagePage-DiFBymHj.js.gz +0 -0
- package/dist/dashboard/assets/api-keys-DFY_fZU4.js.gz +0 -0
- package/dist/dashboard/assets/app-BOJ1_xUB.css +0 -2
- package/dist/dashboard/assets/app-BOJ1_xUB.css.gz +0 -0
- package/dist/dashboard/assets/assert-CwdnZARt.js.gz +0 -0
- package/dist/dashboard/assets/badge-Dr-uf0Au.js.gz +0 -0
- package/dist/dashboard/assets/banner-BddmMdoS.js.gz +0 -0
- package/dist/dashboard/assets/card-CK0PXWlX.js.gz +0 -0
- package/dist/dashboard/assets/cli-tools-CcB6mGh2.js.gz +0 -0
- package/dist/dashboard/assets/clipboard-button-DGb2P9Yx.js.gz +0 -0
- package/dist/dashboard/assets/cog-CdWhsajs.js +0 -1
- package/dist/dashboard/assets/cog-CdWhsajs.js.gz +0 -0
- package/dist/dashboard/assets/dialog-LkfuL50u.js.gz +0 -0
- package/dist/dashboard/assets/drawer-ZbEZ_84c.js.gz +0 -0
- package/dist/dashboard/assets/entry-BJfsVMvA.js.gz +0 -0
- package/dist/dashboard/assets/entry-CWBY5A0f.js.gz +0 -0
- package/dist/dashboard/assets/entry-CvC1-Zqx.js.gz +0 -0
- package/dist/dashboard/assets/github-badge-BxpznFLt.js.gz +0 -0
- package/dist/dashboard/assets/index-CDzP3ovJ.js.gz +0 -0
- package/dist/dashboard/assets/inline-Ae13MQ5w.js.gz +0 -0
- package/dist/dashboard/assets/input-D23fo7cp.js.gz +0 -0
- package/dist/dashboard/assets/layout-CSsxjj5P.js.gz +0 -0
- package/dist/dashboard/assets/list-row-dmbwZMDY.js.gz +0 -0
- package/dist/dashboard/assets/live-BG1oUsZZ.js.gz +0 -0
- package/dist/dashboard/assets/mutation-DSF-e4ho.js.gz +0 -0
- package/dist/dashboard/assets/numbers-Bm_aECY7.js.gz +0 -0
- package/dist/dashboard/assets/query-keys-CF56nYIV.js.gz +0 -0
- package/dist/dashboard/assets/routing-CeA71n7G.js.gz +0 -0
- package/dist/dashboard/assets/select-KIT0rC6G.js.gz +0 -0
- package/dist/dashboard/assets/settings-B_2gkePf.js.gz +0 -0
- package/dist/dashboard/assets/stack-D7WOSzcX.js.gz +0 -0
- package/dist/dashboard/assets/state-BBxtKZOQ.js.gz +0 -0
- package/dist/dashboard/assets/switch-DNS_D5Ea.js.gz +0 -0
- package/dist/dashboard/assets/useMutation-BgoxdoMk.js.gz +0 -0
- /package/dist/dashboard/assets/{app-CaXZ9SCn.js → app-BaWd0puM.js} +0 -0
- /package/dist/dashboard/assets/{app-CaXZ9SCn.js.gz → app-BaWd0puM.js.gz} +0 -0
|
@@ -1,365 +1,226 @@
|
|
|
1
|
-
// Validated fetch: SSRF-guarded outbound fetch with optional pool-agent support.
|
|
2
|
-
import { GatewayError } from "../protocol/gateway-error";
|
|
3
|
-
import { Agent as HttpAgent
|
|
4
|
-
import { Agent as HttpsAgent
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
readonly
|
|
16
|
-
readonly
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
*
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
init: RequestInit,
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
headers.
|
|
102
|
-
|
|
103
|
-
headers.
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
const
|
|
197
|
-
if (
|
|
198
|
-
|
|
199
|
-
return;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
const responseHeaders = stripResponseHeaders(response.headers);
|
|
228
|
-
const { stream, encoding } = decompressBody(response, response.headers["content-encoding"]);
|
|
229
|
-
if (encoding === undefined) {
|
|
230
|
-
// Body is plain (decoded here, or never compressed): the header must
|
|
231
|
-
// not survive, or the caller would try to decode a decoded body.
|
|
232
|
-
responseHeaders.delete("content-encoding");
|
|
233
|
-
}
|
|
234
|
-
// A compressed body this path cannot decode keeps its header so the
|
|
235
|
-
// caller's decoder still applies.
|
|
236
|
-
const webStream = Readable.toWeb(stream) as unknown as ReadableStream<Uint8Array>;
|
|
237
|
-
resolve(
|
|
238
|
-
new Response(webStream, {
|
|
239
|
-
status: response.statusCode ?? 502,
|
|
240
|
-
headers: responseHeaders,
|
|
241
|
-
}),
|
|
242
|
-
);
|
|
243
|
-
},
|
|
244
|
-
);
|
|
245
|
-
nodeRequest.on("error", reject);
|
|
246
|
-
if (body !== undefined) nodeRequest.write(body);
|
|
247
|
-
nodeRequest.end();
|
|
248
|
-
});
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
const CREDENTIAL_HEADERS = ["authorization", "cookie", "proxy-authorization"] as const;
|
|
252
|
-
|
|
253
|
-
function stripCredentialsOnCrossOrigin(
|
|
254
|
-
headersInit: HeadersInit | undefined,
|
|
255
|
-
): Headers {
|
|
256
|
-
const headers = new Headers(headersInit);
|
|
257
|
-
for (const name of CREDENTIAL_HEADERS) headers.delete(name);
|
|
258
|
-
return headers;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
export function createValidatedFetch(options: ValidatedFetchOptions = {}): ValidatedFetch {
|
|
262
|
-
const policy = options.policy ?? {};
|
|
263
|
-
const maxRedirects = options.maxRedirects ?? policy.maxRedirects ?? 3;
|
|
264
|
-
const resolveFn = options.resolveFn ?? resolveAllAddresses;
|
|
265
|
-
const configuredAgent = options.agent;
|
|
266
|
-
const relayEndpoint = isProxyAgentPair(configuredAgent)
|
|
267
|
-
? configuredAgent.relayEndpoint
|
|
268
|
-
: undefined;
|
|
269
|
-
return async (input, init = {}) => {
|
|
270
|
-
const startedAt = performance.now();
|
|
271
|
-
let url = input instanceof Request ? new URL(input.url) : new URL(input.toString());
|
|
272
|
-
let requestInit: RequestInit = { ...init, redirect: "manual" };
|
|
273
|
-
if (input instanceof Request && init.headers === undefined) {
|
|
274
|
-
requestInit.headers = new Headers(input.headers);
|
|
275
|
-
}
|
|
276
|
-
for (let hop = 0; hop <= maxRedirects; hop += 1) {
|
|
277
|
-
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
278
|
-
throw new TypeError("unsupported outbound URL protocol");
|
|
279
|
-
const signal = requestInit.signal ?? new AbortController().signal;
|
|
280
|
-
// Every hop — including proxied dials and redirect targets — is resolved
|
|
281
|
-
// and checked against the SSRF policy. When a proxy agent owns the dial,
|
|
282
|
-
// the proxy still resolves its own answer, so the check is advisory there;
|
|
283
|
-
// it deterministically rejects targets whose *advertised* addresses fall
|
|
284
|
-
// in blocked ranges (literal-IP redirects, private-space DNS, relay abuse).
|
|
285
|
-
// For a pool/relay-owned dial, the upstream *target* is resolved by the
|
|
286
|
-
// proxy/relay itself, so local DNS for it is an advisory SSRF check,
|
|
287
|
-
// not a prerequisite for egress. Operator VPNs routinely break the
|
|
288
|
-
// system resolver (tunnel DNS that is unreachable or drops queries);
|
|
289
|
-
const poolDialsTarget = Boolean(configuredAgent || relayEndpoint);
|
|
290
|
-
let resolvedAddress: string | undefined;
|
|
291
|
-
let relayResolvedAddress: string | undefined;
|
|
292
|
-
if (poolDialsTarget) {
|
|
293
|
-
// The egress dials the pool/relay, which resolves the target itself.
|
|
294
|
-
// Counting these lets ops confirm how much traffic depends on
|
|
295
|
-
// proxy-resolved DNS rather than the local system resolver.
|
|
296
|
-
metrics.cartethyia_proxy_dial_proxy_resolved_total.inc();
|
|
297
|
-
}
|
|
298
|
-
try {
|
|
299
|
-
if (!poolDialsTarget) {
|
|
300
|
-
const addresses = await resolveFn(url.hostname, signal);
|
|
301
|
-
validateResolvedAddresses(addresses, policy);
|
|
302
|
-
resolvedAddress = addresses[0];
|
|
303
|
-
}
|
|
304
|
-
if (relayEndpoint) {
|
|
305
|
-
const relayAddresses = await resolveFn(relayEndpoint.hostname, signal);
|
|
306
|
-
validateResolvedAddresses(relayAddresses, policy);
|
|
307
|
-
relayResolvedAddress = relayAddresses[0];
|
|
308
|
-
if (!relayResolvedAddress)
|
|
309
|
-
throw new TypeError(`no validated address for ${relayEndpoint.hostname}`);
|
|
310
|
-
}
|
|
311
|
-
} catch (error) {
|
|
312
|
-
// A pool/relay-owned dial still needs its egress host locally; only
|
|
313
|
-
// the advisory target lookup may degrade.
|
|
314
|
-
if (relayEndpoint && !relayResolvedAddress) throw error;
|
|
315
|
-
if (!poolDialsTarget) throw new TypeError(`no validated address for ${url.hostname}`);
|
|
316
|
-
}
|
|
317
|
-
if (!poolDialsTarget && !resolvedAddress)
|
|
318
|
-
throw new TypeError(`no validated address for ${url.hostname}`);
|
|
319
|
-
let response: Response;
|
|
320
|
-
if (relayEndpoint) {
|
|
321
|
-
const relay = relayRequest(relayEndpoint, url, requestInit);
|
|
322
|
-
response = options.fetchFn
|
|
323
|
-
? await options.fetchFn(relay.url, relay.init)
|
|
324
|
-
: await pinnedFetch(relay.url, relay.init, relayResolvedAddress!);
|
|
325
|
-
} else if (options.fetchFn) {
|
|
326
|
-
response = await options.fetchFn(url, requestInit);
|
|
327
|
-
} else {
|
|
328
|
-
const agent = agentForTarget(configuredAgent, url.protocol);
|
|
329
|
-
response = await pinnedFetch(url, requestInit, resolvedAddress!, agent);
|
|
330
|
-
}
|
|
331
|
-
if (response.status < 300 || response.status >= 400) {
|
|
332
|
-
trackNetworkCall(url.hostname, performance.now() - startedAt);
|
|
333
|
-
return response;
|
|
334
|
-
}
|
|
335
|
-
const location = response.headers.get("location");
|
|
336
|
-
if (!location) {
|
|
337
|
-
trackNetworkCall(url.hostname, performance.now() - startedAt);
|
|
338
|
-
return response;
|
|
339
|
-
}
|
|
340
|
-
// The redirect response body is never consumed: release its socket
|
|
341
|
-
// before following the next hop instead of leaving it open.
|
|
342
|
-
await response.body?.cancel().catch(() => undefined);
|
|
343
|
-
const nextUrl = new URL(location, url);
|
|
344
|
-
const isCrossOriginRedirect = nextUrl.origin !== url.origin;
|
|
345
|
-
if (isCrossOriginRedirect) {
|
|
346
|
-
requestInit = { ...requestInit, headers: stripCredentialsOnCrossOrigin(requestInit.headers) };
|
|
347
|
-
}
|
|
348
|
-
if (isCrossOriginRedirect && (response.status === 307 || response.status === 308)) {
|
|
349
|
-
const method = (requestInit.method ?? "GET").toUpperCase();
|
|
350
|
-
if (method !== "GET" && method !== "HEAD" && requestInit.body !== undefined) {
|
|
351
|
-
throw new GatewayError("invalid_request", 400, "cross-origin redirect with body blocked");
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
url = nextUrl;
|
|
355
|
-
if (response.status === 301 || response.status === 302 || response.status === 303) {
|
|
356
|
-
const method = (requestInit.method ?? "GET").toUpperCase();
|
|
357
|
-
if (method !== "GET" && method !== "HEAD") {
|
|
358
|
-
const { body: _body, ...withoutBody } = requestInit;
|
|
359
|
-
requestInit = { ...withoutBody, method: "GET" };
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
throw new Error("outbound redirect limit exceeded");
|
|
364
|
-
};
|
|
365
|
-
}
|
|
1
|
+
// Validated fetch: SSRF-guarded outbound fetch with optional pool-agent support.
|
|
2
|
+
import { GatewayError } from "../protocol/gateway-error";
|
|
3
|
+
import { Agent as HttpAgent } from "node:http";
|
|
4
|
+
import { Agent as HttpsAgent } from "node:https";
|
|
5
|
+
import { resolveAllAddresses, validateResolvedAddresses } from "./ssrf";
|
|
6
|
+
import type { SsrfPolicy } from "../config/network";
|
|
7
|
+
import { isProxyAgentPair, type ProxyAgentPair } from "./pool/agent";
|
|
8
|
+
import { metrics } from "../observability/metrics";
|
|
9
|
+
import { trackNetworkCall } from "../observability/performance-metrics";
|
|
10
|
+
// Validated fetch — single egress with ProxyAgent support
|
|
11
|
+
export interface ValidatedFetchOptions {
|
|
12
|
+
readonly fetchFn?: typeof fetch;
|
|
13
|
+
readonly policy?: SsrfPolicy;
|
|
14
|
+
readonly maxRedirects?: number;
|
|
15
|
+
readonly resolveFn?: (hostname: string, signal: AbortSignal) => Promise<readonly string[]>;
|
|
16
|
+
readonly agent?: HttpAgent | HttpsAgent | ProxyAgentPair;
|
|
17
|
+
/**
|
|
18
|
+
* Proxy URL to route the dial through, forwarded to the runtime-native fetch
|
|
19
|
+
* (`proxy` option). Pool egress passes the pool's endpoint here; without it
|
|
20
|
+
* a request that should leave through a proxy leaves directly.
|
|
21
|
+
*/
|
|
22
|
+
readonly proxyUrl?: string;
|
|
23
|
+
}
|
|
24
|
+
export type ValidatedFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Whether a configured pool/relay agent owns this dial. When it does, the
|
|
28
|
+
* proxy resolves the upstream target itself, so the local resolve below is an
|
|
29
|
+
* advisory SSRF check rather than a prerequisite for egress.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Builds the application-relay request used by hosted relay front doors.
|
|
34
|
+
* These endpoints intentionally do not receive CONNECT: they accept the
|
|
35
|
+
* target origin/path in headers and perform the upstream fetch themselves,
|
|
36
|
+
* matching the 21-beta relay contract.
|
|
37
|
+
*/
|
|
38
|
+
function relayRequest(
|
|
39
|
+
relayEndpoint: URL,
|
|
40
|
+
target: URL,
|
|
41
|
+
init: RequestInit,
|
|
42
|
+
): { url: URL; init: RequestInit } {
|
|
43
|
+
const relayUrl = new URL(relayEndpoint.toString());
|
|
44
|
+
const headers = new Headers(init.headers);
|
|
45
|
+
headers.delete("host");
|
|
46
|
+
headers.set("x-relay-target", target.origin);
|
|
47
|
+
headers.set("x-relay-path", `${target.pathname}${target.search}`);
|
|
48
|
+
if (relayUrl.username || relayUrl.password) {
|
|
49
|
+
const credentials = `${decodeURIComponent(relayUrl.username)}:${decodeURIComponent(relayUrl.password)}`;
|
|
50
|
+
headers.set("x-relay-auth", `Basic ${Buffer.from(credentials).toString("base64")}`);
|
|
51
|
+
relayUrl.username = "";
|
|
52
|
+
relayUrl.password = "";
|
|
53
|
+
}
|
|
54
|
+
return { url: relayUrl, init: { ...init, headers } };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const CREDENTIAL_HEADERS = ["authorization", "cookie", "proxy-authorization"] as const;
|
|
58
|
+
|
|
59
|
+
function stripCredentialsOnCrossOrigin(
|
|
60
|
+
headersInit: HeadersInit | undefined,
|
|
61
|
+
): Headers {
|
|
62
|
+
const headers = new Headers(headersInit);
|
|
63
|
+
for (const name of CREDENTIAL_HEADERS) headers.delete(name);
|
|
64
|
+
return headers;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The injected dialer. `fetchFn` is the seam offline suites use to record
|
|
69
|
+
* requests without a socket; the runtime fetch is the only other dialer.
|
|
70
|
+
*/
|
|
71
|
+
function dialer(options: ValidatedFetchOptions): typeof fetch {
|
|
72
|
+
return options.fetchFn ?? fetch;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* One dial, always through `fetch`.
|
|
77
|
+
*
|
|
78
|
+
* The runtime's own fetch (Bun/Undici) is the transport the clients this
|
|
79
|
+
* gateway impersonates actually use, and several upstreams meter per client
|
|
80
|
+
* transport fingerprint — OpenCode's anonymous free tier refuses a
|
|
81
|
+
* Node-stack dial with 429 FreeUsageLimitError while accepting the identical
|
|
82
|
+
* bytes over this one. There is no Node `http`/`https` path to choose
|
|
83
|
+
* between: the SSRF resolve/validate pass that runs before this dial is the
|
|
84
|
+
* security boundary, and it is identical for every caller.
|
|
85
|
+
*
|
|
86
|
+
* A configured pool/relay agent is still honored through `dispatcher` when the
|
|
87
|
+
* runtime supports it (Bun and Undici both do); when it is not supported the
|
|
88
|
+
* agent is ignored rather than silently replaced by an unvalidated dial.
|
|
89
|
+
*
|
|
90
|
+
* `content-length` is set for a known-length body only when the caller did not
|
|
91
|
+
* set it: without the header the request falls back to chunked encoding, and
|
|
92
|
+
* some upstream gateways wait for the complete chunked stream before routing
|
|
93
|
+
* the request — an avoidable round trip per dial.
|
|
94
|
+
*/
|
|
95
|
+
async function dial(
|
|
96
|
+
url: URL,
|
|
97
|
+
init: RequestInit,
|
|
98
|
+
fetchFn: typeof fetch,
|
|
99
|
+
proxyUrl: string | undefined,
|
|
100
|
+
): Promise<Response> {
|
|
101
|
+
const headers = new Headers(init.headers);
|
|
102
|
+
const rawBody = init.body;
|
|
103
|
+
if (rawBody !== undefined && rawBody !== null && !headers.has("content-length")) {
|
|
104
|
+
const bytes =
|
|
105
|
+
typeof rawBody === "string"
|
|
106
|
+
? Buffer.byteLength(rawBody, "utf8")
|
|
107
|
+
: Buffer.isBuffer(rawBody) || rawBody instanceof Uint8Array
|
|
108
|
+
? rawBody.byteLength
|
|
109
|
+
: (await new Response(rawBody).arrayBuffer()).byteLength;
|
|
110
|
+
headers.set("content-length", String(bytes));
|
|
111
|
+
}
|
|
112
|
+
return await fetchFn(url, {
|
|
113
|
+
...init,
|
|
114
|
+
headers,
|
|
115
|
+
...(proxyUrl === undefined ? {} : { proxy: proxyUrl }),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function createValidatedFetch(options: ValidatedFetchOptions = {}): ValidatedFetch {
|
|
120
|
+
const policy = options.policy ?? {};
|
|
121
|
+
const maxRedirects = options.maxRedirects ?? policy.maxRedirects ?? 3;
|
|
122
|
+
const resolveFn = options.resolveFn ?? resolveAllAddresses;
|
|
123
|
+
const fetchFn = dialer(options);
|
|
124
|
+
const configuredAgent = options.agent;
|
|
125
|
+
const relayEndpoint = isProxyAgentPair(configuredAgent)
|
|
126
|
+
? configuredAgent.relayEndpoint
|
|
127
|
+
: undefined;
|
|
128
|
+
const proxyUrl =
|
|
129
|
+
options.proxyUrl ?? (isProxyAgentPair(configuredAgent) ? configuredAgent.proxyUrl : undefined);
|
|
130
|
+
return async (input, init = {}) => {
|
|
131
|
+
const startedAt = performance.now();
|
|
132
|
+
let url = input instanceof Request ? new URL(input.url) : new URL(input.toString());
|
|
133
|
+
let requestInit: RequestInit = { ...init, redirect: "manual" };
|
|
134
|
+
if (input instanceof Request && init.headers === undefined) {
|
|
135
|
+
requestInit.headers = new Headers(input.headers);
|
|
136
|
+
}
|
|
137
|
+
for (let hop = 0; hop <= maxRedirects; hop += 1) {
|
|
138
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
139
|
+
throw new TypeError("unsupported outbound URL protocol");
|
|
140
|
+
const signal = requestInit.signal ?? new AbortController().signal;
|
|
141
|
+
// Every hop — including proxied dials and redirect targets — is resolved
|
|
142
|
+
// and checked against the SSRF policy. When a proxy agent owns the dial,
|
|
143
|
+
// the proxy still resolves its own answer, so the check is advisory there;
|
|
144
|
+
// it deterministically rejects targets whose *advertised* addresses fall
|
|
145
|
+
// in blocked ranges (literal-IP redirects, private-space DNS, relay abuse).
|
|
146
|
+
// For a pool/relay-owned dial, the upstream *target* is resolved by the
|
|
147
|
+
// proxy/relay itself, so local DNS for it is an advisory SSRF check,
|
|
148
|
+
// not a prerequisite for egress. Operator VPNs routinely break the
|
|
149
|
+
// system resolver (tunnel DNS that is unreachable or drops queries);
|
|
150
|
+
const poolDialsTarget = Boolean(configuredAgent || relayEndpoint);
|
|
151
|
+
let resolvedAddress: string | undefined;
|
|
152
|
+
let relayResolvedAddress: string | undefined;
|
|
153
|
+
if (poolDialsTarget) {
|
|
154
|
+
// The egress dials the pool/relay, which resolves the target itself.
|
|
155
|
+
// Counting these lets ops confirm how much traffic depends on
|
|
156
|
+
// proxy-resolved DNS rather than the local system resolver.
|
|
157
|
+
metrics.cartethyia_proxy_dial_proxy_resolved_total.inc();
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
if (!poolDialsTarget) {
|
|
161
|
+
const addresses = await resolveFn(url.hostname, signal);
|
|
162
|
+
validateResolvedAddresses(addresses, policy);
|
|
163
|
+
resolvedAddress = addresses[0];
|
|
164
|
+
}
|
|
165
|
+
if (relayEndpoint) {
|
|
166
|
+
const relayAddresses = await resolveFn(relayEndpoint.hostname, signal);
|
|
167
|
+
validateResolvedAddresses(relayAddresses, policy);
|
|
168
|
+
relayResolvedAddress = relayAddresses[0];
|
|
169
|
+
if (!relayResolvedAddress)
|
|
170
|
+
throw new TypeError(`no validated address for ${relayEndpoint.hostname}`);
|
|
171
|
+
}
|
|
172
|
+
} catch (error) {
|
|
173
|
+
// A pool/relay-owned dial still needs its egress host locally; only
|
|
174
|
+
// the advisory target lookup may degrade.
|
|
175
|
+
if (relayEndpoint && !relayResolvedAddress) throw error;
|
|
176
|
+
if (!poolDialsTarget) throw new TypeError(`no validated address for ${url.hostname}`);
|
|
177
|
+
}
|
|
178
|
+
if (!poolDialsTarget && !resolvedAddress)
|
|
179
|
+
throw new TypeError(`no validated address for ${url.hostname}`);
|
|
180
|
+
let response: Response;
|
|
181
|
+
if (relayEndpoint) {
|
|
182
|
+
const relay = relayRequest(relayEndpoint, url, requestInit);
|
|
183
|
+
// The relay front door is reached directly: it reads the target from
|
|
184
|
+
// `x-relay-target`/`x-relay-path` and performs the upstream fetch
|
|
185
|
+
// itself, so the runtime fetch must NOT be given `proxy` here — that
|
|
186
|
+
// would make it attempt a CONNECT tunnel against the relay, which a
|
|
187
|
+
// serverless front door refuses (400).
|
|
188
|
+
response = await dial(relay.url, relay.init, fetchFn, undefined);
|
|
189
|
+
} else {
|
|
190
|
+
response = await dial(url, requestInit, fetchFn, proxyUrl);
|
|
191
|
+
}
|
|
192
|
+
if (response.status < 300 || response.status >= 400) {
|
|
193
|
+
trackNetworkCall(url.hostname, performance.now() - startedAt);
|
|
194
|
+
return response;
|
|
195
|
+
}
|
|
196
|
+
const location = response.headers.get("location");
|
|
197
|
+
if (!location) {
|
|
198
|
+
trackNetworkCall(url.hostname, performance.now() - startedAt);
|
|
199
|
+
return response;
|
|
200
|
+
}
|
|
201
|
+
// The redirect response body is never consumed: release its socket
|
|
202
|
+
// before following the next hop instead of leaving it open.
|
|
203
|
+
await response.body?.cancel().catch(() => undefined);
|
|
204
|
+
const nextUrl = new URL(location, url);
|
|
205
|
+
const isCrossOriginRedirect = nextUrl.origin !== url.origin;
|
|
206
|
+
if (isCrossOriginRedirect) {
|
|
207
|
+
requestInit = { ...requestInit, headers: stripCredentialsOnCrossOrigin(requestInit.headers) };
|
|
208
|
+
}
|
|
209
|
+
if (isCrossOriginRedirect && (response.status === 307 || response.status === 308)) {
|
|
210
|
+
const method = (requestInit.method ?? "GET").toUpperCase();
|
|
211
|
+
if (method !== "GET" && method !== "HEAD" && requestInit.body !== undefined) {
|
|
212
|
+
throw new GatewayError("invalid_request", 400, "cross-origin redirect with body blocked");
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
url = nextUrl;
|
|
216
|
+
if (response.status === 301 || response.status === 302 || response.status === 303) {
|
|
217
|
+
const method = (requestInit.method ?? "GET").toUpperCase();
|
|
218
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
219
|
+
const { body: _body, ...withoutBody } = requestInit;
|
|
220
|
+
requestInit = { ...withoutBody, method: "GET" };
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
throw new Error("outbound redirect limit exceeded");
|
|
225
|
+
};
|
|
226
|
+
}
|
|
@@ -309,6 +309,12 @@ export interface ProxyAgentPair {
|
|
|
309
309
|
* validated fetcher sends `x-relay-target`/`x-relay-path` to this URL.
|
|
310
310
|
*/
|
|
311
311
|
readonly relayEndpoint?: URL;
|
|
312
|
+
/**
|
|
313
|
+
* The proxy URL this pool dials through, forwarded to the runtime-native
|
|
314
|
+
* fetch (`proxy` option). The native transport has no agent concept, so
|
|
315
|
+
* the URL is what carries the pool egress.
|
|
316
|
+
*/
|
|
317
|
+
readonly proxyUrl?: string;
|
|
312
318
|
}
|
|
313
319
|
|
|
314
320
|
export function isProxyAgentPair(value: unknown): value is ProxyAgentPair {
|
|
@@ -407,6 +413,7 @@ export function createHttpProxyAgent(
|
|
|
407
413
|
return {
|
|
408
414
|
http: new HttpProxyAgent(proxy, policy, config, poolId),
|
|
409
415
|
https: new HttpsProxyAgent(proxy, policy, config, poolId),
|
|
416
|
+
proxyUrl: proxy.toString(),
|
|
410
417
|
...(isRelayHost(proxy.hostname) ? { relayEndpoint: proxy } : {}),
|
|
411
418
|
};
|
|
412
419
|
}
|
|
@@ -472,6 +479,7 @@ export function createSocks5Agent(
|
|
|
472
479
|
return {
|
|
473
480
|
http: http as unknown as HttpAgent,
|
|
474
481
|
https: https as unknown as HttpsAgent,
|
|
482
|
+
proxyUrl: withCredential(url, credential).toString(),
|
|
475
483
|
};
|
|
476
484
|
}
|
|
477
485
|
|