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.
Files changed (320) hide show
  1. package/.env.example +107 -106
  2. package/README.md +25 -2
  3. package/dist/dashboard/assets/{BannedPage-P76QLGJA.js → BannedPage-D-J75hBM.js} +1 -1
  4. package/dist/dashboard/assets/BannedPage-D-J75hBM.js.gz +0 -0
  5. package/dist/dashboard/assets/{CliToolDetailPage-DdBggJS9.js → CliToolDetailPage-CVe6RaBH.js} +1 -1
  6. package/dist/dashboard/assets/CliToolDetailPage-CVe6RaBH.js.gz +0 -0
  7. package/dist/dashboard/assets/{CliToolsPage-aN1iXhhP.js → CliToolsPage-Dd8ZSamp.js} +1 -1
  8. package/dist/dashboard/assets/CliToolsPage-Dd8ZSamp.js.gz +0 -0
  9. package/dist/dashboard/assets/{CombosPage-DE-F1nxZ.js → CombosPage-QoJ6foxo.js} +1 -1
  10. package/dist/dashboard/assets/CombosPage-QoJ6foxo.js.gz +0 -0
  11. package/dist/dashboard/assets/ConsoleLogPage-DpNRay4C.js +1 -0
  12. package/dist/dashboard/assets/ConsoleLogPage-DpNRay4C.js.gz +0 -0
  13. package/dist/dashboard/assets/{CustomizationPage-3CUxMy2X.js → CustomizationPage-CKUFTcW4.js} +1 -1
  14. package/dist/dashboard/assets/CustomizationPage-CKUFTcW4.js.gz +0 -0
  15. package/dist/dashboard/assets/HealthEventsModal-CzSLiDzr.js +1 -0
  16. package/dist/dashboard/assets/HealthEventsModal-CzSLiDzr.js.gz +0 -0
  17. package/dist/dashboard/assets/LoginPage-DubdQcvX.js +1 -0
  18. package/dist/dashboard/assets/LoginPage-DubdQcvX.js.gz +0 -0
  19. package/dist/dashboard/assets/{ModelPicker-DKVslqG3.js → ModelPicker-BH8Tehdi.js} +1 -1
  20. package/dist/dashboard/assets/ModelPicker-BH8Tehdi.js.gz +0 -0
  21. package/dist/dashboard/assets/OverviewPage-DuWzAQFL.js +1 -0
  22. package/dist/dashboard/assets/OverviewPage-DuWzAQFL.js.gz +0 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-BKEKe2Fm.js +4 -0
  24. package/dist/dashboard/assets/ProviderDetailPage-BKEKe2Fm.js.gz +0 -0
  25. package/dist/dashboard/assets/ProviderIcon-FB6yS4v1.js +1 -0
  26. package/dist/dashboard/assets/ProviderIcon-FB6yS4v1.js.gz +0 -0
  27. package/dist/dashboard/assets/ProvidersPage-Bwotu8UJ.js +1 -0
  28. package/dist/dashboard/assets/ProvidersPage-Bwotu8UJ.js.gz +0 -0
  29. package/dist/dashboard/assets/ProxyPage-D7hpWbg9.js +9 -0
  30. package/dist/dashboard/assets/ProxyPage-D7hpWbg9.js.gz +0 -0
  31. package/dist/dashboard/assets/{QuotaPage-BjldQwIU.js → QuotaPage-ATpaxrbl.js} +1 -1
  32. package/dist/dashboard/assets/QuotaPage-ATpaxrbl.js.gz +0 -0
  33. package/dist/dashboard/assets/{SettingsPage-DUIoLRxk.js → SettingsPage-CETophpx.js} +1 -1
  34. package/dist/dashboard/assets/SettingsPage-CETophpx.js.gz +0 -0
  35. package/dist/dashboard/assets/SetupPage-BdgECjqq.js +1 -0
  36. package/dist/dashboard/assets/SetupPage-BdgECjqq.js.gz +0 -0
  37. package/dist/dashboard/assets/{SortableList-fI7JkdIB.js → SortableList-CAK-EN9z.js} +1 -1
  38. package/dist/dashboard/assets/SortableList-CAK-EN9z.js.gz +0 -0
  39. package/dist/dashboard/assets/{StudioPage-CHVZqskZ.js → StudioPage-B7W2_RhT.js} +3 -3
  40. package/dist/dashboard/assets/StudioPage-B7W2_RhT.js.gz +0 -0
  41. package/dist/dashboard/assets/UsagePage-6T4EFDjW.js +56 -0
  42. package/dist/dashboard/assets/UsagePage-6T4EFDjW.js.gz +0 -0
  43. package/dist/dashboard/assets/{api-keys-DFY_fZU4.js → api-keys-CUi6UXsR.js} +1 -1
  44. package/dist/dashboard/assets/api-keys-CUi6UXsR.js.gz +0 -0
  45. package/dist/dashboard/assets/app-KvsUJUMF.css +2 -0
  46. package/dist/dashboard/assets/app-KvsUJUMF.css.gz +0 -0
  47. package/dist/dashboard/assets/{assert-CwdnZARt.js → assert-67xXH5rf.js} +1 -1
  48. package/dist/dashboard/assets/assert-67xXH5rf.js.gz +0 -0
  49. package/dist/dashboard/assets/{badge-Dr-uf0Au.js → badge-DC2L43Mj.js} +1 -1
  50. package/dist/dashboard/assets/badge-DC2L43Mj.js.gz +0 -0
  51. package/dist/dashboard/assets/{banner-BddmMdoS.js → banner-BBVcb0J3.js} +1 -1
  52. package/dist/dashboard/assets/banner-BBVcb0J3.js.gz +0 -0
  53. package/dist/dashboard/assets/{card-CK0PXWlX.js → card-Cca1XC6G.js} +1 -1
  54. package/dist/dashboard/assets/card-Cca1XC6G.js.gz +0 -0
  55. package/dist/dashboard/assets/{cli-tools-CcB6mGh2.js → cli-tools-KvsWa1Jl.js} +1 -1
  56. package/dist/dashboard/assets/cli-tools-KvsWa1Jl.js.gz +0 -0
  57. package/dist/dashboard/assets/{clipboard-button-DGb2P9Yx.js → clipboard-button-DUzEbynq.js} +1 -1
  58. package/dist/dashboard/assets/clipboard-button-DUzEbynq.js.gz +0 -0
  59. package/dist/dashboard/assets/{dialog-LkfuL50u.js → dialog-DFvWhwTa.js} +1 -1
  60. package/dist/dashboard/assets/dialog-DFvWhwTa.js.gz +0 -0
  61. package/dist/dashboard/assets/{drawer-ZbEZ_84c.js → drawer-CFM2sMX1.js} +1 -1
  62. package/dist/dashboard/assets/drawer-CFM2sMX1.js.gz +0 -0
  63. package/dist/dashboard/assets/{entry-CWBY5A0f.js → entry-BLBLxX0h.js} +3 -3
  64. package/dist/dashboard/assets/entry-BLBLxX0h.js.gz +0 -0
  65. package/dist/dashboard/assets/{entry-BJfsVMvA.js → entry-Bqy8RFvM.js} +1 -1
  66. package/dist/dashboard/assets/entry-Bqy8RFvM.js.gz +0 -0
  67. package/dist/dashboard/assets/{entry-CvC1-Zqx.js → entry-_xHo5x6r.js} +1 -1
  68. package/dist/dashboard/assets/entry-_xHo5x6r.js.gz +0 -0
  69. package/dist/dashboard/assets/formatters-mpBPqAQ8.js +1 -0
  70. package/dist/dashboard/assets/formatters-mpBPqAQ8.js.gz +0 -0
  71. package/dist/dashboard/assets/{github-badge-BxpznFLt.js → github-badge-C9e8Ts7e.js} +1 -1
  72. package/dist/dashboard/assets/github-badge-C9e8Ts7e.js.gz +0 -0
  73. package/dist/dashboard/assets/{index-CDzP3ovJ.js → index-D6QzEC_n.js} +2 -2
  74. package/dist/dashboard/assets/index-D6QzEC_n.js.gz +0 -0
  75. package/dist/dashboard/assets/{inline-Ae13MQ5w.js → inline-BFUcvllj.js} +1 -1
  76. package/dist/dashboard/assets/inline-BFUcvllj.js.gz +0 -0
  77. package/dist/dashboard/assets/{input-D23fo7cp.js → input-C_ILAvBb.js} +1 -1
  78. package/dist/dashboard/assets/input-C_ILAvBb.js.gz +0 -0
  79. package/dist/dashboard/assets/{layout-CSsxjj5P.js → layout-CBIyT6Rt.js} +1 -1
  80. package/dist/dashboard/assets/layout-CBIyT6Rt.js.gz +0 -0
  81. package/dist/dashboard/assets/{list-row-dmbwZMDY.js → list-row-ebJ_-Nsb.js} +1 -1
  82. package/dist/dashboard/assets/list-row-ebJ_-Nsb.js.gz +0 -0
  83. package/dist/dashboard/assets/{live-BG1oUsZZ.js → live-D0mm--ME.js} +1 -1
  84. package/dist/dashboard/assets/live-D0mm--ME.js.gz +0 -0
  85. package/dist/dashboard/assets/{mutation-DSF-e4ho.js → mutation-sMD965oO.js} +1 -1
  86. package/dist/dashboard/assets/mutation-sMD965oO.js.gz +0 -0
  87. package/dist/dashboard/assets/{numbers-Bm_aECY7.js → numbers-BO9dKaFr.js} +1 -1
  88. package/dist/dashboard/assets/numbers-BO9dKaFr.js.gz +0 -0
  89. package/dist/dashboard/assets/providers/inception.svg +5 -0
  90. package/dist/dashboard/assets/providers/inception.svg.gz +0 -0
  91. package/dist/dashboard/assets/{query-keys-CF56nYIV.js → query-keys-CfL_Zdoh.js} +1 -1
  92. package/dist/dashboard/assets/query-keys-CfL_Zdoh.js.gz +0 -0
  93. package/dist/dashboard/assets/{routing-CeA71n7G.js → routing-BnIpi1IJ.js} +1 -1
  94. package/dist/dashboard/assets/routing-BnIpi1IJ.js.gz +0 -0
  95. package/dist/dashboard/assets/{select-KIT0rC6G.js → select-Cj5Nw43g.js} +1 -1
  96. package/dist/dashboard/assets/select-Cj5Nw43g.js.gz +0 -0
  97. package/dist/dashboard/assets/{settings-B_2gkePf.js → settings-BAFhDgX8.js} +1 -1
  98. package/dist/dashboard/assets/settings-BAFhDgX8.js.gz +0 -0
  99. package/dist/dashboard/assets/{stack-D7WOSzcX.js → stack-EMrOMt4I.js} +1 -1
  100. package/dist/dashboard/assets/stack-EMrOMt4I.js.gz +0 -0
  101. package/dist/dashboard/assets/{state-BBxtKZOQ.js → state-CWoo7ty5.js} +1 -1
  102. package/dist/dashboard/assets/state-CWoo7ty5.js.gz +0 -0
  103. package/dist/dashboard/assets/{switch-DNS_D5Ea.js → switch-CCjT6IkF.js} +1 -1
  104. package/dist/dashboard/assets/switch-CCjT6IkF.js.gz +0 -0
  105. package/dist/dashboard/assets/{useMutation-BgoxdoMk.js → useMutation-DjkLHUcE.js} +1 -1
  106. package/dist/dashboard/assets/useMutation-DjkLHUcE.js.gz +0 -0
  107. package/dist/dashboard/index.html +1 -1
  108. package/dist/dashboard/index.html.gz +0 -0
  109. package/dist/dashboard/precompressed-assets.json +54 -53
  110. package/migrations/full-postgres/0024_add_pool_provider_cooldowns.sql +12 -0
  111. package/migrations/full-postgres/0025_add_pool_random_strategy.sql +3 -0
  112. package/migrations/lite-sqlite3/0024_add_pool_provider_cooldowns.sql +12 -0
  113. package/migrations/lite-sqlite3/0025_add_pool_random_strategy.sql +20 -0
  114. package/package.json +96 -96
  115. package/scripts/cli/console-menu.ts +5 -2
  116. package/scripts/cli/tray.ts +21 -6
  117. package/src/boot/app.ts +684 -714
  118. package/src/boot/dependencies.ts +65 -62
  119. package/src/config/cooldowns.ts +16 -0
  120. package/src/config/providers.ts +13 -1
  121. package/src/config/resources.ts +70 -70
  122. package/src/config/security.ts +37 -37
  123. package/src/config/spec.ts +371 -358
  124. package/src/console/console-router.ts +233 -225
  125. package/src/console/domain-registration.ts +398 -381
  126. package/src/console/providers/catalog/catalog-projections.ts +2 -6
  127. package/src/console/providers/catalog/contracts.ts +19 -0
  128. package/src/console/providers/catalog/provider-operations.ts +38 -0
  129. package/src/console/providers/catalog/routes.ts +14 -0
  130. package/src/console/providers/catalog/store-helpers.ts +326 -0
  131. package/src/console/providers/catalog/store.ts +1292 -1563
  132. package/src/console/quota/account-quota-global.ts +2 -2
  133. package/src/console/quota/account-quota-shared.ts +1 -1
  134. package/src/console/quota/account-quota-tenant.ts +35 -12
  135. package/src/console/quota/quota-view.ts +2 -2
  136. package/src/console/routing/pools/contracts.ts +41 -1
  137. package/src/console/routing/pools/operations.ts +183 -4
  138. package/src/console/routing/pools/relay-deploy.ts +17 -4
  139. package/src/console/routing/pools/routes.ts +30 -0
  140. package/src/console/routing/pools/store.ts +98 -8
  141. package/src/main.ts +183 -167
  142. package/src/network/outbound-fetch.ts +226 -365
  143. package/src/network/pool/agent.ts +8 -0
  144. package/src/network/pool/pool-state.ts +7 -0
  145. package/src/network/pool/selector.ts +110 -2
  146. package/src/network/pool-cooldown-store.ts +252 -0
  147. package/src/network/pool-health-machine.ts +5 -3
  148. package/src/network/pool-health.ts +36 -9
  149. package/src/observability/event-query.ts +57 -0
  150. package/src/observability/metrics.ts +467 -467
  151. package/src/observability/runtime-metrics.ts +269 -269
  152. package/src/persistence/health-event-flush.ts +2 -0
  153. package/src/persistence/schema.ts +19 -0
  154. package/src/persistence/usage-flush.ts +8 -3
  155. package/src/protocol/canonical-model.ts +1 -1
  156. package/src/protocol/dsml.ts +377 -0
  157. package/src/protocol/gateway-error.ts +232 -232
  158. package/src/{providers/provider-reasoning.ts → protocol/reasoning.ts} +10 -9
  159. package/src/protocol/request/chat.ts +7 -7
  160. package/src/protocol/request/codex.ts +2 -2
  161. package/src/protocol/request/gemini.ts +1 -1
  162. package/src/protocol/request/messages.ts +6 -6
  163. package/src/protocol/request/responses.ts +7 -7
  164. package/src/protocol/response/dsml.ts +12 -133
  165. package/src/{transport → protocol}/translation/attachment-integrity.ts +2 -2
  166. package/src/{transport → protocol}/translation/cache-controls.ts +1 -1
  167. package/src/{transport → protocol}/translation/capabilities.ts +2 -2
  168. package/src/{transport → protocol}/translation/normalize.ts +1 -1
  169. package/src/{transport → protocol}/translation/quirks.ts +1 -1
  170. package/src/{transport → protocol}/translation/search.ts +1 -1
  171. package/src/{transport → protocol}/translation/thinking.ts +23 -1
  172. package/src/{transport → protocol}/translation/tool-id.ts +1 -1
  173. package/src/{transport → protocol}/translation/tool-repair.ts +2 -2
  174. package/src/protocol/upstream-parsing.ts +176 -0
  175. package/src/providers/adapters/compatible-adapter.ts +11 -2
  176. package/src/providers/authentication/oauth-refresh-service.ts +9 -5
  177. package/src/providers/default-registry.ts +21 -5
  178. package/src/providers/discovery/probe-phases.ts +16 -3
  179. package/src/providers/discovery/probing-service.ts +4 -1
  180. package/src/providers/integrations/anthropic.ts +1 -1
  181. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +1 -4
  182. package/src/providers/integrations/buddy/buddy-chat-shared.ts +141 -8
  183. package/src/providers/integrations/buddy/buddy-checkin.ts +5 -1
  184. package/src/providers/integrations/buddy/buddy-growth-shared.ts +0 -82
  185. package/src/providers/integrations/buddy/buddy-identity.ts +25 -0
  186. package/src/providers/integrations/buddy/buddy-server-tools.ts +240 -0
  187. package/src/providers/integrations/buddy/codebuddy/cn-catalog.ts +36 -0
  188. package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +6 -31
  189. package/src/providers/integrations/buddy/codebuddy/provider.ts +2 -0
  190. package/src/providers/integrations/buddy/codebuddy/shared.ts +19 -4
  191. package/src/providers/integrations/buddy/turing-device-token.ts +262 -0
  192. package/src/providers/integrations/buddy/workbuddy/provider.ts +2 -0
  193. package/src/providers/integrations/buddy/workbuddy/shared.ts +56 -3
  194. package/src/providers/integrations/claude/claude.ts +1 -1
  195. package/src/providers/integrations/cloudflareai/cloudflareai.ts +1 -1
  196. package/src/providers/integrations/codex/codex-errors.ts +1 -1
  197. package/src/providers/integrations/codex/codex.ts +1 -1
  198. package/src/providers/integrations/factory/factory.ts +1 -1
  199. package/src/providers/integrations/github/github.ts +1 -1
  200. package/src/providers/integrations/grok/grok.ts +1 -1
  201. package/src/providers/integrations/inception/provider.ts +91 -0
  202. package/src/providers/integrations/inferhub.ts +1 -1
  203. package/src/providers/integrations/kiro/kiro-request.ts +1 -1
  204. package/src/providers/integrations/mistral.ts +193 -19
  205. package/src/providers/integrations/opencode/fingerprint.ts +19 -2
  206. package/src/providers/integrations/opencode/prompt.ts +111 -0
  207. package/src/providers/integrations/opencode/provider.ts +101 -23
  208. package/src/providers/integrations/opencode/tool-definitions.ts +4 -4
  209. package/src/providers/integrations/qoder/qoder-checkin.ts +303 -0
  210. package/src/providers/integrations/qoder/qoder.ts +1641 -1458
  211. package/src/providers/integrations/shared/install-id.ts +2 -1
  212. package/src/providers/integrations/trae/trae-cache.ts +110 -0
  213. package/src/providers/integrations/trae/trae-oauth.ts +29 -4
  214. package/src/providers/integrations/trae/trae-protocol.ts +2 -1
  215. package/src/providers/integrations/trae/trae.ts +106 -23
  216. package/src/providers/operations/account-error-classification.ts +1660 -0
  217. package/src/providers/operations/account-health-service.ts +621 -2097
  218. package/src/providers/operations/account-reset-service.ts +2 -0
  219. package/src/providers/provider-contracts.ts +0 -2
  220. package/src/providers/provider-metadata.ts +25 -4
  221. package/src/providers/provider-probe.ts +2 -2
  222. package/src/{console → providers}/quota/cache.ts +1 -1
  223. package/src/{console → providers}/quota/refresh.ts +5 -5
  224. package/src/transport/dispatch/attempt-finalize.ts +114 -12
  225. package/src/transport/dispatch/attempt-loop.ts +3 -0
  226. package/src/transport/dispatch/empty-stream.ts +224 -217
  227. package/src/transport/dispatch/generation.ts +37 -55
  228. package/src/transport/dispatch/handler-deps.ts +32 -0
  229. package/src/transport/dispatch/leases.ts +9 -4
  230. package/src/transport/dispatch/proxy-request.ts +6 -30
  231. package/src/transport/dispatch/responses-compact.ts +22 -43
  232. package/src/transport/dispatch/streaming/finalize.ts +3 -0
  233. package/src/transport/dispatch/streaming/stream.ts +0 -8
  234. package/src/transport/dispatch/systemone.ts +22 -42
  235. package/src/transport/dispatch/token-count.ts +23 -39
  236. package/src/transport/dispatch/upstream.ts +1 -3
  237. package/src/transport/dispatch/websearch.ts +22 -41
  238. package/src/transport/failure-policy.ts +59 -178
  239. package/src/transport/guards/abuse-guard.ts +10 -0
  240. package/src/transport/guards/auth-guard.ts +109 -109
  241. package/src/transport/guards/error-lifecycle.ts +1 -1
  242. package/src/transport/guards/pipeline.ts +220 -220
  243. package/src/transport/guards/request-context.ts +245 -238
  244. package/src/transport/request/degradation.ts +20 -1
  245. package/src/transport/request/inflight.ts +4 -114
  246. package/src/transport/request/preparer.ts +6 -6
  247. package/src/transport/request/state.ts +66 -38
  248. package/src/transport/request/tenant-preferences.ts +1 -1
  249. package/src/transport/routing/route-catalog.ts +41 -31
  250. package/src/transport/routing/router.ts +2 -2
  251. package/src/transport/surface/messages/parse.ts +1 -1
  252. package/src/workers/buddy-growth-tasks.ts +346 -425
  253. package/src/workers/daily-checkin.ts +17 -6
  254. package/src/workers/quota-refresh-sweep.ts +2 -2
  255. package/src/workers/scheduler.ts +17 -1
  256. package/dist/dashboard/assets/BannedPage-P76QLGJA.js.gz +0 -0
  257. package/dist/dashboard/assets/CliToolDetailPage-DdBggJS9.js.gz +0 -0
  258. package/dist/dashboard/assets/CliToolsPage-aN1iXhhP.js.gz +0 -0
  259. package/dist/dashboard/assets/CombosPage-DE-F1nxZ.js.gz +0 -0
  260. package/dist/dashboard/assets/ConsoleLogPage-5SpmfLyw.js +0 -1
  261. package/dist/dashboard/assets/ConsoleLogPage-5SpmfLyw.js.gz +0 -0
  262. package/dist/dashboard/assets/CustomizationPage-3CUxMy2X.js.gz +0 -0
  263. package/dist/dashboard/assets/HealthEventsModal-BkmJQKC0.js +0 -1
  264. package/dist/dashboard/assets/HealthEventsModal-BkmJQKC0.js.gz +0 -0
  265. package/dist/dashboard/assets/LoginPage-B_0FUKWc.js +0 -1
  266. package/dist/dashboard/assets/LoginPage-B_0FUKWc.js.gz +0 -0
  267. package/dist/dashboard/assets/ModelPicker-DKVslqG3.js.gz +0 -0
  268. package/dist/dashboard/assets/OverviewPage-D-H4MEiT.js +0 -1
  269. package/dist/dashboard/assets/OverviewPage-D-H4MEiT.js.gz +0 -0
  270. package/dist/dashboard/assets/ProviderDetailPage-CEJw6Guo.js +0 -3
  271. package/dist/dashboard/assets/ProviderDetailPage-CEJw6Guo.js.gz +0 -0
  272. package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js +0 -1
  273. package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js.gz +0 -0
  274. package/dist/dashboard/assets/ProvidersPage-CEplAR-i.js +0 -1
  275. package/dist/dashboard/assets/ProvidersPage-CEplAR-i.js.gz +0 -0
  276. package/dist/dashboard/assets/ProxyPage-CAN7hy0f.js +0 -7
  277. package/dist/dashboard/assets/ProxyPage-CAN7hy0f.js.gz +0 -0
  278. package/dist/dashboard/assets/QuotaPage-BjldQwIU.js.gz +0 -0
  279. package/dist/dashboard/assets/SettingsPage-DUIoLRxk.js.gz +0 -0
  280. package/dist/dashboard/assets/SetupPage-_bCaTM0v.js +0 -1
  281. package/dist/dashboard/assets/SetupPage-_bCaTM0v.js.gz +0 -0
  282. package/dist/dashboard/assets/SortableList-fI7JkdIB.js.gz +0 -0
  283. package/dist/dashboard/assets/StudioPage-CHVZqskZ.js.gz +0 -0
  284. package/dist/dashboard/assets/UsagePage-DiFBymHj.js +0 -56
  285. package/dist/dashboard/assets/UsagePage-DiFBymHj.js.gz +0 -0
  286. package/dist/dashboard/assets/api-keys-DFY_fZU4.js.gz +0 -0
  287. package/dist/dashboard/assets/app-BOJ1_xUB.css +0 -2
  288. package/dist/dashboard/assets/app-BOJ1_xUB.css.gz +0 -0
  289. package/dist/dashboard/assets/assert-CwdnZARt.js.gz +0 -0
  290. package/dist/dashboard/assets/badge-Dr-uf0Au.js.gz +0 -0
  291. package/dist/dashboard/assets/banner-BddmMdoS.js.gz +0 -0
  292. package/dist/dashboard/assets/card-CK0PXWlX.js.gz +0 -0
  293. package/dist/dashboard/assets/cli-tools-CcB6mGh2.js.gz +0 -0
  294. package/dist/dashboard/assets/clipboard-button-DGb2P9Yx.js.gz +0 -0
  295. package/dist/dashboard/assets/cog-CdWhsajs.js +0 -1
  296. package/dist/dashboard/assets/cog-CdWhsajs.js.gz +0 -0
  297. package/dist/dashboard/assets/dialog-LkfuL50u.js.gz +0 -0
  298. package/dist/dashboard/assets/drawer-ZbEZ_84c.js.gz +0 -0
  299. package/dist/dashboard/assets/entry-BJfsVMvA.js.gz +0 -0
  300. package/dist/dashboard/assets/entry-CWBY5A0f.js.gz +0 -0
  301. package/dist/dashboard/assets/entry-CvC1-Zqx.js.gz +0 -0
  302. package/dist/dashboard/assets/github-badge-BxpznFLt.js.gz +0 -0
  303. package/dist/dashboard/assets/index-CDzP3ovJ.js.gz +0 -0
  304. package/dist/dashboard/assets/inline-Ae13MQ5w.js.gz +0 -0
  305. package/dist/dashboard/assets/input-D23fo7cp.js.gz +0 -0
  306. package/dist/dashboard/assets/layout-CSsxjj5P.js.gz +0 -0
  307. package/dist/dashboard/assets/list-row-dmbwZMDY.js.gz +0 -0
  308. package/dist/dashboard/assets/live-BG1oUsZZ.js.gz +0 -0
  309. package/dist/dashboard/assets/mutation-DSF-e4ho.js.gz +0 -0
  310. package/dist/dashboard/assets/numbers-Bm_aECY7.js.gz +0 -0
  311. package/dist/dashboard/assets/query-keys-CF56nYIV.js.gz +0 -0
  312. package/dist/dashboard/assets/routing-CeA71n7G.js.gz +0 -0
  313. package/dist/dashboard/assets/select-KIT0rC6G.js.gz +0 -0
  314. package/dist/dashboard/assets/settings-B_2gkePf.js.gz +0 -0
  315. package/dist/dashboard/assets/stack-D7WOSzcX.js.gz +0 -0
  316. package/dist/dashboard/assets/state-BBxtKZOQ.js.gz +0 -0
  317. package/dist/dashboard/assets/switch-DNS_D5Ea.js.gz +0 -0
  318. package/dist/dashboard/assets/useMutation-BgoxdoMk.js.gz +0 -0
  319. /package/dist/dashboard/assets/{app-CaXZ9SCn.js → app-BaWd0puM.js} +0 -0
  320. /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, request as httpRequest, type RequestOptions } from "node:http";
