cartethyia 2.5.10 → 2.6.0

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 (367) hide show
  1. package/.env.example +8 -0
  2. package/dist/dashboard/assets/{BannedPage-Cd30Z0b9.js → BannedPage-l6XMnfr9.js} +1 -1
  3. package/dist/dashboard/assets/BannedPage-l6XMnfr9.js.gz +0 -0
  4. package/dist/dashboard/assets/{CliToolDetailPage-wf5u-ww6.js → CliToolDetailPage-DwOzAW45.js} +1 -1
  5. package/dist/dashboard/assets/CliToolDetailPage-DwOzAW45.js.gz +0 -0
  6. package/dist/dashboard/assets/{CliToolsPage-DykPvuck.js → CliToolsPage-BT3Up3QA.js} +1 -1
  7. package/dist/dashboard/assets/CliToolsPage-BT3Up3QA.js.gz +0 -0
  8. package/dist/dashboard/assets/{CombosPage-DwvSD3vs.js → CombosPage-6kYIUpzR.js} +1 -1
  9. package/dist/dashboard/assets/CombosPage-6kYIUpzR.js.gz +0 -0
  10. package/dist/dashboard/assets/{ConsoleLogPage-CIr7KDex.js → ConsoleLogPage-zRnj1mlR.js} +1 -1
  11. package/dist/dashboard/assets/ConsoleLogPage-zRnj1mlR.js.gz +0 -0
  12. package/dist/dashboard/assets/{CustomizationPage-82X0DDdH.js → CustomizationPage-C7m-H1Rz.js} +1 -1
  13. package/dist/dashboard/assets/CustomizationPage-C7m-H1Rz.js.gz +0 -0
  14. package/dist/dashboard/assets/HealthEventsModal-C8MR_BdB.js +1 -0
  15. package/dist/dashboard/assets/HealthEventsModal-C8MR_BdB.js.gz +0 -0
  16. package/dist/dashboard/assets/{LoginPage-CS5mPuVF.js → LoginPage-CbphLvjg.js} +1 -1
  17. package/dist/dashboard/assets/LoginPage-CbphLvjg.js.gz +0 -0
  18. package/dist/dashboard/assets/{ModelPicker-C0UCUsVJ.js → ModelPicker-DVny4S5_.js} +1 -1
  19. package/dist/dashboard/assets/ModelPicker-DVny4S5_.js.gz +0 -0
  20. package/dist/dashboard/assets/OverviewPage-DABu2Wgt.js +1 -0
  21. package/dist/dashboard/assets/OverviewPage-DABu2Wgt.js.gz +0 -0
  22. package/dist/dashboard/assets/ProviderDetailPage-C15x9q13.js +3 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-C15x9q13.js.gz +0 -0
  24. package/dist/dashboard/assets/{ProvidersPage-vdja5ZPK.js → ProvidersPage-Dfb2896L.js} +1 -1
  25. package/dist/dashboard/assets/ProvidersPage-Dfb2896L.js.gz +0 -0
  26. package/dist/dashboard/assets/{ProxyPage-BB4CNm4-.js → ProxyPage-BaCIdAUt.js} +1 -1
  27. package/dist/dashboard/assets/ProxyPage-BaCIdAUt.js.gz +0 -0
  28. package/dist/dashboard/assets/QuotaPage-BsdoCIw1.js +1 -0
  29. package/dist/dashboard/assets/QuotaPage-BsdoCIw1.js.gz +0 -0
  30. package/dist/dashboard/assets/SettingsPage-KVJFh3Ip.js +1 -0
  31. package/dist/dashboard/assets/SettingsPage-KVJFh3Ip.js.gz +0 -0
  32. package/dist/dashboard/assets/{SetupPage-FadUOl9S.js → SetupPage-D4g6vOGc.js} +1 -1
  33. package/dist/dashboard/assets/SetupPage-D4g6vOGc.js.gz +0 -0
  34. package/dist/dashboard/assets/{SortableList-Cfy6_Khv.js → SortableList-C93i14BD.js} +1 -1
  35. package/dist/dashboard/assets/SortableList-C93i14BD.js.gz +0 -0
  36. package/dist/dashboard/assets/{StudioPage-CM92fgJf.js → StudioPage-SgQpymjm.js} +1 -1
  37. package/dist/dashboard/assets/{StudioPage-CM92fgJf.js.gz → StudioPage-SgQpymjm.js.gz} +0 -0
  38. package/dist/dashboard/assets/{UsagePage-BT4MyQeT.js → UsagePage-BFME-eba.js} +2 -2
  39. package/dist/dashboard/assets/UsagePage-BFME-eba.js.gz +0 -0
  40. package/dist/dashboard/assets/{api-keys-DQYLQxpW.js → api-keys-C23S9sgp.js} +1 -1
  41. package/dist/dashboard/assets/api-keys-C23S9sgp.js.gz +0 -0
  42. package/dist/dashboard/assets/{app-B7pVRhJ1.js → app-2KhtHCv9.js} +1 -1
  43. package/dist/dashboard/assets/{app-B7pVRhJ1.js.gz → app-2KhtHCv9.js.gz} +0 -0
  44. package/dist/dashboard/assets/app-CEeaMHOy.css +2 -0
  45. package/dist/dashboard/assets/app-CEeaMHOy.css.gz +0 -0
  46. package/dist/dashboard/assets/{assert-C9FD_JwJ.js → assert-DU6qrMA_.js} +1 -1
  47. package/dist/dashboard/assets/assert-DU6qrMA_.js.gz +0 -0
  48. package/dist/dashboard/assets/{badge-Bvyr7D0u.js → badge-CKsF123d.js} +1 -1
  49. package/dist/dashboard/assets/badge-CKsF123d.js.gz +0 -0
  50. package/dist/dashboard/assets/{banner-DIvKAZih.js → banner-8j7uChA9.js} +1 -1
  51. package/dist/dashboard/assets/banner-8j7uChA9.js.gz +0 -0
  52. package/dist/dashboard/assets/{card-D5_gYIS_.js → card-CdsSlWA3.js} +1 -1
  53. package/dist/dashboard/assets/card-CdsSlWA3.js.gz +0 -0
  54. package/dist/dashboard/assets/{cli-tools-CNqzrfdb.js → cli-tools-BaFUuFFF.js} +1 -1
  55. package/dist/dashboard/assets/cli-tools-BaFUuFFF.js.gz +0 -0
  56. package/dist/dashboard/assets/{clipboard-button-B6vDBtbm.js → clipboard-button-uL5aYLfW.js} +1 -1
  57. package/dist/dashboard/assets/clipboard-button-uL5aYLfW.js.gz +0 -0
  58. package/dist/dashboard/assets/{dialog-CpjrgdwJ.js → dialog-C07hr5_G.js} +1 -1
  59. package/dist/dashboard/assets/dialog-C07hr5_G.js.gz +0 -0
  60. package/dist/dashboard/assets/{drawer-BWmt3b5y.js → drawer-BPO1TcKH.js} +1 -1
  61. package/dist/dashboard/assets/drawer-BPO1TcKH.js.gz +0 -0
  62. package/dist/dashboard/assets/{entry-C_oakV3z.js → entry-Bz_CtDfC.js} +1 -1
  63. package/dist/dashboard/assets/entry-Bz_CtDfC.js.gz +0 -0
  64. package/dist/dashboard/assets/entry-CwPRKk0d.js +4 -0
  65. package/dist/dashboard/assets/entry-CwPRKk0d.js.gz +0 -0
  66. package/dist/dashboard/assets/entry-GJRbmrpA.js +1 -0
  67. package/dist/dashboard/assets/entry-GJRbmrpA.js.gz +0 -0
  68. package/dist/dashboard/assets/{github-badge-RRHHWGxh.js → github-badge-DmQreSDd.js} +1 -1
  69. package/dist/dashboard/assets/github-badge-DmQreSDd.js.gz +0 -0
  70. package/dist/dashboard/assets/{index-Bhey_rg_.js → index-Bblu6erR.js} +2 -2
  71. package/dist/dashboard/assets/index-Bblu6erR.js.gz +0 -0
  72. package/dist/dashboard/assets/{inline-C2_EQ5Uf.js → inline-C9dCYaBZ.js} +1 -1
  73. package/dist/dashboard/assets/inline-C9dCYaBZ.js.gz +0 -0
  74. package/dist/dashboard/assets/{input-BMRlQ450.js → input-CjCnXweJ.js} +1 -1
  75. package/dist/dashboard/assets/input-CjCnXweJ.js.gz +0 -0
  76. package/dist/dashboard/assets/{layout-XR5xP8ew.js → layout-V6y8DAkl.js} +1 -1
  77. package/dist/dashboard/assets/layout-V6y8DAkl.js.gz +0 -0
  78. package/dist/dashboard/assets/{list-row-Cw07dioc.js → list-row-CM1AaG_y.js} +1 -1
  79. package/dist/dashboard/assets/list-row-CM1AaG_y.js.gz +0 -0
  80. package/dist/dashboard/assets/{live-BivGfuUs.js → live-ta6GMb-8.js} +1 -1
  81. package/dist/dashboard/assets/live-ta6GMb-8.js.gz +0 -0
  82. package/dist/dashboard/assets/{model-bans-CmffjLuG.js → model-bans-BmYTmbAk.js} +1 -1
  83. package/dist/dashboard/assets/model-bans-BmYTmbAk.js.gz +0 -0
  84. package/dist/dashboard/assets/{mutation-46P0oWMe.js → mutation-g_fduiNw.js} +1 -1
  85. package/dist/dashboard/assets/mutation-g_fduiNw.js.gz +0 -0
  86. package/dist/dashboard/assets/{numbers-DrdA9oSM.js → numbers-BOevJzdz.js} +1 -1
  87. package/dist/dashboard/assets/numbers-BOevJzdz.js.gz +0 -0
  88. package/dist/dashboard/assets/{query-keys-BFhGpwR1.js → query-keys-UETqfytp.js} +1 -1
  89. package/dist/dashboard/assets/query-keys-UETqfytp.js.gz +0 -0
  90. package/dist/dashboard/assets/{routing-CAb4F37R.js → routing-BPUo-W8d.js} +1 -1
  91. package/dist/dashboard/assets/routing-BPUo-W8d.js.gz +0 -0
  92. package/dist/dashboard/assets/{select-CxVVB__N.js → select-C980c4cz.js} +1 -1
  93. package/dist/dashboard/assets/select-C980c4cz.js.gz +0 -0
  94. package/dist/dashboard/assets/{settings-DNX9sydi.js → settings--3hxSyry.js} +1 -1
  95. package/dist/dashboard/assets/settings--3hxSyry.js.gz +0 -0
  96. package/dist/dashboard/assets/{stack-uPOwM9Hs.js → stack-_gfTdLVB.js} +1 -1
  97. package/dist/dashboard/assets/stack-_gfTdLVB.js.gz +0 -0
  98. package/dist/dashboard/assets/{state-D1Yz79iL.js → state-2c5V4zo7.js} +1 -1
  99. package/dist/dashboard/assets/state-2c5V4zo7.js.gz +0 -0
  100. package/dist/dashboard/assets/{switch-sevsRrRo.js → switch-Dkke7Naf.js} +1 -1
  101. package/dist/dashboard/assets/switch-Dkke7Naf.js.gz +0 -0
  102. package/dist/dashboard/assets/{useMutation-Dem0lXQj.js → useMutation-EDFRkn1i.js} +1 -1
  103. package/dist/dashboard/assets/useMutation-EDFRkn1i.js.gz +0 -0
  104. package/dist/dashboard/index.html +1 -1
  105. package/dist/dashboard/index.html.gz +0 -0
  106. package/dist/dashboard/precompressed-assets.json +51 -51
  107. package/migrations/README.md +52 -52
  108. package/migrations/full-postgres/0020_add_telemetry_event_analytics.sql +77 -0
  109. package/migrations/full-postgres/0021_add_api_key_concurrency_tolerance.sql +13 -0
  110. package/migrations/full-postgres/0022_add_inferhub_config_key.sql +17 -0
  111. package/migrations/full-postgres/0023_drop_api_key_concurrency_tolerance.sql +13 -0
  112. package/migrations/lite-sqlite3/0020_add_telemetry_event_analytics.sql +69 -0
  113. package/migrations/lite-sqlite3/0021_add_api_key_concurrency_tolerance.sql +13 -0
  114. package/migrations/lite-sqlite3/0022_add_inferhub_config_key.sql +17 -0
  115. package/migrations/lite-sqlite3/0023_drop_api_key_concurrency_tolerance.sql +13 -0
  116. package/package.json +96 -95
  117. package/scripts/cli/admin.ts +126 -128
  118. package/scripts/cli/autostart.ts +237 -237
  119. package/scripts/cli/background.ts +120 -110
  120. package/scripts/cli/bin/carte.cjs +8 -8
  121. package/scripts/cli/bin/cartethyia.cjs +167 -167
  122. package/scripts/cli/console-menu.ts +39 -30
  123. package/scripts/cli/controller.ts +145 -0
  124. package/scripts/cli/env.ts +99 -99
  125. package/scripts/cli/gateway.ts +264 -260
  126. package/scripts/cli/instance.ts +234 -224
  127. package/scripts/cli/main.ts +188 -167
  128. package/scripts/cli/platform.ts +64 -64
  129. package/scripts/cli/prompt.ts +217 -79
  130. package/scripts/cli/services.ts +88 -88
  131. package/scripts/cli/setup.ts +445 -445
  132. package/scripts/cli/start.ts +85 -85
  133. package/scripts/cli/startup-signal.ts +23 -23
  134. package/scripts/cli/telemetry.ts +70 -0
  135. package/scripts/cli/tray/index.ts +263 -263
  136. package/scripts/cli/tray/windows.ps1 +107 -107
  137. package/scripts/cli/tray.ts +29 -4
  138. package/scripts/cli/update.ts +39 -39
  139. package/scripts/cli/version.ts +67 -67
  140. package/scripts/cli/wordmark.ts +58 -58
  141. package/src/boot/app.ts +16 -1
  142. package/src/boot/dependencies.ts +128 -25
  143. package/src/config/resources.ts +137 -0
  144. package/src/config/runtime.ts +133 -76
  145. package/src/config/spec.ts +30 -29
  146. package/src/config/telemetry.ts +0 -25
  147. package/src/console/api-keys/bucket-spend.ts +14 -17
  148. package/src/console/api-keys/contracts.ts +410 -410
  149. package/src/console/api-keys/routes.ts +12 -0
  150. package/src/console/audit/store.ts +2 -3
  151. package/src/console/auth/setup-service.ts +8 -1
  152. package/src/console/backup/routes.ts +7 -0
  153. package/src/console/backup/service.ts +463 -463
  154. package/src/console/backup/store.ts +16 -11
  155. package/src/console/backup/validate.ts +1 -4
  156. package/src/console/cli-tools/fs-ops.ts +18 -2
  157. package/src/console/data-management/contracts.ts +1 -8
  158. package/src/console/data-management/operations.ts +61 -48
  159. package/src/console/data-management/routes.ts +4 -2
  160. package/src/console/domain-registration.ts +25 -3
  161. package/src/console/inferhub/client.ts +139 -0
  162. package/src/console/inferhub/key-store.ts +129 -0
  163. package/src/console/inferhub/normalize.ts +347 -0
  164. package/src/console/inferhub/routes.ts +411 -0
  165. package/src/console/inferhub/store.ts +109 -0
  166. package/src/console/inferhub/types.ts +183 -0
  167. package/src/console/observability/contracts.ts +2 -41
  168. package/src/console/observability/store.ts +176 -371
  169. package/src/console/providers/catalog/public-model-store.ts +0 -0
  170. package/src/console/providers/catalog/store.ts +23 -36
  171. package/src/console/quota/account-quota-shared.ts +52 -52
  172. package/src/console/quota/account-quota-tenant.ts +16 -24
  173. package/src/console/quota/refresh.ts +441 -441
  174. package/src/console/routing/pools/import-parse.ts +206 -206
  175. package/src/console/routing/pools/operations.ts +543 -543
  176. package/src/console/routing/pools/probe-verdict.ts +132 -132
  177. package/src/console/routing/pools/routes.ts +181 -181
  178. package/src/console/settings/contracts.ts +42 -40
  179. package/src/console/settings/store.ts +25 -25
  180. package/src/console/share/share-stats.ts +22 -4
  181. package/src/console/share/share-usage.ts +30 -4
  182. package/src/console/shared/cursor.ts +15 -0
  183. package/src/console/updates/routes.ts +12 -0
  184. package/src/main.ts +13 -5
  185. package/src/network/pool/resolver.ts +1104 -1104
  186. package/src/observability/client-name.ts +22 -0
  187. package/src/observability/event-log.ts +5 -29
  188. package/src/observability/event-query.ts +690 -0
  189. package/src/observability/log-ring.ts +26 -2
  190. package/src/observability/log-sanitize.ts +118 -0
  191. package/src/observability/logger.ts +17 -13
  192. package/src/observability/metrics.ts +35 -0
  193. package/src/observability/payload-capture.ts +114 -41
  194. package/src/observability/payload-store.ts +249 -24
  195. package/src/observability/raw-event-writer.ts +118 -0
  196. package/src/observability/runtime-metrics.ts +71 -1
  197. package/src/observability/telemetry-buffer.ts +100 -17
  198. package/src/observability/telemetry-reconciliation.ts +417 -0
  199. package/src/persistence/boot.ts +20 -9
  200. package/src/persistence/db-pg.ts +13 -0
  201. package/src/persistence/db-sqlite.ts +569 -497
  202. package/src/persistence/dialect.ts +3 -2
  203. package/src/persistence/migrate.ts +181 -181
  204. package/src/persistence/schema.ts +1496 -1406
  205. package/src/persistence/shared-cache.ts +5 -1
  206. package/src/persistence/stores/api-key-store.ts +581 -581
  207. package/src/persistence/stores/telemetry-store.ts +124 -63
  208. package/src/protocol/reasoning-text.ts +30 -0
  209. package/src/protocol/response/chat.ts +33 -7
  210. package/src/protocol/response/codex.ts +14 -2
  211. package/src/protocol/response/messages.ts +5 -2
  212. package/src/providers/authentication/oauth-flow-store.ts +3 -10
  213. package/src/providers/bundled-discovery-providers.ts +122 -122
  214. package/src/providers/default-registry.ts +10 -0
  215. package/src/providers/discovery/catalog/catalog-blob.ts +18 -18
  216. package/src/providers/discovery/models-dev-catalog.ts +354 -354
  217. package/src/providers/discovery/probe-phases.ts +43 -7
  218. package/src/providers/discovery/probing-service.ts +70 -32
  219. package/src/providers/integrations/antigravity/antigravity.ts +9 -2
  220. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +85 -17
  221. package/src/providers/integrations/buddy/buddy-chat-shared.ts +934 -934
  222. package/src/providers/integrations/buddy/buddy-checkin.ts +5 -9
  223. package/src/providers/integrations/buddy/buddy-discovery-shared.ts +3 -7
  224. package/src/providers/integrations/buddy/buddy-quota-shared.ts +3 -1
  225. package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +57 -49
  226. package/src/providers/integrations/buddy/codebuddy/provider.ts +180 -180
  227. package/src/providers/integrations/buddy/workbuddy/provider.ts +206 -206
  228. package/src/providers/integrations/buddy/workbuddy/quota.ts +161 -161
  229. package/src/providers/integrations/claude/claude-quota.ts +2 -2
  230. package/src/providers/integrations/claude/claude-user-profiles.ts +3 -2
  231. package/src/providers/integrations/claude/claude.ts +3 -4
  232. package/src/providers/integrations/cline/cline.ts +94 -8
  233. package/src/providers/integrations/codex/codex.ts +61 -37
  234. package/src/providers/integrations/cursor/cursor-catalog.ts +368 -368
  235. package/src/providers/integrations/cursor/cursor-http2.ts +300 -300
  236. package/src/providers/integrations/cursor/cursor-identity.ts +2 -4
  237. package/src/providers/integrations/cursor/cursor-request.ts +11 -0
  238. package/src/providers/integrations/cursor/cursor.ts +29 -17
  239. package/src/providers/integrations/cursor/protocol/framing.ts +129 -129
  240. package/src/providers/integrations/cursor/protocol/messages.ts +2380 -2380
  241. package/src/providers/integrations/cursor/protocol/struct.ts +305 -305
  242. package/src/providers/integrations/cursor/protocol/tool-canonical.ts +275 -0
  243. package/src/providers/integrations/grok/grok-quota.ts +279 -279
  244. package/src/providers/integrations/grok/grok-ratelimit-store.ts +73 -73
  245. package/src/providers/integrations/grok/grok-ratelimit.ts +218 -218
  246. package/src/providers/integrations/hermes.ts +7 -1
  247. package/src/providers/integrations/inferhub.ts +201 -73
  248. package/src/providers/integrations/kilo/kilo.ts +152 -152
  249. package/src/providers/integrations/kiro/kiro-machine-id.ts +2 -4
  250. package/src/providers/integrations/openrouter/provider.ts +129 -129
  251. package/src/providers/integrations/qoder/qoder-quota.ts +193 -192
  252. package/src/providers/integrations/qoder/qoder-realm.ts +106 -106
  253. package/src/providers/integrations/trae/trae-oauth.ts +4 -26
  254. package/src/providers/integrations/trae/trae-quota.ts +389 -384
  255. package/src/providers/integrations/trae/trae-realm.ts +15 -2
  256. package/src/providers/integrations/xiaomi-mimo/think-stream.ts +100 -24
  257. package/src/providers/integrations/zcode/oauth.ts +1 -1
  258. package/src/providers/operations/account-health-service.ts +605 -279
  259. package/src/providers/provider-metadata.ts +12 -1
  260. package/src/providers/provider-model-definition.ts +373 -373
  261. package/src/providers/quota/quota-contracts.ts +3 -7
  262. package/src/providers/search/search-provider.ts +2 -6
  263. package/src/security/admission/contracts.ts +10 -0
  264. package/src/security/admission/in-memory-store.ts +89 -14
  265. package/src/security/admission/lua.ts +191 -182
  266. package/src/security/admission/redis-store.ts +30 -0
  267. package/src/security/admission/service.ts +18 -0
  268. package/src/security/api-key-auth-cache.ts +96 -0
  269. package/src/security/api-key-auth.ts +11 -100
  270. package/src/security/client-router-fingerprint.ts +3 -7
  271. package/src/security/ip-boundary.ts +7 -3
  272. package/src/shared/coerce.ts +67 -0
  273. package/src/shared/hash.ts +6 -0
  274. package/src/shared/time.ts +7 -0
  275. package/src/transport/dispatch/attempt-finalize.ts +26 -138
  276. package/src/transport/dispatch/attempt-loop.ts +103 -81
  277. package/src/transport/dispatch/capture-policy.ts +78 -0
  278. package/src/transport/dispatch/proxy-request.ts +4 -2
  279. package/src/transport/dispatch/streaming/encoder.ts +111 -5
  280. package/src/transport/dispatch/streaming/stream.ts +19 -2
  281. package/src/transport/dispatch/streaming/thinking-loop.ts +300 -0
  282. package/src/transport/failure-policy.ts +27 -0
  283. package/src/transport/guards/abuse-guard.ts +2 -2
  284. package/src/transport/guards/auth-guard.ts +17 -0
  285. package/src/transport/guards/body-policy.ts +3 -2
  286. package/src/transport/guards/concurrency-guard.ts +89 -13
  287. package/src/transport/guards/early-rejection-capture.ts +43 -3
  288. package/src/transport/guards/error-lifecycle.ts +10 -2
  289. package/src/transport/guards/guard-errors.ts +23 -0
  290. package/src/transport/guards/headroom-guard.ts +41 -7
  291. package/src/transport/guards/pipeline.ts +24 -0
  292. package/src/transport/guards/request-context.ts +2 -1
  293. package/src/transport/routing/admission.ts +51 -9
  294. package/src/transport/routing/bounded-lru.ts +49 -0
  295. package/src/transport/routing/candidate-index.ts +88 -0
  296. package/src/transport/routing/reservations.ts +121 -7
  297. package/src/transport/routing/round-robin.ts +10 -22
  298. package/src/transport/routing/route-catalog.ts +26 -13
  299. package/src/transport/routing/route-model.ts +22 -2
  300. package/src/transport/routing/router.ts +53 -31
  301. package/src/transport/surface/chat/encode.ts +2 -3
  302. package/src/transport/surface/chat/parse.ts +2 -1
  303. package/src/transport/surface/dialects.ts +11 -3
  304. package/src/transport/surface/messages/encode.ts +2 -1
  305. package/src/transport/surface/messages/parse.ts +3 -1
  306. package/src/transport/surface/messages/stream.ts +2 -6
  307. package/src/workers/maintenance-lease.ts +200 -0
  308. package/src/workers/scheduler.ts +101 -1
  309. package/dist/dashboard/assets/BannedPage-Cd30Z0b9.js.gz +0 -0
  310. package/dist/dashboard/assets/CliToolDetailPage-wf5u-ww6.js.gz +0 -0
  311. package/dist/dashboard/assets/CliToolsPage-DykPvuck.js.gz +0 -0
  312. package/dist/dashboard/assets/CombosPage-DwvSD3vs.js.gz +0 -0
  313. package/dist/dashboard/assets/ConsoleLogPage-CIr7KDex.js.gz +0 -0
  314. package/dist/dashboard/assets/CustomizationPage-82X0DDdH.js.gz +0 -0
  315. package/dist/dashboard/assets/HealthEventsModal-BYPucTTn.js +0 -1
  316. package/dist/dashboard/assets/HealthEventsModal-BYPucTTn.js.gz +0 -0
  317. package/dist/dashboard/assets/LoginPage-CS5mPuVF.js.gz +0 -0
  318. package/dist/dashboard/assets/ModelPicker-C0UCUsVJ.js.gz +0 -0
  319. package/dist/dashboard/assets/OverviewPage-Dk_ew3YU.js +0 -1
  320. package/dist/dashboard/assets/OverviewPage-Dk_ew3YU.js.gz +0 -0
  321. package/dist/dashboard/assets/ProviderDetailPage-CAiRn2Q0.js +0 -3
  322. package/dist/dashboard/assets/ProviderDetailPage-CAiRn2Q0.js.gz +0 -0
  323. package/dist/dashboard/assets/ProvidersPage-vdja5ZPK.js.gz +0 -0
  324. package/dist/dashboard/assets/ProxyPage-BB4CNm4-.js.gz +0 -0
  325. package/dist/dashboard/assets/QuotaPage-FEc4iNyq.js +0 -1
  326. package/dist/dashboard/assets/QuotaPage-FEc4iNyq.js.gz +0 -0
  327. package/dist/dashboard/assets/SettingsPage-Dlfi5L4N.js +0 -1
  328. package/dist/dashboard/assets/SettingsPage-Dlfi5L4N.js.gz +0 -0
  329. package/dist/dashboard/assets/SetupPage-FadUOl9S.js.gz +0 -0
  330. package/dist/dashboard/assets/SortableList-Cfy6_Khv.js.gz +0 -0
  331. package/dist/dashboard/assets/UsagePage-BT4MyQeT.js.gz +0 -0
  332. package/dist/dashboard/assets/api-keys-DQYLQxpW.js.gz +0 -0
  333. package/dist/dashboard/assets/app-CX_Z8cMs.css +0 -2
  334. package/dist/dashboard/assets/app-CX_Z8cMs.css.gz +0 -0
  335. package/dist/dashboard/assets/assert-C9FD_JwJ.js.gz +0 -0
  336. package/dist/dashboard/assets/badge-Bvyr7D0u.js.gz +0 -0
  337. package/dist/dashboard/assets/banner-DIvKAZih.js.gz +0 -0
  338. package/dist/dashboard/assets/card-D5_gYIS_.js.gz +0 -0
  339. package/dist/dashboard/assets/cli-tools-CNqzrfdb.js.gz +0 -0
  340. package/dist/dashboard/assets/clipboard-button-B6vDBtbm.js.gz +0 -0
  341. package/dist/dashboard/assets/dialog-CpjrgdwJ.js.gz +0 -0
  342. package/dist/dashboard/assets/drawer-BWmt3b5y.js.gz +0 -0
  343. package/dist/dashboard/assets/entry-B-gvimrg.js +0 -1
  344. package/dist/dashboard/assets/entry-B-gvimrg.js.gz +0 -0
  345. package/dist/dashboard/assets/entry-C_oakV3z.js.gz +0 -0
  346. package/dist/dashboard/assets/entry-Dj0cHfEc.js +0 -4
  347. package/dist/dashboard/assets/entry-Dj0cHfEc.js.gz +0 -0
  348. package/dist/dashboard/assets/github-badge-RRHHWGxh.js.gz +0 -0
  349. package/dist/dashboard/assets/index-Bhey_rg_.js.gz +0 -0
  350. package/dist/dashboard/assets/inline-C2_EQ5Uf.js.gz +0 -0
  351. package/dist/dashboard/assets/input-BMRlQ450.js.gz +0 -0
  352. package/dist/dashboard/assets/layout-XR5xP8ew.js.gz +0 -0
  353. package/dist/dashboard/assets/list-row-Cw07dioc.js.gz +0 -0
  354. package/dist/dashboard/assets/live-BivGfuUs.js.gz +0 -0
  355. package/dist/dashboard/assets/model-bans-CmffjLuG.js.gz +0 -0
  356. package/dist/dashboard/assets/mutation-46P0oWMe.js.gz +0 -0
  357. package/dist/dashboard/assets/numbers-DrdA9oSM.js.gz +0 -0
  358. package/dist/dashboard/assets/query-keys-BFhGpwR1.js.gz +0 -0
  359. package/dist/dashboard/assets/routing-CAb4F37R.js.gz +0 -0
  360. package/dist/dashboard/assets/select-CxVVB__N.js.gz +0 -0
  361. package/dist/dashboard/assets/settings-DNX9sydi.js.gz +0 -0
  362. package/dist/dashboard/assets/stack-uPOwM9Hs.js.gz +0 -0
  363. package/dist/dashboard/assets/state-D1Yz79iL.js.gz +0 -0
  364. package/dist/dashboard/assets/switch-sevsRrRo.js.gz +0 -0
  365. package/dist/dashboard/assets/useMutation-Dem0lXQj.js.gz +0 -0
  366. package/src/observability/event-compaction.ts +0 -317
  367. package/src/observability/event-day-index.ts +0 -439