4
- import { Agent as HttpsAgent, request as httpsRequest } from "node:https";
5
- import { Readable, type Transform } from "node:stream";
6
- import { createBrotliDecompress, createGunzip, createInflate } from "node:zlib";
7
- import { resolveAllAddresses, validateResolvedAddresses } from "./ssrf";
8
- import type { SsrfPolicy } from "../config/network";
9
- import { isProxyAgentPair, type ProxyAgentPair } from "./pool/agent";
10
- import { metrics } from "../observability/metrics";
11
- import { trackNetworkCall } from "../observability/performance-metrics";
12
- import { stripResponseHeaders } from "./response-headers";
13
- // Validated fetch — single egress with ProxyAgent support
14
- export interface ValidatedFetchOptions {
15
- readonly fetchFn?: typeof fetch;
16
- readonly policy?: SsrfPolicy;
17
- readonly maxRedirects?: number;
18
- readonly resolveFn?: (hostname: string, signal: AbortSignal) => Promise<readonly string[]>;
19
- readonly agent?: HttpAgent | HttpsAgent | ProxyAgentPair;
20
- }
21
- export type ValidatedFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
22
-
23
- /**
24
- * Node validates `agent.protocol` per request, so a pool pair contributes
25
- * the flavor matching the *target* scheme (redirect hops reselect, since
26
- * the scheme can change mid-chain).
27
- */
28
- function agentForTarget(
29
- agent: HttpAgent | HttpsAgent | ProxyAgentPair | undefined,
30
- protocol: string,
31
- ): HttpAgent | HttpsAgent | undefined {
32
- if (!agent) return undefined;
33
- if (isProxyAgentPair(agent)) return protocol === "https:" ? agent.https : agent.http;
34
- return agent;
35
- }
36
-
37
- /**
38
- * Keep-alive options for the direct (non-pool) egress agents.
39
- *
40
- * Matched to `network/pool/agent.ts`, which builds pool agents from
41
- * `resolveProxyMaxSockets()` / `resolveProxyMaxFreeSockets()` and
42
- * `PROXY_KEEP_ALIVE_TIMEOUT_MS`. Direct egress has no per-pool config to
43
- * read, so it uses the documented defaults those resolvers return.
44
- */
45
- const DIRECT_AGENT_OPTIONS = {
46
- keepAlive: true,
47
- maxSockets: 100,
48
- maxFreeSockets: 20,
49
- timeout: 60_000,
50
- } as const;
51
-
52
- /**
53
- * An agent whose socket key includes the SSRF-validated address.
54
- *
55
- * The direct (non-pool) path pins egress by passing a `lookup` that returns the
56
- * one validated address, but `Agent#getName` — the keep-alive socket key —
57
- * hashes only host/port/localAddress/family. Two requests to the same
58
- * `host:port` that validated to *different* addresses therefore share a pooled
59
- * socket, and the second one silently egresses to the first one's address:
60
- * the pin is bypassed without any error surfacing.
61
- *
62
- * Including the address in the key makes each validated address its own socket
63
- * bucket, so a re-resolved address always dials fresh while the *same* address
64
- * still reuses its socket (keep-alive is preserved, not defeated).
65
- *
66
- * This is a real gap, not a theoretical one: with no agent at all the request
67
- * falls back to the global agent, which has the same key shape *and* is shared
68
- * with unrelated traffic.
69
- */
70
- class PinnedAddressAgent extends HttpAgent {
71
- override getName(options: Record<string, unknown> = {}): string {
72
- const base = super.getName(options as never);
73
- const pinned = (options["lookup"] as { pinnedAddress?: string } | undefined)?.pinnedAddress;
74
- return `${base}:${pinned ?? "-"}`;
75
- }
76
- }
77
- class PinnedAddressHttpsAgent extends HttpsAgent {
78
- override getName(options: Record<string, unknown> = {}): string {
79
- const base = super.getName(options as never);
80
- const pinned = (options["lookup"] as { pinnedAddress?: string } | undefined)?.pinnedAddress;
81
- return `${base}:${pinned ?? "-"}`;
82
- }
83
- }
84
-
85
- const DIRECT_HTTP_AGENT = new PinnedAddressAgent(DIRECT_AGENT_OPTIONS);
86
- const DIRECT_HTTPS_AGENT = new PinnedAddressHttpsAgent(DIRECT_AGENT_OPTIONS);
87
-
88
- /**
89
- * Builds the application-relay request used by hosted relay front doors.
90
- * These endpoints intentionally do not receive CONNECT: they accept the
91
- * target origin/path in headers and perform the upstream fetch themselves,
92
- * matching the 21-beta relay contract.
93
- */
94
- function relayRequest(
95
- relayEndpoint: URL,
96
- target: URL,
97
- init: RequestInit,
98
- ): { url: URL; init: RequestInit } {
99
- const relayUrl = new URL(relayEndpoint.toString());
100
- const headers = new Headers(init.headers);
101
- headers.delete("host");
102
- headers.set("x-relay-target", target.origin);
103
- headers.set("x-relay-path", `${target.pathname}${target.search}`);
104
- if (relayUrl.username || relayUrl.password) {
105
- const credentials = `${decodeURIComponent(relayUrl.username)}:${decodeURIComponent(relayUrl.password)}`;
106
- headers.set("x-relay-auth", `Basic ${Buffer.from(credentials).toString("base64")}`);
107
- relayUrl.username = "";
108
- relayUrl.password = "";
109
- }
110
- return { url: relayUrl, init: { ...init, headers } };
111
- }
112
-
113
- /**
114
- * Decompresses an upstream body the way `fetch` would, because this path
115
- * bypasses `fetch`.
116
- *
117
- * `node:http` hands back the raw body and does not decode `content-encoding`,
118
- * while `fetch`/Undici transparently gunzips. Rebuilding a `Response` around the
119
- * still-compressed stream therefore told every consumer the body was plain when
120
- * it was not: `response.text()` returned binary garbage, and a JSON parse on it
121
- * failed as `platform_unavailable: Claude response is not valid JSON`. That is
122
- * why only the upstreams that actually compress (`br` for Anthropic) appeared
123
- * broken while others worked — nothing here requested an encoding, but a server
124
- * may compress anyway.
125
- *
126
- * Returns the stream untouched for `identity`, an unknown encoding, or when no
127
- * `content-encoding` is present.
128
- */
129
- function decompressBody(
130
- body: Readable,
131
- encoding: string | undefined,
132
- ): { stream: Readable; encoding: string | undefined } {
133
- const normalized = encoding?.trim().toLowerCase();
134
- if (normalized === undefined || normalized === "" || normalized === "identity") {
135
- return { stream: body, encoding: undefined };
136
- }
137
- const decoder: Transform | undefined =
138
- normalized === "gzip" || normalized === "x-gzip"
139
- ? createGunzip()
140
- : normalized === "deflate"
141
- ? createInflate()
142
- : normalized === "br"
143
- ? createBrotliDecompress()
144
- : undefined;
145
- // An encoding we cannot decode (e.g. zstd) is left alone: forwarding the
146
- // header with the raw body keeps the caller's own decoder in charge, which is
147
- // strictly better than claiming a plain body we never decoded.
148
- if (decoder === undefined) return { stream: body, encoding: normalized };
149
- body.on("error", (error) => decoder.destroy(error));
150
- return { stream: body.pipe(decoder), encoding: undefined };
151
- }
152
-
153
- async function pinnedFetch(
154
- url: URL,
155
- init: RequestInit,
156
- resolvedAddress: string | undefined,
157
- agent?: HttpAgent | HttpsAgent,
158
- ): Promise<Response> {
159
- const request = url.protocol === "https:" ? httpsRequest : httpRequest;
160
- const headers = new Headers(init.headers);
161
- // String/Buffer/Uint8Array bodies go direct: routing them through
162
- // `new Response(body).arrayBuffer()` copied the whole body per request.
163
- const rawBody = init.body;
164
- const body =
165
- rawBody === undefined || rawBody === null
166
- ? undefined
167
- : typeof rawBody === "string" || Buffer.isBuffer(rawBody) || rawBody instanceof Uint8Array
168
- ? rawBody
169
- : Buffer.from(await new Response(rawBody).arrayBuffer());
170
- const outboundHeaders: Record<string, string> = {};
171
- headers.forEach((value, key) => {
172
- outboundHeaders[key] = value;
173
- });
174
- // A known-length JSON/string body must not fall back to chunked encoding.
175
- // Upstream gateways may wait for the complete chunked stream before routing
176
- // the request, adding an avoidable RTT compared with global fetch/Undici.
177
- if (body !== undefined && !headers.has("content-length")) {
178
- outboundHeaders["content-length"] =
179
- typeof body === "string" ? String(Buffer.byteLength(body, "utf8")) : String(body.byteLength);
180
- }
181
- const lookup = Object.assign(
182
- (
183
- _hostname: string,
184
- options: { all?: boolean; family?: number } | number,
185
- callback: (
186
- error: Error | null,
187
- address?: string | Array<{ address: string; family: number }>,
188
- family?: number,
189
- ) => void,
190
- ): void => {
191
- if (resolvedAddress === undefined) {
192
- callback(new Error("no validated address available"));
193
- return;
194
- }
195
- const opts = typeof options === "number" ? { family: options } : options;
196
- const family = resolvedAddress.includes(":") ? 6 : 4;
197
- if (opts?.all) {
198
- callback(null, [{ address: resolvedAddress, family }] as never);
199
- return;
200
- }
201
- callback(null, resolvedAddress, family);
202
- },
203
- // Read by `PinnedAddressAgent#getName` so the keep-alive socket key
204
- // carries the validated address. See the class comment for why.
205
- { pinnedAddress: resolvedAddress },
206
- ) as unknown as NonNullable<RequestOptions["lookup"]>;
207
- // Direct egress gets a dedicated agent as well as the pinning `lookup`.
208
- // Passing only `lookup` left the request on the global agent, whose socket
209
- // key ignores `lookup` — so a re-resolved address could reuse a socket
210
- // dialed against an earlier one and egress past the SSRF check. The direct
211
- // agent keys its sockets on the pinned address, so the pin holds.
212
- const directAgent = url.protocol === "https:" ? DIRECT_HTTPS_AGENT : DIRECT_HTTP_AGENT;
213
- return new Promise<Response>((resolve, reject) => {
214
- const nodeRequest = request(
215
- {
216
- protocol: url.protocol,
217
- hostname: url.hostname,
218
- port: url.port ? url.port : url.protocol === "https:" ? "443" : "80",
219
- path: `${url.pathname}${url.search}`,
220
- method: init.method ?? "GET",
221
- headers: outboundHeaders,
222
- signal: init.signal ?? undefined,
223
- ...(agent ? { agent } : { agent: directAgent, lookup }),
224
- ...(url.protocol === "https:" ? { servername: url.hostname } : {}),
225
- },
226
- (response) => {
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