@@ -1,1104 +1,1104 @@
1
- /**
2
- * `PoolAgentResolver` — builds, caches, and reaps the per-pool egress agents
3
- * on demand — and `ValidatedNetworkBindingFactory`, which resolves upstream
4
- * names once through the SSRF policy and hands dispatch a validated fetch
5
- * (direct or pool-bound).
6
- */
7
- import type { CartethyiaDatabase } from "../../persistence/db-handle";
8
- import {
9
- isAddressAllowed,
10
- resolveAllAddresses,
11
- resolveAndValidateOnce,
12
- type ValidatedDestination,
13
- } from "../ssrf";
14
- import { createValidatedFetch, type ValidatedFetch } from "../outbound-fetch";
15
- import { GatewayError } from "../../protocol/gateway-error";
16
- import type { SsrfPolicy } from "../../config/network";
17
- import { log } from "../../observability/logger";
18
- import type { TransportKind, PoolAgent } from "./agent";
19
- import { isIP } from "node:net";
20
- import * as tls from "node:tls";
21
- import * as http2 from "node:http2";
22
- import type { Duplex } from "node:stream";
23
- import WebSocket, { type ClientOptions, type RawData } from "ws";
24
- import { isProxyAgentPair, createHttpProxyAgent, createSocks5Agent, PoolBindingError } from "./agent";
25
- import { Agent as HttpsAgent } from "node:https";
26
- import { parseAgentConfig, ProxyConfigError, type AgentConfig } from "../types";
27
- import type { ProviderWebSocketSession, ValidatedOutboundWebSocket, ValidatedOutboundHttp2Bidi } from "../../providers/provider-contracts";
28
- import type { Http2BidiEndReason, Http2BidiOpenOptions, ValidatedHttp2BidiStream } from "../http2-bidi";
29
-
30
- /** Idle time after which an unused pool agent is destroyed. */
31
- const POOL_AGENT_IDLE_MS = 10 * 60_000;
32
- /** Maximum number of concurrent pool agents to prevent unbounded growth. */
33
- const MAX_POOL_AGENTS = 1000;
34
- /** Minimum gap between idle-time sweeps; the per-resolve scan is the hot cost. */
35
- const POOL_AGENT_SWEEP_INTERVAL_MS = 30_000;
36
- /** Time allowed for one HTTP/2 connection (TCP + TLS + preface) to establish. */
37
- const HTTP2_CONNECT_TIMEOUT_MS = 15_000;
38
- /** Read-idle bound after which a bidi stream is declared dead. */
39
- const HTTP2_READ_IDLE_MS = 120_000;
40
- /** Buffered inbound bytes before a bidi stream is refused as unbounded. */
41
- const HTTP2_MAX_BUFFERED_BYTES = 64 * 1024 * 1024;
42
- /**
43
- * Session memory ceiling for one HTTP/2 session.
44
- *
45
- * Node and Bun count a session's unsent bytes against `maxSessionMemory`
46
- * (10 MB by default) and, once over it, fail every new stream with
47
- * `ENHANCE_YOUR_CALM`. A bidi Run request and its KV replies run to many
48
- * megabytes and the caller holds those bytes anyway, so the default only adds
49
- * a failure mode.
50
- */
51
- const HTTP2_SESSION_MAX_MEMORY_MB = 256;
52
-
53
- export interface PoolAgentResolverDeps {
54
- readonly ssrfPolicy?: SsrfPolicy;
55
- readonly resolveFn?: (hostname: string, signal: AbortSignal) => Promise<readonly string[]>;
56
- /** Database used to load pool rows. */
57
- readonly db?: CartethyiaDatabase;
58
- }
59
-
60
- function destroyAgent(agent: unknown): void {
61
- if (isProxyAgentPair(agent)) {
62
- destroyAgent(agent.http);
63
- destroyAgent(agent.https);
64
- return;
65
- }
66
- const maybeDestroy = (agent as { destroy?: unknown }).destroy;
67
- if (typeof maybeDestroy === "function") {
68
- try {
69
- (maybeDestroy as () => void).call(agent);
70
- return;
71
- } catch (error) {
72
- // Destroy failure means sockets may outlive the agent — surface it
73
- // instead of silently leaking the pool's connections.
74
- log.warn("[pool-agent] destroy failed", error);
75
- }
76
- }
77
- const maybeClose = (agent as { close?: unknown }).close;
78
- if (typeof maybeClose === "function") {
79
- try {
80
- (maybeClose as () => void).call(agent);
81
- } catch (error) {
82
- log.warn("[pool-agent] close failed", error);
83
- }
84
- }
85
- }
86
-
87
- async function validateDialHost(
88
- hostname: string,
89
- policy: SsrfPolicy,
90
- resolveFn: (hostname: string, signal: AbortSignal) => Promise<readonly string[]>,
91
- ): Promise<void> {
92
- if (isIP(hostname) !== 0) {
93
- if (!isAddressAllowed(hostname, policy))
94
- throw new PoolBindingError(`pool endpoint resolves to disallowed address: ${hostname}`);
95
- return;
96
- }
97
- const addresses = await resolveFn(hostname, AbortSignal.timeout(5000));
98
- for (const address of addresses) {
99
- if (!isAddressAllowed(address, policy))
100
- throw new PoolBindingError(`pool endpoint resolves to disallowed address: ${address}`);
101
- }
102
- }
103
-
104
- function endpointHostname(kind: TransportKind, endpoint: string): string | undefined {
105
- try {
106
- if (kind === "http" || kind === "https") {
107
- const url = new URL(endpoint);
108
- return url.hostname || undefined;
109
- }
110
- if (kind === "socks5") {
111
- const authority = endpoint.includes("://") ? endpoint : `socks5://${endpoint}`;
112
- const url = new URL(authority);
113
- return url.hostname || undefined;
114
- }
115
- } catch {
116
- return undefined;
117
- }
118
- return undefined;
119
- }
120
-
121
-
122
- export interface NetworkPoolRow {
123
- readonly id: string;
124
- readonly kind: TransportKind;
125
- readonly endpoint: string;
126
- readonly tenantId: string;
127
- readonly credential?: string;
128
- readonly config?: Record<string, unknown>;
129
- }
130
-
131
- export interface NetworkPoolLoader {
132
- load(poolId: string): Promise<NetworkPoolRow | undefined>;
133
- }
134
-
135
- /**
136
- * Per-pool egress agent cache.
137
- *
138
- * Lifecycle: `resolveAgent` single-flights agent construction per
139
- * `tenantId:poolId` by caching the build *promise* — concurrent requests over
140
- * the same pool share one build, and a failed build evicts itself so the next
141
- * caller retries. Each agent holds persistent sockets (`keepAlive: true`) and
142
- * carries the SSRF policy so every connect re-validates the proxy host.
143
- *
144
- * Reaping: entries idle beyond `POOL_AGENT_IDLE_MS` (10 min) are destroyed on
145
- * the next resolve — bounded memory for pools deleted or disabled outside the
146
- * mutation path, while hot entries are never destroyed mid-request. Also enforces
147
- * a hard cardinality ceiling (MAX_POOL_AGENTS) by evicting the least-recently-used
148
- * entries when the cache exceeds the cap.
149
- * `releasePool` tears down eagerly when a pool is mutated; `closeAll` drains
150
- * the cache at shutdown.
151
- *
152
- * Concurrency is deliberately *not* managed here: slot limits are owned by
153
- * `NetworkPoolSelector` (local counters + Redis `proxy:inflight:*`), so
154
- * agents stay pure connection factories.
155
- */
156
- export class PoolAgentResolver {
157
- private readonly agents = new Map<string, Promise<PoolAgent>>();
158
- private readonly lastUsed = new Map<string, number>();
159
- private readonly ssrfPolicy: SsrfPolicy;
160
- private lastIdleSweepAt = 0;
161
- private readonly resolveFn: (
162
- hostname: string,
163
- signal: AbortSignal,
164
- ) => Promise<readonly string[]>;
165
-
166
- constructor(
167
- private readonly loader: NetworkPoolLoader,
168
- deps?: PoolAgentResolverDeps,
169
- ) {
170
- this.ssrfPolicy = deps?.ssrfPolicy ?? {};
171
- const injectedResolve = deps?.resolveFn;
172
- this.resolveFn =
173
- injectedResolve ?? ((hostname, signal) => resolveAllAddresses(hostname, signal));
174
- }
175
-
176
- async resolveAgent(poolId: string, tenantId: string): Promise<PoolAgent> {
177
- const cacheKey = `${tenantId}:${poolId}`;
178
- this.lastUsed.set(cacheKey, Date.now());
179
- this.evictIdleAgents();
180
- const existing = this.agents.get(cacheKey);
181
- if (existing) return existing;
182
- const built = this.buildAgent(poolId, tenantId).catch((err: unknown) => {
183
- this.agents.delete(cacheKey);
184
- this.lastUsed.delete(cacheKey);
185
- throw err;
186
- });
187
- this.agents.set(cacheKey, built);
188
- return built;
189
- }
190
-
191
- /**
192
- * Bounds the agent cache: entries idle beyond POOL_AGENT_IDLE_MS are
193
- * destroyed, so pools deleted or disabled outside the mutation path cannot
194
- * retain agents and sockets for the process lifetime. Hot entries are never
195
- * evicted — destroying an agent an active request is dialing through would
196
- * break that request.
197
- *
198
- * Also enforces a hard cardinality ceiling (MAX_POOL_AGENTS) by evicting
199
- * the least-recently-used entries when the cache exceeds the cap.
200
- *
201
- * The idle-time scan is throttled to once per 30 s: it walks the whole map
202
- * on every dispatch otherwise. The over-cap eviction still runs per call —
203
- * it only allocates when actually over the ceiling.
204
- */
205
- private evictIdleAgents(): void {
206
- const now = Date.now();
207
- if (now - this.lastIdleSweepAt >= POOL_AGENT_SWEEP_INTERVAL_MS) {
208
- this.lastIdleSweepAt = now;
209
- const cutoff = now - POOL_AGENT_IDLE_MS;
210
- for (const [key, usedAt] of this.lastUsed) {
211
- if (usedAt >= cutoff) continue;
212
- this.lastUsed.delete(key);
213
- const pending = this.agents.get(key);
214
- this.agents.delete(key);
215
- if (pending) {
216
- void pending
217
- .then((agent) => destroyAgent(agent))
218
- .catch(() => undefined);
219
- }
220
- }
221
- }
222
- // Evict oldest entries if over the cardinality ceiling
223
- if (this.agents.size > MAX_POOL_AGENTS) {
224
- const entries = Array.from(this.lastUsed.entries()).sort((a, b) => a[1] - b[1]);
225
- const toEvict = entries.slice(0, this.agents.size - MAX_POOL_AGENTS);
226
- for (const [key] of toEvict) {
227
- this.lastUsed.delete(key);
228
- const pending = this.agents.get(key);
229
- this.agents.delete(key);
230
- if (pending) {
231
- void pending
232
- .then((agent) => destroyAgent(agent))
233
- .catch(() => undefined);
234
- }
235
- }
236
- }
237
- }
238
-
239
- /** Drops agents idle past the retention window. Safe to call from maintenance. */
240
- sweepIdleAgents(): void {
241
- this.lastIdleSweepAt = 0;
242
- this.evictIdleAgents();
243
- }
244
-
245
- async closeAll(): Promise<void> {
246
- for (const pending of this.agents.values()) {
247
- try {
248
- const agent = await pending;
249
- destroyAgent(agent);
250
- } catch {
251
- // Rejected builds already removed
252
- }
253
- }
254
- this.agents.clear();
255
- this.lastUsed.clear();
256
-
257
- }
258
-
259
- /** Observable cache size for the runtime metrics sampler. */
260
- agentCount(): number {
261
- return this.agents.size;
262
- }
263
-
264
- async releasePool(poolId: string, tenantId: string): Promise<void> {
265
- const cacheKey = `${tenantId}:${poolId}`;
266
- const pending = this.agents.get(cacheKey);
267
- this.agents.delete(cacheKey);
268
- this.lastUsed.delete(cacheKey);
269
- if (pending) {
270
- try {
271
- destroyAgent(await pending);
272
- } catch {
273
- // Build failed — nothing to destroy.
274
- }
275
- }
276
- }
277
- async resolveWebSocketAgent(poolId: string, tenantId: string): Promise<PoolAgent> {
278
- return this.resolveAgent(poolId, tenantId);
279
- }
280
-
281
- private async buildAgent(poolId: string, tenantId: string): Promise<PoolAgent> {
282
- const row = await this.loader.load(poolId);
283
- if (!row) throw new PoolBindingError(`network pool ${poolId} not found`);
284
- if (row.tenantId !== tenantId)
285
- throw new PoolBindingError(`network pool ${poolId} is not available to this tenant`);
286
-
287
- const agentConfig = this.resolveAgentConfig(row);
288
- switch (row.kind) {
289
- case "http":
290
- case "https":
291
- case "socks5": {
292
- const hostname = endpointHostname(row.kind, row.endpoint);
293
- if (hostname) await validateDialHost(hostname, this.ssrfPolicy, this.resolveFn);
294
- // The policy is handed to the agent too: both flavors re-resolve and
295
- // re-validate the proxy host on every connect, so the create-time
296
- // check cannot be bypassed by a DNS rebind of the proxy record.
297
- // The pool id rides along so the tunnel's bytes are attributed to the
298
- // pool that carried them.
299
- if (row.kind === "socks5")
300
- return createSocks5Agent(row.endpoint, row.credential, this.ssrfPolicy, agentConfig, poolId);
301
- return createHttpProxyAgent(row.endpoint, row.credential, this.ssrfPolicy, agentConfig, poolId);
302
- }
303
- default:
304
- // Compile-time exhaustiveness: new TransportKind variants must add a case.
305
- row.kind satisfies never;
306
- throw new PoolBindingError(`unsupported network pool kind: ${String(row.kind)}`);
307
- }
308
- }
309
-
310
- private resolveAgentConfig(row: NetworkPoolRow): AgentConfig {
311
- try {
312
- return parseAgentConfig(row.config ?? {});
313
- } catch (error) {
314
- if (error instanceof ProxyConfigError)
315
- throw new PoolBindingError(`invalid pool agent config: ${error.message}`);
316
- throw error;
317
- }
318
- }
319
-
320
-
321
- }
322
- /** Resolves upstream names and supplies the enforced outbound fetch capability. */
323
- function connectValidatedWebSocket(
324
- url: URL,
325
- headers: Readonly<Record<string, string>>,
326
- signal: AbortSignal,
327
- destination: ValidatedDestination,
328
- agent?: PoolAgent,
329
- ): Promise<ProviderWebSocketSession> {
330
- const targetUrl = new URL(url.toString());
331
- targetUrl.protocol = "wss:";
332
- targetUrl.port = "";
333
- targetUrl.hash = "";
334
- const connectionOptions: ClientOptions = {
335
- handshakeTimeout: 15_000,
336
- perMessageDeflate: false,
337
- followRedirects: false,
338
- headers: { ...headers },
339
- ...(agent === undefined ? {} : { agent: agentForWebSocket(agent) }),
340
- ...(agent === undefined
341
- ? {
342
- createConnection: () => {
343
- const socket = tls.connect({
344
- host: destination.resolvedAddress,
345
- port: 443,
346
- servername: destination.hostname,
347
- rejectUnauthorized: true,
348
- ALPNProtocols: ["http/1.1"],
349
- });
350
- const onAbort = (): void => {
351
- socket.destroy(new Error("connection aborted"));
352
- };
353
- signal.addEventListener("abort", onAbort, { once: true });
354
- socket.once("secureConnect", () => signal.removeEventListener("abort", onAbort));
355
- return socket;
356
- },
357
- }
358
- : {}),
359
- };
360
- return new Promise<ProviderWebSocketSession>((resolve, reject) => {
361
- const socket = new WebSocket(targetUrl, connectionOptions);
362
- let settled = false;
363
- const timeout = setTimeout(() => {
364
- if (settled) return;
365
- settled = true;
366
- socket.terminate();
367
- reject(new GatewayError("transport_unavailable", 502, "WebSocket handshake timed out"));
368
- }, 15_000);
369
- const abort = (): void => {
370
- if (settled) return;
371
- settled = true;
372
- clearTimeout(timeout);
373
- socket.terminate();
374
- reject(new GatewayError("transport_closed", 499, "WebSocket connection was cancelled"));
375
- };
376
- const onError = (): void => {
377
- if (settled) return;
378
- settled = true;
379
- clearTimeout(timeout);
380
- signal.removeEventListener("abort", abort);
381
- reject(new GatewayError("transport_unavailable", 502, "WebSocket connection failed"));
382
- };
383
- socket.once("open", () => {
384
- if (settled) return;
385
- settled = true;
386
- clearTimeout(timeout);
387
- signal.removeEventListener("abort", abort);
388
- resolve(new WsSession(socket, signal));
389
- });
390
- socket.once("error", onError);
391
- socket.once("unexpected-response", onError);
392
- signal.addEventListener("abort", abort, { once: true });
393
- if (signal.aborted) abort();
394
- });
395
- }
396
-
397
-
398
- function agentForWebSocket(agent: PoolAgent): import("http").Agent | import("https").Agent {
399
- if (isProxyAgentPair(agent)) return agent.https;
400
- if (agent instanceof HttpsAgent) return agent;
401
- throw new GatewayError("proxy_pool_unhealthy", 503, "configured WebSocket proxy pool does not support WSS");
402
- }
403
-
404
- /**
405
- * Rejects a bidi target that cannot be dialed safely.
406
- *
407
- * Every one of these is a property of the URL, not of the DNS answer, so it is
408
- * checked before any resolution: a credential in the authority would be sent to
409
- * whatever host resolves, a non-443 port is not the TLS endpoint the policy
410
- * validated, and an IP literal bypasses the hostname the caller allowlists.
411
- */
412
- function assertBidiTarget(url: URL): void {
413
- if (url.protocol !== "https:") {
414
- throw new GatewayError("invalid_request", 400, "validated HTTP/2 egress requires https");
415
- }
416
- if (url.username || url.password || url.hash) {
417
- throw new GatewayError("invalid_request", 400, "invalid validated HTTP/2 target");
418
- }
419
- if (url.port !== "" && url.port !== "443") {
420
- throw new GatewayError("invalid_request", 400, "validated HTTP/2 egress runs on port 443");
421
- }
422
- const hostname = url.hostname;
423
- if (hostname.length === 0) {
424
- throw new GatewayError("invalid_request", 400, "HTTP/2 target hostname is required");
425
- }
426
- if (isIP(hostname) !== 0) {
427
- throw new GatewayError("invalid_request", 400, "HTTP/2 target must be a hostname, not an IP literal");
428
- }
429
- }
430
-
431
- /**
432
- * Opens one TLS socket to a validated destination.
433
- *
434
- * Direct egress dials the pinned address with SNI set to the *validated
435
- * hostname*: pinning is what stops a DNS rebind between check and connect, and
436
- * preserving SNI is what makes the certificate validate against the name the
437
- * caller asked for rather than against an address.
438
- */
439
- function dialDirect(
440
- destination: ValidatedDestination,
441
- signal: AbortSignal,
442
- ): Promise<tls.TLSSocket> {
443
- return new Promise<tls.TLSSocket>((resolve, reject) => {
444
- const socket = tls.connect({
445
- host: destination.resolvedAddress,
446
- port: destination.port,
447
- servername: destination.hostname,
448
- rejectUnauthorized: true,
449
- ALPNProtocols: ["h2"],
450
- });
451
- const cleanup = (): void => {
452
- clearTimeout(timer);
453
- signal.removeEventListener("abort", onAbort);
454
- };
455
- const onAbort = (): void => {
456
- cleanup();
457
- socket.destroy(new Error("HTTP/2 connection aborted"));
458
- reject(new GatewayError("transport_closed", 499, "HTTP/2 connection was cancelled"));
459
- };
460
- const timer = setTimeout(() => {
461
- cleanup();
462
- socket.destroy();
463
- reject(
464
- new GatewayError("transport_unavailable", 502, "HTTP/2 connection timed out", {}, "network"),
465
- );
466
- }, HTTP2_CONNECT_TIMEOUT_MS);
467
- socket.once("secureConnect", () => {
468
- cleanup();
469
- resolve(socket);
470
- });
471
- socket.once("error", (error) => {
472
- cleanup();
473
- reject(
474
- new GatewayError(
475
- "transport_unavailable",
476
- 502,
477
- `HTTP/2 connection failed: ${error.message}`,
478
- {},
479
- "network",
480
- ),
481
- );
482
- });
483
- signal.addEventListener("abort", onAbort, { once: true });
484
- if (signal.aborted) onAbort();
485
- });
486
- }
487
-
488
- /**
489
- * Opens one TLS socket through a pool agent's CONNECT/SOCKS tunnel.
490
- *
491
- * The agent owns the dial and re-validates the proxy host per connect, so the
492
- * tunnel is the pool's egress; the target TLS handshake then runs inside it
493
- * with SNI set to the validated target hostname.
494
- */
495
- function dialThroughPool(
496
- agent: PoolAgent,
497
- hostname: string,
498
- signal: AbortSignal,
499
- ): Promise<tls.TLSSocket> {
500
- const pair = isProxyAgentPair(agent)
501
- ? agent
502
- : agent instanceof HttpsAgent
503
- ? { https: agent }
504
- : undefined;
505
- if (pair === undefined) {
506
- throw new GatewayError(
507
- "proxy_pool_unhealthy",
508
- 503,
509
- "configured proxy pool does not support HTTPS tunneling",
510
- );
511
- }
512
- return new Promise<tls.TLSSocket>((resolve, reject) => {
513
- pair.https.createConnection(
514
- { host: hostname, port: 443, servername: hostname },
515
- (error: Error | null, socket: Duplex) => {
516
- if (error !== null || socket === null) {
517
- reject(
518
- error instanceof PoolBindingError
519
- ? new GatewayError(
520
- "proxy_pool_unhealthy",
521
- 503,
522
- "Configured proxy pool could not be established.",
523
- { reason: error.message.slice(0, 200) },
524
- "network",
525
- )
526
- : new GatewayError(
527
- "transport_unavailable",
528
- 502,
529
- `HTTP/2 tunnel failed: ${error?.message ?? "no socket"}`,
530
- {},
531
- "network",
532
- ),
533
- );
534
- return;
535
- }
536
- const secured = tls.connect({
537
- socket: socket as tls.TLSSocket,
538
- servername: hostname,
539
- rejectUnauthorized: true,
540
- ALPNProtocols: ["h2"],
541
- });
542
- const cleanup = (): void => {
543
- clearTimeout(timer);
544
- signal.removeEventListener("abort", onAbort);
545
- };
546
- const onAbort = (): void => {
547
- cleanup();
548
- secured.destroy();
549
- reject(new GatewayError("transport_closed", 499, "HTTP/2 connection was cancelled"));
550
- };
551
- const timer = setTimeout(() => {
552
- cleanup();
553
- secured.destroy();
554
- reject(
555
- new GatewayError("transport_unavailable", 502, "HTTP/2 connection timed out", {}, "network"),
556
- );
557
- }, HTTP2_CONNECT_TIMEOUT_MS);
558
- secured.once("secureConnect", () => {
559
- cleanup();
560
- resolve(secured);
561
- });
562
- secured.once("error", (tlsError) => {
563
- cleanup();
564
- reject(
565
- new GatewayError(
566
- "transport_unavailable",
567
- 502,
568
- `HTTP/2 TLS handshake failed: ${tlsError.message}`,
569
- {},
570
- "network",
571
- ),
572
- );
573
- });
574
- signal.addEventListener("abort", onAbort, { once: true });
575
- if (signal.aborted) onAbort();
576
- },
577
- );
578
- });
579
- }
580
-
581
- /**
582
- * Wraps one connected HTTP/2 stream as a validated bidi stream.
583
- *
584
- * Inbound bytes are exposed as an async iterable whose consumer pace is the
585
- * backpressure: a paused consumer leaves the data unread and lets the HTTP/2
586
- * flow-control window close, which is what keeps a multi-megabyte Run from
587
- * buffering entirely in memory.
588
- */
589
- class Http2BidiSession implements ValidatedHttp2BidiStream {
590
- private status = 0;
591
- private responseHeaders: Record<string, string> = {};
592
- private responseTrailers: Record<string, string> = {};
593
- private readonly chunks: Uint8Array[] = [];
594
- private readonly waiters: Array<{
595
- resolve(value: IteratorResult<Uint8Array>): void;
596
- reject(reason: unknown): void;
597
- }> = [];
598
- private inboundEnded = false;
599
- private inboundFailure: Error | undefined;
600
- private ended = false;
601
- private terminalReason: Http2BidiEndReason | undefined;
602
- private readonly terminalListeners = new Set<(reason: Http2BidiEndReason) => void>();
603
- private readIdleTimer: ReturnType<typeof setTimeout> | undefined;
604
- private bufferedBytes = 0;
605
-
606
- constructor(
607
- private readonly stream: http2.ClientHttp2Stream,
608
- private readonly session: http2.ClientHttp2Session,
609
- private readonly readIdleMs: number,
610
- private readonly maxBufferedBytes: number,
611
- ) {
612
- stream.on("response", (headers) => {
613
- const status = headers[":status"];
614
- this.status = typeof status === "string" ? Number(status) : (status ?? 0);
615
- this.responseHeaders = flattenHeaders(headers);
616
- });
617
- stream.on("trailers", (headers) => {
618
- this.responseTrailers = flattenHeaders(headers);
619
- });
620
- stream.on("data", (chunk: Buffer) => {
621
- this.armReadIdle();
622
- const bytes = new Uint8Array(chunk);
623
- this.bufferedBytes += bytes.byteLength;
624
- const waiter = this.waiters.shift();
625
- if (waiter !== undefined) {
626
- this.bufferedBytes -= bytes.byteLength;
627
- waiter.resolve({ done: false, value: bytes });
628
- return;
629
- }
630
- this.chunks.push(bytes);
631
- // A stream that keeps arriving while nobody reads is unbounded growth,
632
- // not backpressure: pause the socket rather than accumulating until the
633
- // process is out of memory.
634
- if (this.bufferedBytes > this.maxBufferedBytes) {
635
- this.fail(
636
- new GatewayError(
637
- "platform_unavailable",
638
- 502,
639
- "HTTP/2 response exceeded the buffer bound",
640
- {},
641
- "network",
642
- ),
643
- );
644
- }
645
- });
646
- stream.on("end", () => this.finishInbound());
647
- stream.on("error", (error) => this.fail(error));
648
- stream.on("aborted", () =>
649
- this.fail(new GatewayError("transport_unavailable", 502, "HTTP/2 stream aborted by remote", {}, "network")),
650
- );
651
- stream.on("close", () => {
652
- this.finishInbound();
653
- this.settleTerminal({ kind: "remote-clean-close" });
654
- });
655
- }
656
-
657
- get response(): { status: number; headers: Readonly<Record<string, string>> } {
658
- return { status: this.status, headers: this.responseHeaders };
659
- }
660
-
661
- get trailers(): Readonly<Record<string, string>> {
662
- return this.responseTrailers;
663
- }
664
-
665
- get body(): AsyncIterable<Uint8Array> {
666
- const self = this;
667
- return {
668
- [Symbol.asyncIterator](): AsyncIterator<Uint8Array> {
669
- return {
670
- next(): Promise<IteratorResult<Uint8Array>> {
671
- return self.nextChunk();
672
- },
673
- return(): Promise<IteratorResult<Uint8Array>> {
674
- self.abort();
675
- return Promise.resolve({ done: true, value: undefined });
676
- },
677
- };
678
- },
679
- };
680
- }
681
-
682
- private nextChunk(): Promise<IteratorResult<Uint8Array>> {
683
- const queued = this.chunks.shift();
684
- if (queued !== undefined) {
685
- this.bufferedBytes -= queued.byteLength;
686
- return Promise.resolve({ done: false, value: queued });
687
- }
688
- if (this.inboundFailure !== undefined) return Promise.reject(this.inboundFailure);
689
- if (this.inboundEnded) return Promise.resolve({ done: true, value: undefined });
690
- return new Promise<IteratorResult<Uint8Array>>((resolve, reject) => {
691
- this.waiters.push({ resolve, reject });
692
- });
693
- }
694
-
695
- private armReadIdle(): void {
696
- clearTimeout(this.readIdleTimer);
697
- this.readIdleTimer = setTimeout(() => {
698
- this.fail(
699
- new GatewayError(
700
- "transport_unavailable",
701
- 504,
702
- `HTTP/2 stream read-idle timeout after ${this.readIdleMs}ms — connection presumed dead`,
703
- {},
704
- "network",
705
- ),
706
- );
707
- }, this.readIdleMs);
708
- this.readIdleTimer.unref?.();
709
- }
710
-
711
- private finishInbound(): void {
712
- clearTimeout(this.readIdleTimer);
713
- this.readIdleTimer = undefined;
714
- if (this.inboundFailure !== undefined || this.inboundEnded) return;
715
- this.inboundEnded = true;
716
- const waiter = this.waiters.shift();
717
- waiter?.resolve({ done: true, value: undefined });
718
- }
719
-
720
- private fail(error: Error): void {
721
- if (this.inboundFailure !== undefined) return;
722
- this.inboundFailure = error;
723
- this.chunks.length = 0;
724
- for (const waiter of this.waiters.splice(0)) waiter.reject(error);
725
- this.settleTerminal({ kind: "remote-error", error });
726
- try {
727
- this.stream.destroy(error);
728
- } catch {
729
- // The stream may already be closing.
730
- }
731
- }
732
-
733
- private settleTerminal(reason: Http2BidiEndReason): void {
734
- if (this.terminalReason !== undefined) return;
735
- this.terminalReason = reason;
736
- for (const listener of this.terminalListeners) {
737
- try {
738
- listener(reason);
739
- } catch {
740
- // An observer cannot destabilize teardown.
741
- }
742
- }
743
- this.terminalListeners.clear();
744
- }
745
-
746
- onTerminal(listener: (reason: Http2BidiEndReason) => void): () => void {
747
- this.terminalListeners.add(listener);
748
- return () => this.terminalListeners.delete(listener);
749
- }
750
-
751
- write(chunk: Uint8Array): Promise<void> {
752
- if (this.inboundFailure !== undefined) return Promise.reject(this.inboundFailure);
753
- if (this.ended) return Promise.reject(new Error("HTTP/2 request side already ended"));
754
- return new Promise<void>((resolve, reject) => {
755
- const flushed = this.stream.write(chunk, (error) => {
756
- if (error !== null && error !== undefined) reject(error);
757
- });
758
- if (flushed) {
759
- resolve();
760
- return;
761
- }
762
- // Backpressured: wait for drain. There is no deadline here — a stream
763
- // error or close settles it, and the read-idle watchdog bounds a stream
764
- // that stops answering.
765
- this.stream.once("drain", () => resolve());
766
- });
767
- }
768
-
769
- end(): void {
770
- if (this.ended) return;
771
- this.ended = true;
772
- try {
773
- this.stream.end();
774
- } catch {
775
- // Already closed by the peer or by abort.
776
- }
777
- }
778
-
779
- abort(reason?: unknown): void {
780
- if (this.ended && this.terminalReason !== undefined) return;
781
- this.ended = true;
782
- this.settleTerminal({ kind: "local-close" });
783
- try {
784
- this.stream.destroy(reason instanceof Error ? reason : undefined);
785
- } catch {
786
- // Already destroyed.
787
- }
788
- try {
789
- this.session.close();
790
- } catch {
791
- // Already closing.
792
- }
793
- }
794
- }
795
-
796
- /** Flattens HTTP/2 header objects into a plain string map. */
797
- function flattenHeaders(headers: Record<string, unknown>): Record<string, string> {
798
- const out: Record<string, string> = {};
799
- for (const [name, value] of Object.entries(headers)) {
800
- if (value === undefined || value === null) continue;
801
- if (Array.isArray(value)) out[name] = value.map((entry) => String(entry)).join(", ");
802
- else out[name] = String(value);
803
- }
804
- return out;
805
- }
806
-
807
- /** Opens one validated bidi stream over an already-connected socket. */
808
- function openBidiStream(
809
- socket: tls.TLSSocket,
810
- url: URL,
811
- headers: Readonly<Record<string, string>>,
812
- options: Http2BidiOpenOptions,
813
- ): Promise<ValidatedHttp2BidiStream> {
814
- // `createConnection`, not the `socket` option: the socket is already a
815
- // validated, ALPN-negotiated TLS connection, and handing it through
816
- // `createConnection` is what makes the session adopt it as-is (Node's
817
- // `socket` option applies its own TLS expectations on top).
818
- const session = http2.connect(`https://${url.hostname}`, {
819
- createConnection: () => socket,
820
- maxSessionMemory: HTTP2_SESSION_MAX_MEMORY_MB,
821
- });
822
- return new Promise<ValidatedHttp2BidiStream>((resolve, reject) => {
823
- const cleanup = (): void => {
824
- clearTimeout(timer);
825
- session.removeListener("error", onError);
826
- session.removeListener("connect", onConnect);
827
- };
828
- const onError = (error: Error): void => {
829
- cleanup();
830
- try {
831
- session.destroy();
832
- } catch {
833
- // Already destroying.
834
- }
835
- reject(
836
- new GatewayError(
837
- "transport_unavailable",
838
- 502,
839
- `HTTP/2 session failed: ${error.message}`,
840
- {},
841
- "network",
842
- ),
843
- );
844
- };
845
- const onConnect = (): void => {
846
- cleanup();
847
- const stream = session.request(
848
- {
849
- ":method": "POST",
850
- ":path": `${url.pathname}${url.search}`,
851
- ":authority": url.hostname,
852
- ":scheme": "https",
853
- ...headers,
854
- } as unknown as http2.OutgoingHttpHeaders,
855
- { endStream: false },
856
- );
857
- resolve(
858
- new Http2BidiSession(
859
- stream,
860
- session,
861
- options.readIdleMs ?? HTTP2_READ_IDLE_MS,
862
- options.maxBufferedBytes ?? HTTP2_MAX_BUFFERED_BYTES,
863
- ),
864
- );
865
- };
866
- const timer = setTimeout(() => {
867
- cleanup();
868
- try {
869
- session.destroy();
870
- } catch {
871
- // Already destroying.
872
- }
873
- reject(
874
- new GatewayError("transport_unavailable", 502, "HTTP/2 session timed out", {}, "network"),
875
- );
876
- }, HTTP2_CONNECT_TIMEOUT_MS);
877
- session.once("error", onError);
878
- session.once("connect", onConnect);
879
- });
880
- }
881
-
882
- class WsSession implements ProviderWebSocketSession {
883
- readonly #socket: WebSocket;
884
- readonly #signal: AbortSignal;
885
- readonly #messages: string[] = [];
886
- readonly #waiters: Array<{
887
- resolve(value: string): void;
888
- reject(reason: Error): void;
889
- cleanup(): void;
890
- }> = [];
891
- #closed: Error | undefined;
892
-
893
- constructor(socket: WebSocket, signal: AbortSignal) {
894
- this.#socket = socket;
895
- this.#signal = signal;
896
- socket.on("message", (data: RawData, isBinary: boolean) => {
897
- if (isBinary) {
898
- this.#fail(new GatewayError("transport_unavailable", 502, "unexpected binary WebSocket frame"));
899
- return;
900
- }
901
- const message = data.toString();
902
- const waiter = this.#waiters.shift();
903
- if (waiter) {
904
- waiter.cleanup();
905
- waiter.resolve(message);
906
- } else {
907
- this.#messages.push(message);
908
- }
909
- });
910
- socket.on("close", () => this.#fail(new GatewayError("transport_closed", 502, "WebSocket connection closed")));
911
- socket.on("error", () => this.#fail(new GatewayError("transport_unavailable", 502, "WebSocket connection failed")));
912
- }
913
-
914
- send(message: string): void {
915
- if (this.#closed) throw this.#closed;
916
- if (this.#socket.readyState !== WebSocket.OPEN) {
917
- throw new GatewayError("transport_closed", 499, "WebSocket connection is not open");
918
- }
919
- this.#socket.send(message, { binary: false });
920
- }
921
-
922
- receive(signal: AbortSignal): Promise<string> {
923
- const queued = this.#messages.shift();
924
- if (queued !== undefined) return Promise.resolve(queued);
925
- if (this.#closed) return Promise.reject(this.#closed);
926
- const combined = AbortSignal.any([this.#signal, signal]);
927
- return new Promise<string>((resolve, reject) => {
928
- const timeout = setTimeout(() => {
929
- cleanup();
930
- reject(new GatewayError("transport_unavailable", 504, "WebSocket receive timed out"));
931
- }, 45_000);
932
- const onAbort = (): void => {
933
- cleanup();
934
- reject(new GatewayError("transport_closed", 499, "WebSocket receive was cancelled"));
935
- };
936
- const cleanup = (): void => {
937
- clearTimeout(timeout);
938
- combined.removeEventListener("abort", onAbort);
939
- };
940
- combined.addEventListener("abort", onAbort, { once: true });
941
- this.#waiters.push({ resolve, reject, cleanup });
942
- if (combined.aborted) onAbort();
943
- });
944
- }
945
-
946
- close(code?: number, reason?: string): void {
947
- if (this.#socket.readyState === WebSocket.CLOSED) return;
948
- this.#socket.close(code, reason);
949
- }
950
-
951
- #fail(error: Error): void {
952
- if (this.#closed) return;
953
- this.#closed = error;
954
- for (const waiter of this.#waiters.splice(0)) {
955
- waiter.cleanup();
956
- waiter.reject(error);
957
- }
958
- }
959
- }
960
- export class ValidatedNetworkBindingFactory {
961
- private readonly directFetch: ValidatedFetch;
962
-
963
- constructor(
964
- private readonly policy: SsrfPolicy = {},
965
- fetchFn?: typeof fetch,
966
- private readonly poolResolver?: PoolAgentResolver,
967
- ) {
968
- this.directFetch = createValidatedFetch({ policy, ...(fetchFn ? { fetchFn } : {}) });
969
- }
970
-
971
- /**
972
- * One-shot DNS resolution + SSRF validation for direct egress targets:
973
- * every resolved address (all families, not just the first answer) must
974
- * pass the SSRF policy, and the validated destination pins the address so
975
- * the subsequent socket cannot be re-pointed at a private/link-local host
976
- * by a DNS rebind between check and connect.
977
- */
978
- resolve(hostname: string, signal: AbortSignal, port = 443): Promise<ValidatedDestination> {
979
- return resolveAndValidateOnce(hostname, this.policy, signal, port);
980
- }
981
-
982
- /**
983
- * Without `networkPoolId` (or when no pool resolver is configured), dispatch
984
- * through validated direct egress. With one, route through that pool's
985
- * validated agent. A pool configuration failure is returned to the caller;
986
- * configured egress is never silently replaced with direct traffic.
987
- */
988
- fetch(networkPoolId?: string, tenantId?: string): ValidatedFetch {
989
- if (!networkPoolId || !this.poolResolver || !tenantId) return this.directFetch;
990
- const { poolResolver, policy } = this;
991
- return async (input, init) => {
992
- try {
993
- const agent = await poolResolver.resolveAgent(networkPoolId, tenantId);
994
- return await createValidatedFetch({ policy, agent })(input, init);
995
- } catch (error) {
996
- if (error instanceof PoolBindingError) {
997
- throw new GatewayError(
998
- "proxy_pool_unhealthy",
999
- 503,
1000
- "Configured proxy pool could not be established.",
1001
- { poolId: networkPoolId, reason: error.message.slice(0, 200) },
1002
- "network",
1003
- );
1004
- }
1005
- throw error;
1006
- }
1007
- };
1008
- }
1009
- webSocket(networkPoolId?: string, tenantId?: string): ValidatedOutboundWebSocket {
1010
- return async (url, headers, signal) => {
1011
- if (url.protocol !== "wss:") {
1012
- throw new GatewayError("invalid_request", 400, "validated WebSocket egress requires wss");
1013
- }
1014
- if (url.username || url.password || url.hash || (url.port !== "" && url.port !== "443")) {
1015
- throw new GatewayError("invalid_request", 400, "invalid validated WebSocket target");
1016
- }
1017
- const hostname = url.hostname;
1018
- if (!hostname) throw new GatewayError("invalid_request", 400, "WebSocket target hostname is required");
1019
- const destination = await this.resolve(hostname, signal, 443);
1020
- if (!isAddressAllowed(destination.resolvedAddress, this.policy)) {
1021
- throw new GatewayError("invalid_request", 400, "unsafe upstream address rejected");
1022
- }
1023
- let agent: PoolAgent | undefined;
1024
- if (networkPoolId || tenantId) {
1025
- if (!networkPoolId || !tenantId || !this.poolResolver) {
1026
- throw new GatewayError("proxy_pool_unhealthy", 503, "configured WebSocket proxy pool could not be established");
1027
- }
1028
- try {
1029
- agent = await this.poolResolver.resolveWebSocketAgent(networkPoolId, tenantId);
1030
- } catch (error) {
1031
- if (error instanceof PoolBindingError) {
1032
- throw new GatewayError("proxy_pool_unhealthy", 503, "configured WebSocket proxy pool could not be established");
1033
- }
1034
- throw error;
1035
- }
1036
- }
1037
- return connectValidatedWebSocket(url, headers, signal, destination, agent);
1038
- };
1039
- }
1040
-
1041
- /**
1042
- * Validated bidirectional HTTP/2 egress for one writable streaming request.
1043
- *
1044
- * Direct egress resolves and validates *every* DNS answer for the hostname,
1045
- * pins the chosen public address, and keeps TLS SNI pointed at the validated
1046
- * hostname — so a rebind between check and connect cannot retarget the socket,
1047
- * and the certificate still validates against the name the caller asked for.
1048
- * Pooled egress reuses the selected tenant pool's CONNECT/SOCKS tunnel and
1049
- * accounts the tunnel's bytes to that pool, exactly as `fetch` does.
1050
- *
1051
- * Credentials in the authority, fragments, non-443 ports, IP literals, and
1052
- * hostnames outside `allowHostname` are refused before anything is dialed: a
1053
- * bidi stream carries the account credential for its whole lifetime, so the
1054
- * target is checked once here rather than trusted to each caller.
1055
- */
1056
- http2Bidi(networkPoolId?: string, tenantId?: string): ValidatedOutboundHttp2Bidi {
1057
- return async (url, headers, options) => {
1058
- assertBidiTarget(url);
1059
- const { hostname } = url;
1060
- if (options?.allowHostname !== undefined && !options.allowHostname(hostname)) {
1061
- throw new GatewayError("invalid_request", 400, "HTTP/2 target host is not allowlisted");
1062
- }
1063
- const signal = options?.signal ?? AbortSignal.timeout(HTTP2_CONNECT_TIMEOUT_MS);
1064
- // Validation runs on every new connection, not once per capability: a
1065
- // long-lived Run is re-opened after a session dies, and a hostname that
1066
- // resolved safely the first time is not safe the twentieth.
1067
- const destination = await this.resolve(hostname, signal, 443);
1068
- if (!isAddressAllowed(destination.resolvedAddress, this.policy)) {
1069
- throw new GatewayError("invalid_request", 400, "unsafe upstream address rejected");
1070
- }
1071
- // Mirrors `fetch`: no pool selected (or none configured) means validated
1072
- // *direct* egress, which is already resolved and SSRF-checked above.
1073
- // Requiring a pool whenever a tenant id was supplied made bidi-only
1074
- // providers (Cursor) unreachable on every install without a pool, even
1075
- // though the equivalent `fetch` path dials direct there.
1076
- if (networkPoolId !== undefined || tenantId !== undefined) {
1077
- if (networkPoolId === undefined || tenantId === undefined || this.poolResolver === undefined) {
1078
- const socket = await dialDirect(destination, signal);
1079
- return openBidiStream(socket, url, headers, options ?? {});
1080
- }
1081
- let agent: PoolAgent;
1082
- try {
1083
- agent = await this.poolResolver.resolveAgent(networkPoolId, tenantId);
1084
- } catch (error) {
1085
- if (error instanceof PoolBindingError) {
1086
- throw new GatewayError(
1087
- "proxy_pool_unhealthy",
1088
- 503,
1089
- "Configured proxy pool could not be established.",
1090
- { poolId: networkPoolId, reason: error.message.slice(0, 200) },
1091
- "network",
1092
- );
1093
- }
1094
- throw error;
1095
- }
1096
- const socket = await dialThroughPool(agent, hostname, signal);
1097
- return openBidiStream(socket, url, headers, options ?? {});
1098
- }
1099
- const socket = await dialDirect(destination, signal);
1100
- return openBidiStream(socket, url, headers, options ?? {});
1101
- };
1102
- }
1103
- }
1104
-
1
+ /**
2
+ * `PoolAgentResolver` — builds, caches, and reaps the per-pool egress agents
3
+ * on demand — and `ValidatedNetworkBindingFactory`, which resolves upstream
4
+ * names once through the SSRF policy and hands dispatch a validated fetch
5
+ * (direct or pool-bound).
6
+ */
7
+ import type { CartethyiaDatabase } from "../../persistence/db-handle";
8
+ import {
9
+ isAddressAllowed,
10
+ resolveAllAddresses,
11
+ resolveAndValidateOnce,
12
+ type ValidatedDestination,
13
+ } from "../ssrf";
14
+ import { createValidatedFetch, type ValidatedFetch } from "../outbound-fetch";
15
+ import { GatewayError } from "../../protocol/gateway-error";
16
+ import type { SsrfPolicy } from "../../config/network";
17
+ import { log } from "../../observability/logger";
18
+ import type { TransportKind, PoolAgent } from "./agent";
19
+ import { isIP } from "node:net";
20
+ import * as tls from "node:tls";
21
+ import * as http2 from "node:http2";
22
+ import type { Duplex } from "node:stream";
23
+ import WebSocket, { type ClientOptions, type RawData } from "ws";
24
+ import { isProxyAgentPair, createHttpProxyAgent, createSocks5Agent, PoolBindingError } from "./agent";
25
+ import { Agent as HttpsAgent } from "node:https";
26
+ import { parseAgentConfig, ProxyConfigError, type AgentConfig } from "../types";
27
+ import type { ProviderWebSocketSession, ValidatedOutboundWebSocket, ValidatedOutboundHttp2Bidi } from "../../providers/provider-contracts";
28
+ import type { Http2BidiEndReason, Http2BidiOpenOptions, ValidatedHttp2BidiStream } from "../http2-bidi";
29
+
30
+ /** Idle time after which an unused pool agent is destroyed. */
31
+ const POOL_AGENT_IDLE_MS = 10 * 60_000;
32
+ /** Maximum number of concurrent pool agents to prevent unbounded growth. */
33
+ const MAX_POOL_AGENTS = 1000;
34
+ /** Minimum gap between idle-time sweeps; the per-resolve scan is the hot cost. */
35
+ const POOL_AGENT_SWEEP_INTERVAL_MS = 30_000;
36
+ /** Time allowed for one HTTP/2 connection (TCP + TLS + preface) to establish. */
37
+ const HTTP2_CONNECT_TIMEOUT_MS = 15_000;
38
+ /** Read-idle bound after which a bidi stream is declared dead. */
39
+ const HTTP2_READ_IDLE_MS = 120_000;
40
+ /** Buffered inbound bytes before a bidi stream is refused as unbounded. */
41
+ const HTTP2_MAX_BUFFERED_BYTES = 64 * 1024 * 1024;
42
+ /**
43
+ * Session memory ceiling for one HTTP/2 session.
44
+ *
45
+ * Node and Bun count a session's unsent bytes against `maxSessionMemory`
46
+ * (10 MB by default) and, once over it, fail every new stream with
47
+ * `ENHANCE_YOUR_CALM`. A bidi Run request and its KV replies run to many
48
+ * megabytes and the caller holds those bytes anyway, so the default only adds
49
+ * a failure mode.
50
+ */
51
+ const HTTP2_SESSION_MAX_MEMORY_MB = 256;
52
+
53
+ export interface PoolAgentResolverDeps {
54
+ readonly ssrfPolicy?: SsrfPolicy;
55
+ readonly resolveFn?: (hostname: string, signal: AbortSignal) => Promise<readonly string[]>;
56
+ /** Database used to load pool rows. */
57
+ readonly db?: CartethyiaDatabase;
58
+ }
59
+
60
+ function destroyAgent(agent: unknown): void {
61
+ if (isProxyAgentPair(agent)) {
62
+ destroyAgent(agent.http);
63
+ destroyAgent(agent.https);
64
+ return;
65
+ }
66
+ const maybeDestroy = (agent as { destroy?: unknown }).destroy;
67
+ if (typeof maybeDestroy === "function") {
68
+ try {
69
+ (maybeDestroy as () => void).call(agent);
70
+ return;
71
+ } catch (error) {
72
+ // Destroy failure means sockets may outlive the agent — surface it
73
+ // instead of silently leaking the pool's connections.
74
+ log.warn("[pool-agent] destroy failed", error);
75
+ }
76
+ }
77
+ const maybeClose = (agent as { close?: unknown }).close;
78
+ if (typeof maybeClose === "function") {
79
+ try {
80
+ (maybeClose as () => void).call(agent);
81
+ } catch (error) {
82
+ log.warn("[pool-agent] close failed", error);
83
+ }
84
+ }
85
+ }
86
+
87
+ async function validateDialHost(
88
+ hostname: string,
89
+ policy: SsrfPolicy,
90
+ resolveFn: (hostname: string, signal: AbortSignal) => Promise<readonly string[]>,
91
+ ): Promise<void> {
92
+ if (isIP(hostname) !== 0) {
93
+ if (!isAddressAllowed(hostname, policy))
94
+ throw new PoolBindingError(`pool endpoint resolves to disallowed address: ${hostname}`);
95
+ return;
96
+ }
97
+ const addresses = await resolveFn(hostname, AbortSignal.timeout(5000));
98
+ for (const address of addresses) {
99
+ if (!isAddressAllowed(address, policy))
100
+ throw new PoolBindingError(`pool endpoint resolves to disallowed address: ${address}`);
101
+ }
102
+ }
103
+
104
+ function endpointHostname(kind: TransportKind, endpoint: string): string | undefined {
105
+ try {
106
+ if (kind === "http" || kind === "https") {
107
+ const url = new URL(endpoint);
108
+ return url.hostname || undefined;
109
+ }
110
+ if (kind === "socks5") {
111
+ const authority = endpoint.includes("://") ? endpoint : `socks5://${endpoint}`;
112
+ const url = new URL(authority);
113
+ return url.hostname || undefined;
114
+ }
115
+ } catch {
116
+ return undefined;
117
+ }
118
+ return undefined;
119
+ }
120
+
121
+
122
+ export interface NetworkPoolRow {
123
+ readonly id: string;
124
+ readonly kind: TransportKind;
125
+ readonly endpoint: string;
126
+ readonly tenantId: string;
127
+ readonly credential?: string;
128
+ readonly config?: Record<string, unknown>;
129
+ }
130
+
131
+ export interface NetworkPoolLoader {
132
+ load(poolId: string): Promise<NetworkPoolRow | undefined>;
133
+ }
134
+
135
+ /**
136
+ * Per-pool egress agent cache.
137
+ *
138
+ * Lifecycle: `resolveAgent` single-flights agent construction per
139
+ * `tenantId:poolId` by caching the build *promise* — concurrent requests over
140
+ * the same pool share one build, and a failed build evicts itself so the next
141
+ * caller retries. Each agent holds persistent sockets (`keepAlive: true`) and
142
+ * carries the SSRF policy so every connect re-validates the proxy host.
143
+ *
144
+ * Reaping: entries idle beyond `POOL_AGENT_IDLE_MS` (10 min) are destroyed on
145
+ * the next resolve — bounded memory for pools deleted or disabled outside the
146
+ * mutation path, while hot entries are never destroyed mid-request. Also enforces
147
+ * a hard cardinality ceiling (MAX_POOL_AGENTS) by evicting the least-recently-used
148
+ * entries when the cache exceeds the cap.
149
+ * `releasePool` tears down eagerly when a pool is mutated; `closeAll` drains
150
+ * the cache at shutdown.
151
+ *
152
+ * Concurrency is deliberately *not* managed here: slot limits are owned by
153
+ * `NetworkPoolSelector` (local counters + Redis `proxy:inflight:*`), so
154
+ * agents stay pure connection factories.
155
+ */
156
+ export class PoolAgentResolver {
157
+ private readonly agents = new Map<string, Promise<PoolAgent>>();
158
+ private readonly lastUsed = new Map<string, number>();
159
+ private readonly ssrfPolicy: SsrfPolicy;
160
+ private lastIdleSweepAt = 0;
161
+ private readonly resolveFn: (
162
+ hostname: string,
163
+ signal: AbortSignal,
164
+ ) => Promise<readonly string[]>;
165
+
166
+ constructor(
167
+ private readonly loader: NetworkPoolLoader,
168
+ deps?: PoolAgentResolverDeps,
169
+ ) {
170
+ this.ssrfPolicy = deps?.ssrfPolicy ?? {};
171
+ const injectedResolve = deps?.resolveFn;
172
+ this.resolveFn =
173
+ injectedResolve ?? ((hostname, signal) => resolveAllAddresses(hostname, signal));
174
+ }
175
+
176
+ async resolveAgent(poolId: string, tenantId: string): Promise<PoolAgent> {
177
+ const cacheKey = `${tenantId}:${poolId}`;
178
+ this.lastUsed.set(cacheKey, Date.now());
179
+ this.evictIdleAgents();
180
+ const existing = this.agents.get(cacheKey);
181
+ if (existing) return existing;
182
+ const built = this.buildAgent(poolId, tenantId).catch((err: unknown) => {
183
+ this.agents.delete(cacheKey);
184
+ this.lastUsed.delete(cacheKey);
185
+ throw err;
186
+ });
187
+ this.agents.set(cacheKey, built);
188
+ return built;
189
+ }
190
+
191
+ /**
192
+ * Bounds the agent cache: entries idle beyond POOL_AGENT_IDLE_MS are
193
+ * destroyed, so pools deleted or disabled outside the mutation path cannot
194
+ * retain agents and sockets for the process lifetime. Hot entries are never
195
+ * evicted — destroying an agent an active request is dialing through would
196
+ * break that request.
197
+ *
198
+ * Also enforces a hard cardinality ceiling (MAX_POOL_AGENTS) by evicting
199
+ * the least-recently-used entries when the cache exceeds the cap.
200
+ *
201
+ * The idle-time scan is throttled to once per 30 s: it walks the whole map
202
+ * on every dispatch otherwise. The over-cap eviction still runs per call —
203
+ * it only allocates when actually over the ceiling.
204
+ */
205
+ private evictIdleAgents(): void {
206
+ const now = Date.now();
207
+ if (now - this.lastIdleSweepAt >= POOL_AGENT_SWEEP_INTERVAL_MS) {
208
+ this.lastIdleSweepAt = now;
209
+ const cutoff = now - POOL_AGENT_IDLE_MS;
210
+ for (const [key, usedAt] of this.lastUsed) {
211
+ if (usedAt >= cutoff) continue;
212
+ this.lastUsed.delete(key);
213
+ const pending = this.agents.get(key);
214
+ this.agents.delete(key);
215
+ if (pending) {
216
+ void pending
217
+ .then((agent) => destroyAgent(agent))
218
+ .catch(() => undefined);
219
+ }
220
+ }
221
+ }
222
+ // Evict oldest entries if over the cardinality ceiling
223
+ if (this.agents.size > MAX_POOL_AGENTS) {
224
+ const entries = Array.from(this.lastUsed.entries()).sort((a, b) => a[1] - b[1]);
225
+ const toEvict = entries.slice(0, this.agents.size - MAX_POOL_AGENTS);
226
+ for (const [key] of toEvict) {
227
+ this.lastUsed.delete(key);
228
+ const pending = this.agents.get(key);
229
+ this.agents.delete(key);
230
+ if (pending) {
231
+ void pending
232
+ .then((agent) => destroyAgent(agent))
233
+ .catch(() => undefined);
234
+ }
235
+ }
236
+ }
237
+ }
238
+
239
+ /** Drops agents idle past the retention window. Safe to call from maintenance. */
240
+ sweepIdleAgents(): void {
241
+ this.lastIdleSweepAt = 0;
242
+ this.evictIdleAgents();
243
+ }
244
+
245
+ async closeAll(): Promise<void> {
246
+ for (const pending of this.agents.values()) {
247
+ try {
248
+ const agent = await pending;
249
+ destroyAgent(agent);
250
+ } catch {
251
+ // Rejected builds already removed
252
+ }
253
+ }
254
+ this.agents.clear();
255
+ this.lastUsed.clear();
256
+
257
+ }
258
+
259
+ /** Observable cache size for the runtime metrics sampler. */
260
+ agentCount(): number {
261
+ return this.agents.size;
262
+ }
263
+
264
+ async releasePool(poolId: string, tenantId: string): Promise<void> {
265
+ const cacheKey = `${tenantId}:${poolId}`;
266
+ const pending = this.agents.get(cacheKey);
267
+ this.agents.delete(cacheKey);
268
+ this.lastUsed.delete(cacheKey);
269
+ if (pending) {
270
+ try {
271
+ destroyAgent(await pending);
272
+ } catch {
273
+ // Build failed — nothing to destroy.
274
+ }
275
+ }
276
+ }
277
+ async resolveWebSocketAgent(poolId: string, tenantId: string): Promise<PoolAgent> {
278
+ return this.resolveAgent(poolId, tenantId);
279
+ }
280
+
281
+ private async buildAgent(poolId: string, tenantId: string): Promise<PoolAgent> {
282
+ const row = await this.loader.load(poolId);
283
+ if (!row) throw new PoolBindingError(`network pool ${poolId} not found`);
284
+ if (row.tenantId !== tenantId)
285
+ throw new PoolBindingError(`network pool ${poolId} is not available to this tenant`);
286
+
287
+ const agentConfig = this.resolveAgentConfig(row);
288
+ switch (row.kind) {
289
+ case "http":
290
+ case "https":
291
+ case "socks5": {
292
+ const hostname = endpointHostname(row.kind, row.endpoint);
293
+ if (hostname) await validateDialHost(hostname, this.ssrfPolicy, this.resolveFn);
294
+ // The policy is handed to the agent too: both flavors re-resolve and
295
+ // re-validate the proxy host on every connect, so the create-time
296
+ // check cannot be bypassed by a DNS rebind of the proxy record.
297
+ // The pool id rides along so the tunnel's bytes are attributed to the
298
+ // pool that carried them.
299
+ if (row.kind === "socks5")
300
+ return createSocks5Agent(row.endpoint, row.credential, this.ssrfPolicy, agentConfig, poolId);
301
+ return createHttpProxyAgent(row.endpoint, row.credential, this.ssrfPolicy, agentConfig, poolId);
302
+ }
303
+ default:
304
+ // Compile-time exhaustiveness: new TransportKind variants must add a case.
305
+ row.kind satisfies never;
306
+ throw new PoolBindingError(`unsupported network pool kind: ${String(row.kind)}`);
307
+ }
308
+ }
309
+
310
+ private resolveAgentConfig(row: NetworkPoolRow): AgentConfig {
311
+ try {
312
+ return parseAgentConfig(row.config ?? {});
313
+ } catch (error) {
314
+ if (error instanceof ProxyConfigError)
315
+ throw new PoolBindingError(`invalid pool agent config: ${error.message}`);
316
+ throw error;
317
+ }
318
+ }
319
+
320
+
321
+ }
322
+ /** Resolves upstream names and supplies the enforced outbound fetch capability. */
323
+ function connectValidatedWebSocket(
324
+ url: URL,
325
+ headers: Readonly<Record<string, string>>,
326
+ signal: AbortSignal,
327
+ destination: ValidatedDestination,
328
+ agent?: PoolAgent,
329
+ ): Promise<ProviderWebSocketSession> {
330
+ const targetUrl = new URL(url.toString());
331
+ targetUrl.protocol = "wss:";
332
+ targetUrl.port = "";
333
+ targetUrl.hash = "";
334
+ const connectionOptions: ClientOptions = {
335
+ handshakeTimeout: 15_000,
336
+ perMessageDeflate: false,
337
+ followRedirects: false,
338
+ headers: { ...headers },
339
+ ...(agent === undefined ? {} : { agent: agentForWebSocket(agent) }),
340
+ ...(agent === undefined
341
+ ? {
342
+ createConnection: () => {
343
+ const socket = tls.connect({
344
+ host: destination.resolvedAddress,
345
+ port: 443,
346
+ servername: destination.hostname,
347
+ rejectUnauthorized: true,
348
+ ALPNProtocols: ["http/1.1"],
349
+ });
350
+ const onAbort = (): void => {
351
+ socket.destroy(new Error("connection aborted"));
352
+ };
353
+ signal.addEventListener("abort", onAbort, { once: true });
354
+ socket.once("secureConnect", () => signal.removeEventListener("abort", onAbort));
355
+ return socket;
356
+ },
357
+ }
358
+ : {}),
359
+ };
360
+ return new Promise<ProviderWebSocketSession>((resolve, reject) => {
361
+ const socket = new WebSocket(targetUrl, connectionOptions);
362
+ let settled = false;
363
+ const timeout = setTimeout(() => {
364
+ if (settled) return;
365
+ settled = true;
366
+ socket.terminate();
367
+ reject(new GatewayError("transport_unavailable", 502, "WebSocket handshake timed out"));
368
+ }, 15_000);
369
+ const abort = (): void => {
370
+ if (settled) return;
371
+ settled = true;
372
+ clearTimeout(timeout);
373
+ socket.terminate();
374
+ reject(new GatewayError("transport_closed", 499, "WebSocket connection was cancelled"));
375
+ };
376
+ const onError = (): void => {
377
+ if (settled) return;
378
+ settled = true;
379
+ clearTimeout(timeout);
380
+ signal.removeEventListener("abort", abort);
381
+ reject(new GatewayError("transport_unavailable", 502, "WebSocket connection failed"));
382
+ };
383
+ socket.once("open", () => {
384
+ if (settled) return;
385
+ settled = true;
386
+ clearTimeout(timeout);
387
+ signal.removeEventListener("abort", abort);
388
+ resolve(new WsSession(socket, signal));
389
+ });
390
+ socket.once("error", onError);
391
+ socket.once("unexpected-response", onError);
392
+ signal.addEventListener("abort", abort, { once: true });
393
+ if (signal.aborted) abort();
394
+ });
395
+ }
396
+
397
+
398
+ function agentForWebSocket(agent: PoolAgent): import("http").Agent | import("https").Agent {
399
+ if (isProxyAgentPair(agent)) return agent.https;
400
+ if (agent instanceof HttpsAgent) return agent;
401
+ throw new GatewayError("proxy_pool_unhealthy", 503, "configured WebSocket proxy pool does not support WSS");
402
+ }
403
+
404
+ /**
405
+ * Rejects a bidi target that cannot be dialed safely.
406
+ *
407
+ * Every one of these is a property of the URL, not of the DNS answer, so it is
408
+ * checked before any resolution: a credential in the authority would be sent to
409
+ * whatever host resolves, a non-443 port is not the TLS endpoint the policy
410
+ * validated, and an IP literal bypasses the hostname the caller allowlists.
411
+ */
412
+ function assertBidiTarget(url: URL): void {
413
+ if (url.protocol !== "https:") {
414
+ throw new GatewayError("invalid_request", 400, "validated HTTP/2 egress requires https");
415
+ }
416
+ if (url.username || url.password || url.hash) {
417
+ throw new GatewayError("invalid_request", 400, "invalid validated HTTP/2 target");
418
+ }
419
+ if (url.port !== "" && url.port !== "443") {
420
+ throw new GatewayError("invalid_request", 400, "validated HTTP/2 egress runs on port 443");
421
+ }
422
+ const hostname = url.hostname;
423
+ if (hostname.length === 0) {
424
+ throw new GatewayError("invalid_request", 400, "HTTP/2 target hostname is required");
425
+ }
426
+ if (isIP(hostname) !== 0) {
427
+ throw new GatewayError("invalid_request", 400, "HTTP/2 target must be a hostname, not an IP literal");
428
+ }
429
+ }
430
+
431
+ /**
432
+ * Opens one TLS socket to a validated destination.
433
+ *
434
+ * Direct egress dials the pinned address with SNI set to the *validated
435
+ * hostname*: pinning is what stops a DNS rebind between check and connect, and
436
+ * preserving SNI is what makes the certificate validate against the name the
437
+ * caller asked for rather than against an address.
438
+ */
439
+ function dialDirect(
440
+ destination: ValidatedDestination,
441
+ signal: AbortSignal,
442
+ ): Promise<tls.TLSSocket> {
443
+ return new Promise<tls.TLSSocket>((resolve, reject) => {
444
+ const socket = tls.connect({
445
+ host: destination.resolvedAddress,
446
+ port: destination.port,
447
+ servername: destination.hostname,
448
+ rejectUnauthorized: true,
449
+ ALPNProtocols: ["h2"],
450
+ });
451
+ const cleanup = (): void => {
452
+ clearTimeout(timer);
453
+ signal.removeEventListener("abort", onAbort);
454
+ };
455
+ const onAbort = (): void => {
456
+ cleanup();
457
+ socket.destroy(new Error("HTTP/2 connection aborted"));
458
+ reject(new GatewayError("transport_closed", 499, "HTTP/2 connection was cancelled"));
459
+ };
460
+ const timer = setTimeout(() => {
461
+ cleanup();
462
+ socket.destroy();
463
+ reject(
464
+ new GatewayError("transport_unavailable", 502, "HTTP/2 connection timed out", {}, "network"),
465
+ );
466
+ }, HTTP2_CONNECT_TIMEOUT_MS);
467
+ socket.once("secureConnect", () => {
468
+ cleanup();
469
+ resolve(socket);
470
+ });
471
+ socket.once("error", (error) => {
472
+ cleanup();
473
+ reject(
474
+ new GatewayError(
475
+ "transport_unavailable",
476
+ 502,
477
+ `HTTP/2 connection failed: ${error.message}`,
478
+ {},
479
+ "network",
480
+ ),
481
+ );
482
+ });
483
+ signal.addEventListener("abort", onAbort, { once: true });
484
+ if (signal.aborted) onAbort();
485
+ });
486
+ }
487
+
488
+ /**
489
+ * Opens one TLS socket through a pool agent's CONNECT/SOCKS tunnel.
490
+ *
491
+ * The agent owns the dial and re-validates the proxy host per connect, so the
492
+ * tunnel is the pool's egress; the target TLS handshake then runs inside it
493
+ * with SNI set to the validated target hostname.
494
+ */
495
+ function dialThroughPool(
496
+ agent: PoolAgent,
497
+ hostname: string,
498
+ signal: AbortSignal,
499
+ ): Promise<tls.TLSSocket> {
500
+ const pair = isProxyAgentPair(agent)
501
+ ? agent
502
+ : agent instanceof HttpsAgent
503
+ ? { https: agent }
504
+ : undefined;
505
+ if (pair === undefined) {
506
+ throw new GatewayError(
507
+ "proxy_pool_unhealthy",
508
+ 503,
509
+ "configured proxy pool does not support HTTPS tunneling",
510
+ );
511
+ }
512
+ return new Promise<tls.TLSSocket>((resolve, reject) => {
513
+ pair.https.createConnection(
514
+ { host: hostname, port: 443, servername: hostname },
515
+ (error: Error | null, socket: Duplex) => {
516
+ if (error !== null || socket === null) {
517
+ reject(
518
+ error instanceof PoolBindingError
519
+ ? new GatewayError(
520
+ "proxy_pool_unhealthy",
521
+ 503,
522
+ "Configured proxy pool could not be established.",
523
+ { reason: error.message.slice(0, 200) },
524
+ "network",
525
+ )
526
+ : new GatewayError(
527
+ "transport_unavailable",
528
+ 502,
529
+ `HTTP/2 tunnel failed: ${error?.message ?? "no socket"}`,
530
+ {},
531
+ "network",
532
+ ),
533
+ );
534
+ return;
535
+ }
536
+ const secured = tls.connect({
537
+ socket: socket as tls.TLSSocket,
538
+ servername: hostname,
539
+ rejectUnauthorized: true,
540
+ ALPNProtocols: ["h2"],
541
+ });
542
+ const cleanup = (): void => {
543
+ clearTimeout(timer);
544
+ signal.removeEventListener("abort", onAbort);
545
+ };
546
+ const onAbort = (): void => {
547
+ cleanup();
548
+ secured.destroy();
549
+ reject(new GatewayError("transport_closed", 499, "HTTP/2 connection was cancelled"));
550
+ };
551
+ const timer = setTimeout(() => {
552
+ cleanup();
553
+ secured.destroy();
554
+ reject(
555
+ new GatewayError("transport_unavailable", 502, "HTTP/2 connection timed out", {}, "network"),
556
+ );
557
+ }, HTTP2_CONNECT_TIMEOUT_MS);
558
+ secured.once("secureConnect", () => {
559
+ cleanup();
560
+ resolve(secured);
561
+ });
562
+ secured.once("error", (tlsError) => {
563
+ cleanup();
564
+ reject(
565
+ new GatewayError(
566
+ "transport_unavailable",
567
+ 502,
568
+ `HTTP/2 TLS handshake failed: ${tlsError.message}`,
569
+ {},
570
+ "network",
571
+ ),
572
+ );
573
+ });
574
+ signal.addEventListener("abort", onAbort, { once: true });
575
+ if (signal.aborted) onAbort();
576
+ },
577
+ );
578
+ });
579
+ }
580
+
581
+ /**
582
+ * Wraps one connected HTTP/2 stream as a validated bidi stream.
583
+ *
584
+ * Inbound bytes are exposed as an async iterable whose consumer pace is the
585
+ * backpressure: a paused consumer leaves the data unread and lets the HTTP/2
586
+ * flow-control window close, which is what keeps a multi-megabyte Run from
587
+ * buffering entirely in memory.
588
+ */
589
+ class Http2BidiSession implements ValidatedHttp2BidiStream {
590
+ private status = 0;
591
+ private responseHeaders: Record<string, string> = {};
592
+ private responseTrailers: Record<string, string> = {};
593
+ private readonly chunks: Uint8Array[] = [];
594
+ private readonly waiters: Array<{
595
+ resolve(value: IteratorResult<Uint8Array>): void;
596
+ reject(reason: unknown): void;
597
+ }> = [];
598
+ private inboundEnded = false;
599
+ private inboundFailure: Error | undefined;
600
+ private ended = false;
601
+ private terminalReason: Http2BidiEndReason | undefined;
602
+ private readonly terminalListeners = new Set<(reason: Http2BidiEndReason) => void>();
603
+ private readIdleTimer: ReturnType<typeof setTimeout> | undefined;
604
+ private bufferedBytes = 0;
605
+
606
+ constructor(
607
+ private readonly stream: http2.ClientHttp2Stream,
608
+ private readonly session: http2.ClientHttp2Session,
609
+ private readonly readIdleMs: number,
610
+ private readonly maxBufferedBytes: number,
611
+ ) {
612
+ stream.on("response", (headers) => {
613
+ const status = headers[":status"];
614
+ this.status = typeof status === "string" ? Number(status) : (status ?? 0);
615
+ this.responseHeaders = flattenHeaders(headers);
616
+ });
617
+ stream.on("trailers", (headers) => {
618
+ this.responseTrailers = flattenHeaders(headers);
619
+ });
620
+ stream.on("data", (chunk: Buffer) => {
621
+ this.armReadIdle();
622
+ const bytes = new Uint8Array(chunk);
623
+ this.bufferedBytes += bytes.byteLength;
624
+ const waiter = this.waiters.shift();
625
+ if (waiter !== undefined) {
626
+ this.bufferedBytes -= bytes.byteLength;
627
+ waiter.resolve({ done: false, value: bytes });
628
+ return;
629
+ }
630
+ this.chunks.push(bytes);
631
+ // A stream that keeps arriving while nobody reads is unbounded growth,
632
+ // not backpressure: pause the socket rather than accumulating until the
633
+ // process is out of memory.
634
+ if (this.bufferedBytes > this.maxBufferedBytes) {
635
+ this.fail(
636
+ new GatewayError(
637
+ "platform_unavailable",
638
+ 502,
639
+ "HTTP/2 response exceeded the buffer bound",
640
+ {},
641
+ "network",
642
+ ),
643
+ );
644
+ }
645
+ });
646
+ stream.on("end", () => this.finishInbound());
647
+ stream.on("error", (error) => this.fail(error));
648
+ stream.on("aborted", () =>
649
+ this.fail(new GatewayError("transport_unavailable", 502, "HTTP/2 stream aborted by remote", {}, "network")),
650
+ );
651
+ stream.on("close", () => {
652
+ this.finishInbound();
653
+ this.settleTerminal({ kind: "remote-clean-close" });
654
+ });
655
+ }
656
+
657
+ get response(): { status: number; headers: Readonly<Record<string, string>> } {
658
+ return { status: this.status, headers: this.responseHeaders };
659
+ }
660
+
661
+ get trailers(): Readonly<Record<string, string>> {
662
+ return this.responseTrailers;
663
+ }
664
+
665
+ get body(): AsyncIterable<Uint8Array> {
666
+ const self = this;
667
+ return {
668
+ [Symbol.asyncIterator](): AsyncIterator<Uint8Array> {
669
+ return {
670
+ next(): Promise<IteratorResult<Uint8Array>> {
671
+ return self.nextChunk();
672
+ },
673
+ return(): Promise<IteratorResult<Uint8Array>> {
674
+ self.abort();
675
+ return Promise.resolve({ done: true, value: undefined });
676
+ },
677
+ };
678
+ },
679
+ };
680
+ }
681
+
682
+ private nextChunk(): Promise<IteratorResult<Uint8Array>> {
683
+ const queued = this.chunks.shift();
684
+ if (queued !== undefined) {
685
+ this.bufferedBytes -= queued.byteLength;
686
+ return Promise.resolve({ done: false, value: queued });
687
+ }
688
+ if (this.inboundFailure !== undefined) return Promise.reject(this.inboundFailure);
689
+ if (this.inboundEnded) return Promise.resolve({ done: true, value: undefined });
690
+ return new Promise<IteratorResult<Uint8Array>>((resolve, reject) => {
691
+ this.waiters.push({ resolve, reject });
692
+ });
693
+ }
694
+
695
+ private armReadIdle(): void {
696
+ clearTimeout(this.readIdleTimer);
697
+ this.readIdleTimer = setTimeout(() => {
698
+ this.fail(
699
+ new GatewayError(
700
+ "transport_unavailable",
701
+ 504,
702
+ `HTTP/2 stream read-idle timeout after ${this.readIdleMs}ms — connection presumed dead`,
703
+ {},
704
+ "network",
705
+ ),
706
+ );
707
+ }, this.readIdleMs);
708
+ this.readIdleTimer.unref?.();
709
+ }
710
+
711
+ private finishInbound(): void {
712
+ clearTimeout(this.readIdleTimer);
713
+ this.readIdleTimer = undefined;
714
+ if (this.inboundFailure !== undefined || this.inboundEnded) return;
715
+ this.inboundEnded = true;
716
+ const waiter = this.waiters.shift();
717
+ waiter?.resolve({ done: true, value: undefined });
718
+ }
719
+
720
+ private fail(error: Error): void {
721
+ if (this.inboundFailure !== undefined) return;
722
+ this.inboundFailure = error;
723
+ this.chunks.length = 0;
724
+ for (const waiter of this.waiters.splice(0)) waiter.reject(error);
725
+ this.settleTerminal({ kind: "remote-error", error });
726
+ try {
727
+ this.stream.destroy(error);
728
+ } catch {
729
+ // The stream may already be closing.
730
+ }
731
+ }
732
+
733
+ private settleTerminal(reason: Http2BidiEndReason): void {
734
+ if (this.terminalReason !== undefined) return;
735
+ this.terminalReason = reason;
736
+ for (const listener of this.terminalListeners) {
737
+ try {
738
+ listener(reason);
739
+ } catch {
740
+ // An observer cannot destabilize teardown.
741
+ }
742
+ }
743
+ this.terminalListeners.clear();
744
+ }
745
+
746
+ onTerminal(listener: (reason: Http2BidiEndReason) => void): () => void {
747
+ this.terminalListeners.add(listener);
748
+ return () => this.terminalListeners.delete(listener);
749
+ }
750
+
751
+ write(chunk: Uint8Array): Promise<void> {
752
+ if (this.inboundFailure !== undefined) return Promise.reject(this.inboundFailure);
753
+ if (this.ended) return Promise.reject(new Error("HTTP/2 request side already ended"));
754
+ return new Promise<void>((resolve, reject) => {
755
+ const flushed = this.stream.write(chunk, (error) => {
756
+ if (error !== null && error !== undefined) reject(error);
757
+ });
758
+ if (flushed) {
759
+ resolve();
760
+ return;
761
+ }
762
+ // Backpressured: wait for drain. There is no deadline here — a stream
763
+ // error or close settles it, and the read-idle watchdog bounds a stream
764
+ // that stops answering.
765
+ this.stream.once("drain", () => resolve());
766
+ });
767
+ }
768
+
769
+ end(): void {
770
+ if (this.ended) return;
771
+ this.ended = true;
772
+ try {
773
+ this.stream.end();
774
+ } catch {
775
+ // Already closed by the peer or by abort.
776
+ }
777
+ }
778
+
779
+ abort(reason?: unknown): void {
780
+ if (this.ended && this.terminalReason !== undefined) return;
781
+ this.ended = true;
782
+ this.settleTerminal({ kind: "local-close" });
783
+ try {
784
+ this.stream.destroy(reason instanceof Error ? reason : undefined);
785
+ } catch {
786
+ // Already destroyed.
787
+ }
788
+ try {
789
+ this.session.close();
790
+ } catch {
791
+ // Already closing.
792
+ }
793
+ }
794
+ }
795
+
796
+ /** Flattens HTTP/2 header objects into a plain string map. */
797
+ function flattenHeaders(headers: Record<string, unknown>): Record<string, string> {
798
+ const out: Record<string, string> = {};
799
+ for (const [name, value] of Object.entries(headers)) {
800
+ if (value === undefined || value === null) continue;
801
+ if (Array.isArray(value)) out[name] = value.map((entry) => String(entry)).join(", ");
802
+ else out[name] = String(value);
803
+ }
804
+ return out;
805
+ }
806
+
807
+ /** Opens one validated bidi stream over an already-connected socket. */
808
+ function openBidiStream(
809
+ socket: tls.TLSSocket,
810
+ url: URL,
811
+ headers: Readonly<Record<string, string>>,
812
+ options: Http2BidiOpenOptions,
813
+ ): Promise<ValidatedHttp2BidiStream> {
814
+ // `createConnection`, not the `socket` option: the socket is already a
815
+ // validated, ALPN-negotiated TLS connection, and handing it through
816
+ // `createConnection` is what makes the session adopt it as-is (Node's
817
+ // `socket` option applies its own TLS expectations on top).
818
+ const session = http2.connect(`https://${url.hostname}`, {
819
+ createConnection: () => socket,
820
+ maxSessionMemory: HTTP2_SESSION_MAX_MEMORY_MB,
821
+ });
822
+ return new Promise<ValidatedHttp2BidiStream>((resolve, reject) => {
823
+ const cleanup = (): void => {
824
+ clearTimeout(timer);
825
+ session.removeListener("error", onError);
826
+ session.removeListener("connect", onConnect);
827
+ };
828
+ const onError = (error: Error): void => {
829
+ cleanup();
830
+ try {
831
+ session.destroy();
832
+ } catch {
833
+ // Already destroying.
834
+ }
835
+ reject(
836
+ new GatewayError(
837
+ "transport_unavailable",
838
+ 502,
839
+ `HTTP/2 session failed: ${error.message}`,
840
+ {},
841
+ "network",
842
+ ),
843
+ );
844
+ };
845
+ const onConnect = (): void => {
846
+ cleanup();
847
+ const stream = session.request(
848
+ {
849
+ ":method": "POST",
850
+ ":path": `${url.pathname}${url.search}`,
851
+ ":authority": url.hostname,
852
+ ":scheme": "https",
853
+ ...headers,
854
+ } as unknown as http2.OutgoingHttpHeaders,
855
+ { endStream: false },
856
+ );
857
+ resolve(
858
+ new Http2BidiSession(
859
+ stream,
860
+ session,
861
+ options.readIdleMs ?? HTTP2_READ_IDLE_MS,
862
+ options.maxBufferedBytes ?? HTTP2_MAX_BUFFERED_BYTES,
863
+ ),
864
+ );
865
+ };
866
+ const timer = setTimeout(() => {
867
+ cleanup();
868
+ try {
869
+ session.destroy();
870
+ } catch {
871
+ // Already destroying.
872
+ }
873
+ reject(
874
+ new GatewayError("transport_unavailable", 502, "HTTP/2 session timed out", {}, "network"),
875
+ );
876
+ }, HTTP2_CONNECT_TIMEOUT_MS);
877
+ session.once("error", onError);
878
+ session.once("connect", onConnect);
879
+ });
880
+ }
881
+
882
+ class WsSession implements ProviderWebSocketSession {
883
+ readonly #socket: WebSocket;
884
+ readonly #signal: AbortSignal;
885
+ readonly #messages: string[] = [];
886
+ readonly #waiters: Array<{
887
+ resolve(value: string): void;
888
+ reject(reason: Error): void;
889
+ cleanup(): void;
890
+ }> = [];
891
+ #closed: Error | undefined;
892
+
893
+ constructor(socket: WebSocket, signal: AbortSignal) {
894
+ this.#socket = socket;
895
+ this.#signal = signal;
896
+ socket.on("message", (data: RawData, isBinary: boolean) => {
897
+ if (isBinary) {
898
+ this.#fail(new GatewayError("transport_unavailable", 502, "unexpected binary WebSocket frame"));
899
+ return;
900
+ }
901
+ const message = data.toString();
902
+ const waiter = this.#waiters.shift();
903
+ if (waiter) {
904
+ waiter.cleanup();
905
+ waiter.resolve(message);
906
+ } else {
907
+ this.#messages.push(message);
908
+ }
909
+ });
910
+ socket.on("close", () => this.#fail(new GatewayError("transport_closed", 502, "WebSocket connection closed")));
911
+ socket.on("error", () => this.#fail(new GatewayError("transport_unavailable", 502, "WebSocket connection failed")));
912
+ }
913
+
914
+ send(message: string): void {
915
+ if (this.#closed) throw this.#closed;
916
+ if (this.#socket.readyState !== WebSocket.OPEN) {
917
+ throw new GatewayError("transport_closed", 499, "WebSocket connection is not open");
918
+ }
919
+ this.#socket.send(message, { binary: false });
920
+ }
921
+
922
+ receive(signal: AbortSignal): Promise<string> {
923
+ const queued = this.#messages.shift();
924
+ if (queued !== undefined) return Promise.resolve(queued);
925
+ if (this.#closed) return Promise.reject(this.#closed);
926
+ const combined = AbortSignal.any([this.#signal, signal]);
927
+ return new Promise<string>((resolve, reject) => {
928
+ const timeout = setTimeout(() => {
929
+ cleanup();
930
+ reject(new GatewayError("transport_unavailable", 504, "WebSocket receive timed out"));
931
+ }, 45_000);
932
+ const onAbort = (): void => {
933
+ cleanup();
934
+ reject(new GatewayError("transport_closed", 499, "WebSocket receive was cancelled"));
935
+ };
936
+ const cleanup = (): void => {
937
+ clearTimeout(timeout);
938
+ combined.removeEventListener("abort", onAbort);
939
+ };
940
+ combined.addEventListener("abort", onAbort, { once: true });
941
+ this.#waiters.push({ resolve, reject, cleanup });
942
+ if (combined.aborted) onAbort();
943
+ });
944
+ }
945
+
946
+ close(code?: number, reason?: string): void {
947
+ if (this.#socket.readyState === WebSocket.CLOSED) return;
948
+ this.#socket.close(code, reason);
949
+ }
950
+
951
+ #fail(error: Error): void {
952
+ if (this.#closed) return;
953
+ this.#closed = error;
954
+ for (const waiter of this.#waiters.splice(0)) {
955
+ waiter.cleanup();
956
+ waiter.reject(error);
957
+ }
958
+ }
959
+ }
960
+ export class ValidatedNetworkBindingFactory {
961
+ private readonly directFetch: ValidatedFetch;
962
+
963
+ constructor(
964
+ private readonly policy: SsrfPolicy = {},
965
+ fetchFn?: typeof fetch,
966
+ private readonly poolResolver?: PoolAgentResolver,
967
+ ) {
968
+ this.directFetch = createValidatedFetch({ policy, ...(fetchFn ? { fetchFn } : {}) });
969
+ }
970
+
971
+ /**
972
+ * One-shot DNS resolution + SSRF validation for direct egress targets:
973
+ * every resolved address (all families, not just the first answer) must
974
+ * pass the SSRF policy, and the validated destination pins the address so
975
+ * the subsequent socket cannot be re-pointed at a private/link-local host
976
+ * by a DNS rebind between check and connect.
977
+ */
978
+ resolve(hostname: string, signal: AbortSignal, port = 443): Promise<ValidatedDestination> {
979
+ return resolveAndValidateOnce(hostname, this.policy, signal, port);
980
+ }
981
+
982
+ /**
983
+ * Without `networkPoolId` (or when no pool resolver is configured), dispatch
984
+ * through validated direct egress. With one, route through that pool's
985
+ * validated agent. A pool configuration failure is returned to the caller;
986
+ * configured egress is never silently replaced with direct traffic.
987
+ */
988
+ fetch(networkPoolId?: string, tenantId?: string): ValidatedFetch {
989
+ if (!networkPoolId || !this.poolResolver || !tenantId) return this.directFetch;
990
+ const { poolResolver, policy } = this;
991
+ return async (input, init) => {
992
+ try {
993
+ const agent = await poolResolver.resolveAgent(networkPoolId, tenantId);
994
+ return await createValidatedFetch({ policy, agent })(input, init);
995
+ } catch (error) {
996
+ if (error instanceof PoolBindingError) {
997
+ throw new GatewayError(
998
+ "proxy_pool_unhealthy",
999
+ 503,
1000
+ "Configured proxy pool could not be established.",
1001
+ { poolId: networkPoolId, reason: error.message.slice(0, 200) },
1002
+ "network",
1003
+ );
1004
+ }
1005
+ throw error;
1006
+ }
1007
+ };
1008
+ }
1009
+ webSocket(networkPoolId?: string, tenantId?: string): ValidatedOutboundWebSocket {
1010
+ return async (url, headers, signal) => {
1011
+ if (url.protocol !== "wss:") {
1012
+ throw new GatewayError("invalid_request", 400, "validated WebSocket egress requires wss");
1013
+ }
1014
+ if (url.username || url.password || url.hash || (url.port !== "" && url.port !== "443")) {
1015
+ throw new GatewayError("invalid_request", 400, "invalid validated WebSocket target");
1016
+ }
1017
+ const hostname = url.hostname;
1018
+ if (!hostname) throw new GatewayError("invalid_request", 400, "WebSocket target hostname is required");
1019
+ const destination = await this.resolve(hostname, signal, 443);
1020
+ if (!isAddressAllowed(destination.resolvedAddress, this.policy)) {
1021
+ throw new GatewayError("invalid_request", 400, "unsafe upstream address rejected");
1022
+ }
1023
+ let agent: PoolAgent | undefined;
1024
+ if (networkPoolId || tenantId) {
1025
+ if (!networkPoolId || !tenantId || !this.poolResolver) {
1026
+ throw new GatewayError("proxy_pool_unhealthy", 503, "configured WebSocket proxy pool could not be established");
1027
+ }
1028
+ try {
1029
+ agent = await this.poolResolver.resolveWebSocketAgent(networkPoolId, tenantId);
1030
+ } catch (error) {
1031
+ if (error instanceof PoolBindingError) {
1032
+ throw new GatewayError("proxy_pool_unhealthy", 503, "configured WebSocket proxy pool could not be established");
1033
+ }
1034
+ throw error;
1035
+ }
1036
+ }
1037
+ return connectValidatedWebSocket(url, headers, signal, destination, agent);
1038
+ };
1039
+ }
1040
+
1041
+ /**
1042
+ * Validated bidirectional HTTP/2 egress for one writable streaming request.
1043
+ *
1044
+ * Direct egress resolves and validates *every* DNS answer for the hostname,
1045
+ * pins the chosen public address, and keeps TLS SNI pointed at the validated
1046
+ * hostname — so a rebind between check and connect cannot retarget the socket,
1047
+ * and the certificate still validates against the name the caller asked for.
1048
+ * Pooled egress reuses the selected tenant pool's CONNECT/SOCKS tunnel and
1049
+ * accounts the tunnel's bytes to that pool, exactly as `fetch` does.
1050
+ *
1051
+ * Credentials in the authority, fragments, non-443 ports, IP literals, and
1052
+ * hostnames outside `allowHostname` are refused before anything is dialed: a
1053
+ * bidi stream carries the account credential for its whole lifetime, so the
1054
+ * target is checked once here rather than trusted to each caller.
1055
+ */
1056
+ http2Bidi(networkPoolId?: string, tenantId?: string): ValidatedOutboundHttp2Bidi {
1057
+ return async (url, headers, options) => {
1058
+ assertBidiTarget(url);
1059
+ const { hostname } = url;
1060
+ if (options?.allowHostname !== undefined && !options.allowHostname(hostname)) {
1061
+ throw new GatewayError("invalid_request", 400, "HTTP/2 target host is not allowlisted");
1062
+ }
1063
+ const signal = options?.signal ?? AbortSignal.timeout(HTTP2_CONNECT_TIMEOUT_MS);
1064
+ // Validation runs on every new connection, not once per capability: a
1065
+ // long-lived Run is re-opened after a session dies, and a hostname that
1066
+ // resolved safely the first time is not safe the twentieth.
1067
+ const destination = await this.resolve(hostname, signal, 443);
1068
+ if (!isAddressAllowed(destination.resolvedAddress, this.policy)) {
1069
+ throw new GatewayError("invalid_request", 400, "unsafe upstream address rejected");
1070
+ }
1071
+ // Mirrors `fetch`: no pool selected (or none configured) means validated
1072
+ // *direct* egress, which is already resolved and SSRF-checked above.
1073
+ // Requiring a pool whenever a tenant id was supplied made bidi-only
1074
+ // providers (Cursor) unreachable on every install without a pool, even
1075
+ // though the equivalent `fetch` path dials direct there.
1076
+ if (networkPoolId !== undefined || tenantId !== undefined) {
1077
+ if (networkPoolId === undefined || tenantId === undefined || this.poolResolver === undefined) {
1078
+ const socket = await dialDirect(destination, signal);
1079
+ return openBidiStream(socket, url, headers, options ?? {});
1080
+ }
1081
+ let agent: PoolAgent;
1082
+ try {
1083
+ agent = await this.poolResolver.resolveAgent(networkPoolId, tenantId);
1084
+ } catch (error) {
1085
+ if (error instanceof PoolBindingError) {
1086
+ throw new GatewayError(
1087
+ "proxy_pool_unhealthy",
1088
+ 503,
1089
+ "Configured proxy pool could not be established.",
1090
+ { poolId: networkPoolId, reason: error.message.slice(0, 200) },
1091
+ "network",
1092
+ );
1093
+ }
1094
+ throw error;
1095
+ }
1096
+ const socket = await dialThroughPool(agent, hostname, signal);
1097
+ return openBidiStream(socket, url, headers, options ?? {});
1098
+ }
1099
+ const socket = await dialDirect(destination, signal);
1100
+ return openBidiStream(socket, url, headers, options ?? {});
1101
+ };
1102
+ }
1103
+ }
1104
+