cartethyia 2.5.4 → 2.5.6

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 (395) hide show
  1. package/.env.example +13 -4
  2. package/README.md +2 -2
  3. package/dist/dashboard/assets/{BannedPage-C2orBsI2.js → BannedPage-CADDq4IU.js} +1 -1
  4. package/dist/dashboard/assets/BannedPage-CADDq4IU.js.gz +0 -0
  5. package/dist/dashboard/assets/CliToolDetailPage-_z1hID7E.js +1 -0
  6. package/dist/dashboard/assets/CliToolDetailPage-_z1hID7E.js.gz +0 -0
  7. package/dist/dashboard/assets/{CliToolsPage-BV6pxVQo.js → CliToolsPage-DuO1Z7dq.js} +1 -1
  8. package/dist/dashboard/assets/CliToolsPage-DuO1Z7dq.js.gz +0 -0
  9. package/dist/dashboard/assets/CombosPage-DNEPi3xU.js +1 -0
  10. package/dist/dashboard/assets/CombosPage-DNEPi3xU.js.gz +0 -0
  11. package/dist/dashboard/assets/{ConsoleLogPage-CW-wPmJ6.js → ConsoleLogPage-BVdpfiz1.js} +1 -1
  12. package/dist/dashboard/assets/ConsoleLogPage-BVdpfiz1.js.gz +0 -0
  13. package/dist/dashboard/assets/{CustomizationPage-0p8uX7vB.js → CustomizationPage-BR5HHvbi.js} +1 -1
  14. package/dist/dashboard/assets/CustomizationPage-BR5HHvbi.js.gz +0 -0
  15. package/dist/dashboard/assets/{HealthEventsModal-BjqSMfko.js → HealthEventsModal-mUfcknqm.js} +1 -1
  16. package/dist/dashboard/assets/HealthEventsModal-mUfcknqm.js.gz +0 -0
  17. package/dist/dashboard/assets/{LoginPage-BvzCx8Kr.js → LoginPage-V5faV9V2.js} +1 -1
  18. package/dist/dashboard/assets/LoginPage-V5faV9V2.js.gz +0 -0
  19. package/dist/dashboard/assets/ModelPicker-C7pDb41R.js +1 -0
  20. package/dist/dashboard/assets/ModelPicker-C7pDb41R.js.gz +0 -0
  21. package/dist/dashboard/assets/OverviewPage-BDu20Jdx.js +1 -0
  22. package/dist/dashboard/assets/OverviewPage-BDu20Jdx.js.gz +0 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-Dllr_aFO.js +3 -0
  24. package/dist/dashboard/assets/ProviderDetailPage-Dllr_aFO.js.gz +0 -0
  25. package/dist/dashboard/assets/{ProvidersPage-Js9jUPSb.js → ProvidersPage-uPiGVTTK.js} +1 -1
  26. package/dist/dashboard/assets/ProvidersPage-uPiGVTTK.js.gz +0 -0
  27. package/dist/dashboard/assets/ProxyPage-BaUkO-4O.js +7 -0
  28. package/dist/dashboard/assets/ProxyPage-BaUkO-4O.js.gz +0 -0
  29. package/dist/dashboard/assets/QuotaPage-o0IFAsFb.js +1 -0
  30. package/dist/dashboard/assets/QuotaPage-o0IFAsFb.js.gz +0 -0
  31. package/dist/dashboard/assets/SettingsPage-CYWqbXjp.js +1 -0
  32. package/dist/dashboard/assets/SettingsPage-CYWqbXjp.js.gz +0 -0
  33. package/dist/dashboard/assets/{SetupPage-DDbMQsoo.js → SetupPage-UB690SE2.js} +1 -1
  34. package/dist/dashboard/assets/SetupPage-UB690SE2.js.gz +0 -0
  35. package/dist/dashboard/assets/{SortableList-CB3Z7VTf.js → SortableList-BLujdX7N.js} +1 -1
  36. package/dist/dashboard/assets/SortableList-BLujdX7N.js.gz +0 -0
  37. package/dist/dashboard/assets/{StudioPage-D95UCPHI.js → StudioPage-D5C2raAc.js} +3 -3
  38. package/dist/dashboard/assets/StudioPage-D5C2raAc.js.gz +0 -0
  39. package/dist/dashboard/assets/UsagePage-8uIcxa1W.js +56 -0
  40. package/dist/dashboard/assets/UsagePage-8uIcxa1W.js.gz +0 -0
  41. package/dist/dashboard/assets/api-keys-DQYLQxpW.js +1 -0
  42. package/dist/dashboard/assets/api-keys-DQYLQxpW.js.gz +0 -0
  43. package/dist/dashboard/assets/app-CX_Z8cMs.css +2 -0
  44. package/dist/dashboard/assets/app-CX_Z8cMs.css.gz +0 -0
  45. package/dist/dashboard/assets/{assert-Chi7pCnt.js → assert-C9FD_JwJ.js} +1 -1
  46. package/dist/dashboard/assets/assert-C9FD_JwJ.js.gz +0 -0
  47. package/dist/dashboard/assets/{badge-D4pQapKj.js → badge-CR72dasT.js} +1 -1
  48. package/dist/dashboard/assets/badge-CR72dasT.js.gz +0 -0
  49. package/dist/dashboard/assets/{banner-DH6cg-_B.js → banner-CI9_eSfO.js} +1 -1
  50. package/dist/dashboard/assets/banner-CI9_eSfO.js.gz +0 -0
  51. package/dist/dashboard/assets/{card-BKqlUOe4.js → card-DS7CQEsO.js} +1 -1
  52. package/dist/dashboard/assets/card-DS7CQEsO.js.gz +0 -0
  53. package/dist/dashboard/assets/{cli-tools-fTgljTGA.js → cli-tools-CNqzrfdb.js} +1 -1
  54. package/dist/dashboard/assets/cli-tools-CNqzrfdb.js.gz +0 -0
  55. package/dist/dashboard/assets/{clipboard-button-D5Ys_sM0.js → clipboard-button-611pSsaX.js} +1 -1
  56. package/dist/dashboard/assets/clipboard-button-611pSsaX.js.gz +0 -0
  57. package/dist/dashboard/assets/{fingerprint-pattern-BWuHtbW4.js → concurrency-BDF0ok7y.js} +1 -1
  58. package/dist/dashboard/assets/concurrency-BDF0ok7y.js.gz +0 -0
  59. package/dist/dashboard/assets/dialog-k37exjNt.js +1 -0
  60. package/dist/dashboard/assets/dialog-k37exjNt.js.gz +0 -0
  61. package/dist/dashboard/assets/drawer-CX7N8ucR.js +1 -0
  62. package/dist/dashboard/assets/drawer-CX7N8ucR.js.gz +0 -0
  63. package/dist/dashboard/assets/{entry-CQBr6U9c.js → entry-B9Xvtnfj.js} +1 -1
  64. package/dist/dashboard/assets/entry-B9Xvtnfj.js.gz +0 -0
  65. package/dist/dashboard/assets/entry-CTP8AyiS.js +1 -0
  66. package/dist/dashboard/assets/entry-CTP8AyiS.js.gz +0 -0
  67. package/dist/dashboard/assets/entry-DxBHnauy.js +4 -0
  68. package/dist/dashboard/assets/entry-DxBHnauy.js.gz +0 -0
  69. package/dist/dashboard/assets/{github-badge-BlptL8Uf.js → github-badge-sltk1qUi.js} +1 -1
  70. package/dist/dashboard/assets/github-badge-sltk1qUi.js.gz +0 -0
  71. package/dist/dashboard/assets/{index-js6TJvdr.js → index-CmxZr14F.js} +2 -2
  72. package/dist/dashboard/assets/index-CmxZr14F.js.gz +0 -0
  73. package/dist/dashboard/assets/{inline-DUWDSmDV.js → inline-BhY2sSnO.js} +1 -1
  74. package/dist/dashboard/assets/inline-BhY2sSnO.js.gz +0 -0
  75. package/dist/dashboard/assets/{input-LVjgZq4f.js → input-V1umFiBk.js} +1 -1
  76. package/dist/dashboard/assets/input-V1umFiBk.js.gz +0 -0
  77. package/dist/dashboard/assets/layout-BBOYycyS.js +1 -0
  78. package/dist/dashboard/assets/layout-BBOYycyS.js.gz +0 -0
  79. package/dist/dashboard/assets/{list-row-DupvLfDx.js → list-row-1zzZYM0B.js} +1 -1
  80. package/dist/dashboard/assets/list-row-1zzZYM0B.js.gz +0 -0
  81. package/dist/dashboard/assets/{live-C4pEJUqI.js → live-BivGfuUs.js} +1 -1
  82. package/dist/dashboard/assets/live-BivGfuUs.js.gz +0 -0
  83. package/dist/dashboard/assets/{model-bans-BRiuufkB.js → model-bans-CmffjLuG.js} +1 -1
  84. package/dist/dashboard/assets/model-bans-CmffjLuG.js.gz +0 -0
  85. package/dist/dashboard/assets/{mutation-B4ROwk8L.js → mutation-46P0oWMe.js} +1 -1
  86. package/dist/dashboard/assets/mutation-46P0oWMe.js.gz +0 -0
  87. package/dist/dashboard/assets/numbers-DkfMR5Qs.js +2 -0
  88. package/dist/dashboard/assets/numbers-DkfMR5Qs.js.gz +0 -0
  89. package/dist/dashboard/assets/{query-keys-gmjzC3RS.js → query-keys-BFhGpwR1.js} +1 -1
  90. package/dist/dashboard/assets/query-keys-BFhGpwR1.js.gz +0 -0
  91. package/dist/dashboard/assets/{routing-C7tBT5o-.js → routing-CAb4F37R.js} +1 -1
  92. package/dist/dashboard/assets/routing-CAb4F37R.js.gz +0 -0
  93. package/dist/dashboard/assets/{select-WUCXorXw.js → select-BYR_5K2c.js} +1 -1
  94. package/dist/dashboard/assets/select-BYR_5K2c.js.gz +0 -0
  95. package/dist/dashboard/assets/{settings-C_zKl1GS.js → settings-DNX9sydi.js} +1 -1
  96. package/dist/dashboard/assets/settings-DNX9sydi.js.gz +0 -0
  97. package/dist/dashboard/assets/{stack-_3O0F8Xn.js → stack-BHTfXdz_.js} +1 -1
  98. package/dist/dashboard/assets/stack-BHTfXdz_.js.gz +0 -0
  99. package/dist/dashboard/assets/{state-BgTxHcJS.js → state-mPDRlQkl.js} +1 -1
  100. package/dist/dashboard/assets/state-mPDRlQkl.js.gz +0 -0
  101. package/dist/dashboard/assets/{switch-DEjTtOWf.js → switch-DOmJxg7H.js} +1 -1
  102. package/dist/dashboard/assets/switch-DOmJxg7H.js.gz +0 -0
  103. package/dist/dashboard/assets/tokens-COzario9.js +1 -0
  104. package/dist/dashboard/assets/tokens-COzario9.js.gz +0 -0
  105. package/dist/dashboard/assets/use-modal-focus-By7oJoGd.js +1 -0
  106. package/dist/dashboard/assets/use-modal-focus-By7oJoGd.js.gz +0 -0
  107. package/dist/dashboard/assets/{useMutation-BWSC0UAS.js → useMutation-Dem0lXQj.js} +1 -1
  108. package/dist/dashboard/assets/useMutation-Dem0lXQj.js.gz +0 -0
  109. package/dist/dashboard/index.html +1 -1
  110. package/dist/dashboard/index.html.gz +0 -0
  111. package/dist/dashboard/precompressed-assets.json +54 -54
  112. package/migrations/README.md +52 -52
  113. package/migrations/lite-sqlite3/0000_baseline.sql +2 -2
  114. package/package.json +96 -95
  115. package/scripts/cli/admin.ts +125 -0
  116. package/scripts/cli/{ui/background.ts → background.ts} +1 -1
  117. package/scripts/cli/{command/console-menu.ts → console-menu.ts} +275 -244
  118. package/scripts/cli/doctor.ts +37 -0
  119. package/scripts/cli/{support/env.ts → env.ts} +99 -99
  120. package/scripts/cli/{gateway/spawn.ts → gateway.ts} +112 -24
  121. package/scripts/cli/instance.ts +224 -0
  122. package/scripts/cli/main.ts +61 -18
  123. package/scripts/cli/{support/platform.ts → platform.ts} +64 -64
  124. package/scripts/cli/port.ts +100 -0
  125. package/scripts/cli/{support/services.ts → services.ts} +88 -88
  126. package/scripts/cli/{command/setup.ts → setup.ts} +35 -34
  127. package/scripts/cli/{command/start.ts → start.ts} +10 -5
  128. package/scripts/cli/{support/startup-signal.ts → startup-signal.ts} +23 -23
  129. package/scripts/cli/tray/index.ts +62 -12
  130. package/scripts/cli/{command/tray.ts → tray.ts} +208 -208
  131. package/scripts/cli/{command/update.ts → update.ts} +39 -39
  132. package/scripts/cli/version.ts +67 -0
  133. package/scripts/cli/{ui/wordmark.ts → wordmark.ts} +58 -58
  134. package/src/boot/app.ts +697 -693
  135. package/src/boot/dependencies.ts +647 -646
  136. package/src/boot/timeout.ts +2 -13
  137. package/src/config/cooldowns.ts +186 -138
  138. package/src/config/network.ts +8 -14
  139. package/src/config/providers.ts +7 -10
  140. package/src/config/runtime.ts +76 -76
  141. package/src/config/security.ts +15 -0
  142. package/src/config/spec.ts +20 -30
  143. package/src/config/telemetry.ts +0 -20
  144. package/src/config/timeouts.ts +0 -6
  145. package/src/console/admin/model-abuse/contracts.ts +28 -6
  146. package/src/console/admin/model-abuse/routes.ts +56 -3
  147. package/src/console/api-keys/contracts.ts +410 -414
  148. package/src/console/api-keys/routes.ts +881 -880
  149. package/src/console/auth/credentials.ts +3 -3
  150. package/src/console/auth/setup-service.ts +2 -2
  151. package/src/console/backup/export-endpoint.ts +6 -2
  152. package/src/console/backup/nine-router.ts +2 -5
  153. package/src/console/backup/service.ts +4 -0
  154. package/src/console/backup/store.ts +192 -29
  155. package/src/console/backup/validate.ts +3 -45
  156. package/src/console/cli-tools/fs-ops.ts +487 -487
  157. package/src/console/console-router-guards.ts +1 -1
  158. package/src/console/data-management/contracts.ts +13 -6
  159. package/src/console/data-management/operations.ts +23 -18
  160. package/src/console/data-management/routes.ts +0 -1
  161. package/src/console/domain-registration.ts +10 -5
  162. package/src/console/observability/live.ts +22 -0
  163. package/src/console/observability/store.ts +39 -16
  164. package/src/console/observability/usage-dimensions.ts +4 -4
  165. package/src/console/providers/catalog/catalog-projections.ts +6 -7
  166. package/src/console/providers/catalog/model-operations.ts +5 -2
  167. package/src/console/providers/catalog/provider-operations.ts +4 -4
  168. package/src/console/providers/catalog/public-model-store.ts +8 -11
  169. package/src/console/providers/catalog/store.ts +1553 -1553
  170. package/src/console/providers/oauth/routes.ts +8 -12
  171. package/src/console/quota/account-quota-tenant.ts +6 -6
  172. package/src/console/quota/quota-view.ts +1 -1
  173. package/src/console/remote-support/contracts.ts +61 -0
  174. package/src/console/remote-support/routes.ts +65 -0
  175. package/src/console/remote-support/service.ts +207 -0
  176. package/src/console/response-compression.ts +0 -3
  177. package/src/console/routing/pools/probe-result.ts +3 -2
  178. package/src/console/routing/pools/probe-verdict.ts +132 -132
  179. package/src/console/routing/pools/store.ts +8 -8
  180. package/src/console/settings/contracts.ts +58 -2
  181. package/src/console/settings/store.ts +30 -3
  182. package/src/console/shared/cursor.ts +8 -8
  183. package/src/console/shared/errors.ts +1 -3
  184. package/src/network/http2-bidi.ts +2 -4
  185. package/src/network/pool/agent.ts +478 -478
  186. package/src/network/pool/selector.ts +5 -4
  187. package/src/network/retry.ts +1 -2
  188. package/src/network/ssrf.ts +5 -4
  189. package/src/observability/event-compaction.ts +80 -78
  190. package/src/observability/event-day-index.ts +13 -15
  191. package/src/observability/event-log.ts +4 -4
  192. package/src/observability/logger.ts +6 -6
  193. package/src/observability/payload-capture.ts +151 -33
  194. package/src/observability/payload-store.ts +397 -440
  195. package/src/observability/redaction.ts +5 -6
  196. package/src/observability/request-notify.ts +43 -0
  197. package/src/observability/telemetry-status.ts +8 -9
  198. package/src/observability/token-speed.ts +5 -4
  199. package/src/persistence/db-mode.ts +7 -7
  200. package/src/persistence/readiness.ts +1 -1
  201. package/src/persistence/schema.ts +1406 -1402
  202. package/src/persistence/shared-cache.ts +1 -2
  203. package/src/persistence/stores/api-key-store.ts +18 -6
  204. package/src/persistence/stores/share-store.ts +4 -3
  205. package/src/persistence/stores/telemetry-store.ts +236 -237
  206. package/src/protocol/canonical-model.ts +11 -3
  207. package/src/protocol/codecs.ts +138 -4
  208. package/src/protocol/gateway-error.ts +6 -4
  209. package/src/protocol/request/chat.ts +21 -1
  210. package/src/protocol/request/codex.ts +10 -1
  211. package/src/protocol/request/messages.ts +4 -5
  212. package/src/protocol/request/responses.ts +9 -1
  213. package/src/protocol/response/chat.ts +34 -4
  214. package/src/protocol/response/codex.ts +7 -4
  215. package/src/protocol/response/dsml.ts +140 -28
  216. package/src/protocol/response/responses.ts +11 -5
  217. package/src/protocol/stream-error-frames.ts +4 -5
  218. package/src/providers/authentication/jwt-validator.ts +9 -10
  219. package/src/providers/authentication/oauth-refresh-service.ts +9 -11
  220. package/src/providers/configured-openai-providers.ts +134 -136
  221. package/src/providers/discovery/catalog/catalog-blob.ts +18 -18
  222. package/src/providers/discovery/catalog/catalog-format.ts +3 -6
  223. package/src/providers/discovery/models-dev-catalog.ts +354 -354
  224. package/src/providers/discovery/probe-phases.ts +4 -4
  225. package/src/providers/discovery/probe-wire.ts +6 -13
  226. package/src/providers/discovery/probing-service.ts +1117 -1045
  227. package/src/providers/integrations/alibabacp/alibabacp.ts +1 -1
  228. package/src/providers/integrations/antigravity/antigravity-quota.ts +5 -7
  229. package/src/providers/integrations/buddy/buddy-chat-shared.ts +23 -2
  230. package/src/providers/integrations/buddy/buddy-checkin.ts +1 -4
  231. package/src/providers/integrations/buddy/buddy-cli-headers.ts +23 -6
  232. package/src/providers/integrations/buddy/buddy-conversation-headers.ts +0 -1
  233. package/src/providers/integrations/buddy/buddy-identity.ts +52 -0
  234. package/src/providers/integrations/buddy/buddy-media.ts +2 -2
  235. package/src/providers/integrations/buddy/buddy-usage-store.ts +3 -4
  236. package/src/providers/integrations/buddy/codebuddy/quota.ts +2 -13
  237. package/src/providers/integrations/buddy/codebuddy/shared.ts +118 -115
  238. package/src/providers/integrations/buddy/workbuddy/quota.ts +0 -11
  239. package/src/providers/integrations/buddy/workbuddy/shared.ts +5 -2
  240. package/src/providers/integrations/claude/claude.ts +608 -608
  241. package/src/providers/integrations/cline/cline-quota.ts +132 -132
  242. package/src/providers/integrations/cline/cline.ts +537 -539
  243. package/src/providers/integrations/codex/codex-oauth.ts +1 -1
  244. package/src/providers/integrations/commandcode/commandcode.ts +674 -675
  245. package/src/providers/integrations/commandcode/discovery.ts +117 -116
  246. package/src/providers/integrations/cursor/cursor-auth.ts +418 -418
  247. package/src/providers/integrations/cursor/cursor-quota.ts +309 -310
  248. package/src/providers/integrations/cursor/cursor.ts +11 -163
  249. package/src/providers/integrations/factory/factory-models.ts +3 -5
  250. package/src/providers/integrations/grok/grok-install-id.ts +2 -3
  251. package/src/providers/integrations/grok/grok.ts +6 -7
  252. package/src/providers/integrations/hermes.ts +14 -18
  253. package/src/providers/integrations/kiro/kiro-discovery.ts +1 -1
  254. package/src/providers/integrations/kiro/kiro.ts +1 -1
  255. package/src/providers/integrations/mistral.ts +6 -6
  256. package/src/providers/integrations/opencode/fingerprint.ts +3 -3
  257. package/src/providers/integrations/opencode/provider.ts +8 -8
  258. package/src/providers/integrations/qoder/qoder-catalog.ts +450 -450
  259. package/src/providers/integrations/qoder/qoder-oauth.ts +388 -310
  260. package/src/providers/integrations/qoder/qoder.ts +884 -865
  261. package/src/providers/integrations/shared/connect.ts +6 -6
  262. package/src/providers/integrations/trae/trae-catalog.ts +1 -2
  263. package/src/providers/integrations/trae/trae.ts +2 -2
  264. package/src/providers/integrations/zcode/oauth.ts +6 -1
  265. package/src/providers/operations/account-health-service.ts +1528 -1523
  266. package/src/providers/operations/account-reset-service.ts +1 -1
  267. package/src/providers/operations/client-versions.ts +6 -6
  268. package/src/providers/operations/custom-cli-headers.ts +3 -4
  269. package/src/providers/operations/provider-catalog-seeder.ts +95 -95
  270. package/src/providers/operations/provider-catalog-service.ts +3 -4
  271. package/src/providers/provider-metadata.ts +4 -8
  272. package/src/providers/provider-registry.ts +6 -7
  273. package/src/providers/provider-usage.ts +1 -1
  274. package/src/security/abuse.ts +15 -10
  275. package/src/security/access-control.ts +144 -146
  276. package/src/security/admission/contracts.ts +11 -0
  277. package/src/security/admission/in-memory-store.ts +4 -3
  278. package/src/security/admission/lease-sweep.ts +6 -6
  279. package/src/security/admission/reasons.ts +5 -1
  280. package/src/security/admission/redis-store.ts +211 -210
  281. package/src/security/admission/service.ts +4 -0
  282. package/src/security/api-key-auth.ts +504 -504
  283. package/src/security/ip-boundary.ts +155 -15
  284. package/src/security/model-abuse.ts +366 -94
  285. package/src/security/outbound-headers.ts +3 -4
  286. package/src/shared/sleep.ts +4 -0
  287. package/src/transport/dispatch/attempt-finalize.ts +47 -5
  288. package/src/transport/dispatch/attempt-loop.ts +531 -530
  289. package/src/transport/dispatch/stream-bridge.ts +1 -3
  290. package/src/transport/dispatch/streaming/finalize.ts +1 -1
  291. package/src/transport/dispatch/streaming/stream.ts +5 -6
  292. package/src/transport/failure-policy.ts +162 -8
  293. package/src/transport/guards/auth-guard.ts +7 -4
  294. package/src/transport/guards/concurrency-guard.ts +0 -5
  295. package/src/transport/guards/error-lifecycle.ts +13 -6
  296. package/src/transport/guards/pipeline.ts +15 -3
  297. package/src/transport/guards/request-context.ts +8 -2
  298. package/src/transport/request/inflight.ts +5 -6
  299. package/src/transport/request/preparer.ts +2 -2
  300. package/src/transport/request/state.ts +12 -8
  301. package/src/transport/routing/admission.ts +4 -0
  302. package/src/transport/routing/route-catalog.ts +8 -1
  303. package/src/transport/routing/route-model.ts +46 -14
  304. package/src/transport/routing/router.ts +21 -14
  305. package/src/transport/surface/completion.ts +2 -3
  306. package/src/transport/surface/dialects.ts +5 -6
  307. package/src/transport/surface/responses/encode.ts +38 -0
  308. package/src/transport/surface/responses/parse.ts +4 -0
  309. package/src/transport/translation/capabilities.ts +2 -3
  310. package/src/transport/translation/quirks.ts +3 -3
  311. package/src/workers/buddy-growth-tasks.ts +5 -10
  312. package/src/workers/daily-checkin.ts +17 -23
  313. package/src/workers/quota-refresh-sweep.ts +26 -26
  314. package/dist/dashboard/assets/BannedPage-C2orBsI2.js.gz +0 -0
  315. package/dist/dashboard/assets/CliToolDetailPage-DUcZ6pMB.js +0 -1
  316. package/dist/dashboard/assets/CliToolDetailPage-DUcZ6pMB.js.gz +0 -0
  317. package/dist/dashboard/assets/CliToolsPage-BV6pxVQo.js.gz +0 -0
  318. package/dist/dashboard/assets/CombosPage-Bsan4679.js +0 -1
  319. package/dist/dashboard/assets/CombosPage-Bsan4679.js.gz +0 -0
  320. package/dist/dashboard/assets/ConsoleLogPage-CW-wPmJ6.js.gz +0 -0
  321. package/dist/dashboard/assets/CustomizationPage-0p8uX7vB.js.gz +0 -0
  322. package/dist/dashboard/assets/HealthEventsModal-BjqSMfko.js.gz +0 -0
  323. package/dist/dashboard/assets/LoginPage-BvzCx8Kr.js.gz +0 -0
  324. package/dist/dashboard/assets/ModelPicker-BY1aKEkP.js +0 -1
  325. package/dist/dashboard/assets/ModelPicker-BY1aKEkP.js.gz +0 -0
  326. package/dist/dashboard/assets/OverviewPage-DOQpy1y0.js +0 -1
  327. package/dist/dashboard/assets/OverviewPage-DOQpy1y0.js.gz +0 -0
  328. package/dist/dashboard/assets/ProviderDetailPage-ecxyUMzt.js +0 -3
  329. package/dist/dashboard/assets/ProviderDetailPage-ecxyUMzt.js.gz +0 -0
  330. package/dist/dashboard/assets/ProvidersPage-Js9jUPSb.js.gz +0 -0
  331. package/dist/dashboard/assets/ProxyPage-J3TBi3Kx.js +0 -7
  332. package/dist/dashboard/assets/ProxyPage-J3TBi3Kx.js.gz +0 -0
  333. package/dist/dashboard/assets/QuotaPage-C1gZS6xr.js +0 -1
  334. package/dist/dashboard/assets/QuotaPage-C1gZS6xr.js.gz +0 -0
  335. package/dist/dashboard/assets/SettingsPage-CS86ho5X.js +0 -1
  336. package/dist/dashboard/assets/SettingsPage-CS86ho5X.js.gz +0 -0
  337. package/dist/dashboard/assets/SetupPage-DDbMQsoo.js.gz +0 -0
  338. package/dist/dashboard/assets/SortableList-CB3Z7VTf.js.gz +0 -0
  339. package/dist/dashboard/assets/StudioPage-D95UCPHI.js.gz +0 -0
  340. package/dist/dashboard/assets/UsagePage-Cnae9SWA.js +0 -56
  341. package/dist/dashboard/assets/UsagePage-Cnae9SWA.js.gz +0 -0
  342. package/dist/dashboard/assets/api-keys-VY68Mhd1.js +0 -1
  343. package/dist/dashboard/assets/api-keys-VY68Mhd1.js.gz +0 -0
  344. package/dist/dashboard/assets/app-COlK95oi.css +0 -2
  345. package/dist/dashboard/assets/app-COlK95oi.css.gz +0 -0
  346. package/dist/dashboard/assets/assert-Chi7pCnt.js.gz +0 -0
  347. package/dist/dashboard/assets/badge-D4pQapKj.js.gz +0 -0
  348. package/dist/dashboard/assets/banner-DH6cg-_B.js.gz +0 -0
  349. package/dist/dashboard/assets/card-BKqlUOe4.js.gz +0 -0
  350. package/dist/dashboard/assets/cli-tools-fTgljTGA.js.gz +0 -0
  351. package/dist/dashboard/assets/clipboard-button-D5Ys_sM0.js.gz +0 -0
  352. package/dist/dashboard/assets/dialog-BYj-8WuK.js +0 -1
  353. package/dist/dashboard/assets/dialog-BYj-8WuK.js.gz +0 -0
  354. package/dist/dashboard/assets/drawer-CmXIZ6H1.js +0 -1
  355. package/dist/dashboard/assets/drawer-CmXIZ6H1.js.gz +0 -0
  356. package/dist/dashboard/assets/entry-CBg5zeu8.js +0 -1
  357. package/dist/dashboard/assets/entry-CBg5zeu8.js.gz +0 -0
  358. package/dist/dashboard/assets/entry-CQBr6U9c.js.gz +0 -0
  359. package/dist/dashboard/assets/entry-CgdSAzEV.js +0 -4
  360. package/dist/dashboard/assets/entry-CgdSAzEV.js.gz +0 -0
  361. package/dist/dashboard/assets/fingerprint-pattern-BWuHtbW4.js.gz +0 -0
  362. package/dist/dashboard/assets/github-badge-BlptL8Uf.js.gz +0 -0
  363. package/dist/dashboard/assets/index-js6TJvdr.js.gz +0 -0
  364. package/dist/dashboard/assets/inline-DUWDSmDV.js.gz +0 -0
  365. package/dist/dashboard/assets/input-LVjgZq4f.js.gz +0 -0
  366. package/dist/dashboard/assets/layout-CPfxpsfy.js +0 -1
  367. package/dist/dashboard/assets/layout-CPfxpsfy.js.gz +0 -0
  368. package/dist/dashboard/assets/list-row-DupvLfDx.js.gz +0 -0
  369. package/dist/dashboard/assets/live-C4pEJUqI.js.gz +0 -0
  370. package/dist/dashboard/assets/model-bans-BRiuufkB.js.gz +0 -0
  371. package/dist/dashboard/assets/mutation-B4ROwk8L.js.gz +0 -0
  372. package/dist/dashboard/assets/numbers-YNcHvRY7.js +0 -2
  373. package/dist/dashboard/assets/numbers-YNcHvRY7.js.gz +0 -0
  374. package/dist/dashboard/assets/query-keys-gmjzC3RS.js.gz +0 -0
  375. package/dist/dashboard/assets/routing-C7tBT5o-.js.gz +0 -0
  376. package/dist/dashboard/assets/select-WUCXorXw.js.gz +0 -0
  377. package/dist/dashboard/assets/settings-C_zKl1GS.js.gz +0 -0
  378. package/dist/dashboard/assets/stack-_3O0F8Xn.js.gz +0 -0
  379. package/dist/dashboard/assets/state-BgTxHcJS.js.gz +0 -0
  380. package/dist/dashboard/assets/switch-DEjTtOWf.js.gz +0 -0
  381. package/dist/dashboard/assets/tokens-DDxMbSRL.js +0 -1
  382. package/dist/dashboard/assets/tokens-DDxMbSRL.js.gz +0 -0
  383. package/dist/dashboard/assets/use-presence-BSZEBx7U.js +0 -1
  384. package/dist/dashboard/assets/use-presence-BSZEBx7U.js.gz +0 -0
  385. package/dist/dashboard/assets/useMutation-BWSC0UAS.js.gz +0 -0
  386. package/scripts/cli/command/admin.ts +0 -65
  387. package/scripts/cli/command/doctor.ts +0 -257
  388. package/scripts/cli/gateway/drain-token.ts +0 -78
  389. package/scripts/cli/gateway/instance-lock.ts +0 -105
  390. package/scripts/cli/gateway/stop.ts +0 -51
  391. package/scripts/cli/support/bun.ts +0 -27
  392. package/scripts/cli/support/version.ts +0 -37
  393. /package/dist/dashboard/assets/{app-DCQZRmHY.js → app-BIt446yE.js} +0 -0
  394. /package/dist/dashboard/assets/{app-DCQZRmHY.js.gz → app-BIt446yE.js.gz} +0 -0
  395. /package/scripts/cli/{ui/prompt.ts → prompt.ts} +0 -0
@@ -1,478 +1,478 @@
1
- /**
2
- * Pool egress agents: the HTTP CONNECT pair, the SOCKS5 pair, and the
3
- * per-connect SSRF revalidation they share. `PoolAgentResolver` (resolver.ts)
4
- * caches instances of these; the selector owns admission.
5
- */
6
- import { isIP } from "node:net";
7
- import { accountSocketBytes, accountSocketWrites, recordPoolBytes } from "./byte-accounting";
8
- import {
9
- Agent as HttpAgent,
10
- request as httpRequest,
11
- type ClientRequestArgs,
12
- type IncomingMessage,
13
- type RequestOptions,
14
- } from "node:http";
15
- import { Agent as HttpsAgent, request as httpsRequest } from "node:https";
16
- import * as tls from "node:tls";
17
- import type { Duplex } from "node:stream";
18
- import { SocksProxyAgent } from "socks-proxy-agent";
19
- import { resolveAllAddresses, validateResolvedAddresses } from "../ssrf";
20
- import type { SsrfPolicy } from "../../config/network";
21
- import { resolveProxyMaxFreeSockets, resolveProxyMaxSockets } from "../../config/network";
22
- import type { AgentConfig } from "../types";
23
-
24
- /**
25
- * Idle keep-alive socket timeout for every pool agent. Matches the 60s DB pool
26
- * idle window so long-idle resources in one process reap together.
27
- */
28
- const PROXY_KEEP_ALIVE_TIMEOUT_MS = 60_000;
29
-
30
- /**
31
- * The transport a network pool dials outbound traffic through. Every kind is
32
- * dialled directly by the resolver; no pool spawns a child process.
33
- *
34
- * `http` and `https` differ only in which flavor answers a `https:` target, so a
35
- * hosted relay front door is recorded as the protocol it actually speaks. A
36
- * serverless host that cannot answer CONNECT at all (see `isRelayHost`) is
37
- * dialled through the `x-relay-target` contract instead, which
38
- * `createHttpProxyAgent` selects from the hostname.
39
- */
40
- export const TRANSPORT_KINDS = ["http", "https", "socks5"] as const;
41
-
42
- /** One outbound transport kind. */
43
- export type TransportKind = (typeof TRANSPORT_KINDS)[number];
44
-
45
-
46
- /** Config keys reserved for endpoint/label; every other key is opaque non-secret transport config. */
47
- export const RESERVED_ENDPOINT_CONFIG_KEYS: Record<string, true> = {
48
- endpoint: true,
49
- label: true,
50
- };
51
-
52
- export function splitEndpointConfig(config: Record<string, unknown>): {
53
- endpoint?: string;
54
- label?: string;
55
- rest: Record<string, unknown>;
56
- } {
57
- const rest: Record<string, unknown> = {};
58
- for (const [key, value] of Object.entries(config)) {
59
- if (!RESERVED_ENDPOINT_CONFIG_KEYS[key]) rest[key] = value;
60
- }
61
- return {
62
- ...(typeof config.endpoint === "string" ? { endpoint: config.endpoint } : {}),
63
- ...(typeof config.label === "string" ? { label: config.label } : {}),
64
- rest,
65
- };
66
- }
67
-
68
- /** Derives the application-level `https` distinction the DB enum collapses into `http`. */
69
- export function deriveKind(dbKind: string, endpoint: string): TransportKind {
70
- if (dbKind === "http") return endpoint.startsWith("https:") ? "https" : "http";
71
- return dbKind as TransportKind;
72
- }
73
-
74
- // Pool agents — single egress
75
- export type PoolAgent = HttpAgent | HttpsAgent | ProxyAgentPair;
76
-
77
- class InvalidPoolAgentConfigError extends Error {}
78
-
79
- function withCredential(url: URL, credential: string | undefined): URL {
80
- if (!credential) return url;
81
- const separator = credential.indexOf(":");
82
- const username = separator === -1 ? credential : credential.slice(0, separator);
83
- const password = separator === -1 ? "" : credential.slice(separator + 1);
84
- url.username = encodeURIComponent(username);
85
- url.password = encodeURIComponent(password);
86
- return url;
87
- }
88
-
89
- /**
90
- * Hosted relay front doors expose an application HTTP endpoint rather than
91
- * an RFC CONNECT proxy. Keep this classification beside the pool factory so
92
- * the dispatch and health-check paths cannot accidentally tunnel them with
93
- * CONNECT. This mirrors the supported relay-host policy in Cartethyia 21.
94
- */
95
- function isRelayHost(hostname: string): boolean {
96
- const normalized = hostname.trim().toLowerCase().replace(/\.$/, "");
97
- return (
98
- normalized.endsWith(".vercel.app") ||
99
- normalized.endsWith(".workers.dev") ||
100
- normalized.endsWith(".netlify.app") ||
101
- // Deno Deploy project URLs. `*.deno.dev` is the project hostname Deno
102
- // Deploy assigns, so a deployed relay answers there exactly like a
103
- // Vercel/Workers front door.
104
- normalized.endsWith(".deno.dev")
105
- );
106
- }
107
-
108
- /**
109
- * Per-connect DNS lookup that validates every resolved address of the dialed
110
- * host against the SSRF policy, pinning the validated address for the socket.
111
- * Shared by the HTTP CONNECT handshake and the SOCKS5 proxy socket: both dial
112
- * a *proxy* host that was validated at create-time, so a DNS-rebind of that
113
- * record must not be able to swap it to a private/link-local address later.
114
- */
115
- function createSsrfLookup(policy: SsrfPolicy): NonNullable<RequestOptions["lookup"]> {
116
- return ((
117
- hostname: string,
118
- lookupOptions: { all?: boolean; family?: number } | number,
119
- cb: (
120
- error: Error | null,
121
- address?: string | Array<{ address: string; family: number }>,
122
- family?: number,
123
- ) => void,
124
- ): void => {
125
- const abort = new AbortController();
126
- const timer = setTimeout(() => abort.abort(), 5_000);
127
- void resolveAllAddresses(hostname, abort.signal)
128
- .then((addrs) => {
129
- try {
130
- validateResolvedAddresses(addrs, policy);
131
- } catch (error) {
132
- cb(error as Error);
133
- return;
134
- }
135
- const first = addrs[0];
136
- if (!first) {
137
- cb(new Error(`no addresses resolved for ${hostname}`));
138
- return;
139
- }
140
- const family = first.includes(":") ? 6 : 4;
141
- const opts = typeof lookupOptions === "number" ? { family: lookupOptions } : lookupOptions;
142
- if (opts?.all) {
143
- cb(null, addrs.map((address) => ({ address, family: address.includes(":") ? 6 : 4 })));
144
- return;
145
- }
146
- cb(null, first, family);
147
- })
148
- .catch((error: unknown) => cb(error as Error))
149
- .finally(() => clearTimeout(timer));
150
- }) as unknown as NonNullable<RequestOptions["lookup"]>;
151
- }
152
-
153
- /**
154
- * A proxy answered the CONNECT handshake with a non-200 status instead of
155
- * establishing the tunnel. Typed so the caller can read the status without
156
- * re-parsing text: the health machine treats 402/407 as "reachable but
157
- * unusable", and a relay pool treats the whole class as "this front door
158
- * does not do CONNECT, use the relay form".
159
- */
160
- export class ProxyConnectRefusalError extends Error {
161
- constructor(
162
- readonly statusCode: number,
163
- statusMessage?: string,
164
- ) {
165
- super(`Proxy CONNECT failed: ${statusCode}${statusMessage ? ` ${statusMessage}` : ""}`);
166
- this.name = "ProxyConnectRefusalError";
167
- }
168
- }
169
-
170
- /**
171
- * A CONNECT handshake that never reached "established" for a reason other than
172
- * an explicit status refusal — the socket was reset, the connection refused, or
173
- * the peer closed before answering.
174
- *
175
- * Tagged at the handshake on purpose. The distinction that matters to a caller
176
- * is *when* the failure happened: this class means "the front door could not be
177
- * reached as a tunnel", which is the signal to fall back to the relay form. A
178
- * failure that happens *inside* an established tunnel — the target's own TLS
179
- * handshake failing, say — is a different error from a different code path and
180
- * must not be retried elsewhere, so it is deliberately not this class.
181
- */
182
- export class ProxyConnectHandshakeError extends Error {
183
- constructor(cause: Error) {
184
- super(`Proxy CONNECT handshake failed: ${cause.message}`);
185
- this.name = "ProxyConnectHandshakeError";
186
- this.cause = cause;
187
- }
188
- }
189
-
190
- /**
191
- * True when a CONNECT attempt failed at the handshake, so a relay pool
192
- * should retry through the relay form. Only the two handshake classes qualify:
193
- * a `ProxyConnectRefusalError` (the front door answered "no CONNECT") and a
194
- * `ProxyConnectHandshakeError` (the front door reset or refused the socket). A
195
- * caller cancellation and any error raised after the tunnel was established are
196
- * never retried.
197
- */
198
- /**
199
- * Shared HTTP CONNECT tunnel establishment (proxy handshake + SSRF-pinned
200
- * proxy DNS). Used by both scheme flavors below; the returned socket is the
201
- * raw CONNECT tunnel — node's http/https layer performs the *target* TLS
202
- * handshake itself over it.
203
- */
204
- function createProxyConnection(
205
- proxy: URL,
206
- policy: SsrfPolicy,
207
- options: ClientRequestArgs,
208
- callback?: (error: Error | null, stream: Duplex) => void,
209
- poolId?: string,
210
- ): Duplex | null | undefined {
211
- const targetHost = options.hostname ?? options.host ?? "";
212
- const targetPort = options.port ?? 443;
213
- const isHttpsProxy = proxy.protocol === "https:";
214
- const fail = (error: Error): void => {
215
- callback?.(error, undefined as unknown as Duplex);
216
- };
217
- const lookup = createSsrfLookup(policy);
218
- const proxyOptions: ClientRequestArgs = {
219
- hostname: proxy.hostname,
220
- port: proxy.port ? Number(proxy.port) : isHttpsProxy ? 443 : 80,
221
- method: "CONNECT",
222
- path: `${targetHost}:${targetPort}`,
223
- headers: { host: `${targetHost}:${targetPort}` },
224
- agent: false,
225
- lookup,
226
- // SNI is mandatory for name-based TLS front doors (Vercel serves a
227
- // fallback cert without it → altnames mismatch). Skipped for literal
228
- // IPs, which TLS rejects as a servername.
229
- ...(isHttpsProxy && !isIP(proxy.hostname) ? { servername: proxy.hostname } : {}),
230
- };
231
- if (proxy.username || proxy.password) {
232
- const auth = `${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password)}`;
233
- proxyOptions.headers = {
234
- ...proxyOptions.headers,
235
- "proxy-authorization": `Basic ${Buffer.from(auth).toString("base64")}`,
236
- };
237
- }
238
- const proxyRequest = (isHttpsProxy ? httpsRequest : httpRequest)(proxyOptions);
239
- // The CONNECT exchange itself costs bytes on this pool's egress. It is tens
240
- // of bytes per tunnel, so it barely moves a quota on its own — but a pool
241
- // that churns tunnels without ever establishing one spends real traffic and
242
- // previously metered as zero.
243
- if (poolId) {
244
- const requestLine = `CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\n`;
245
- const headerText = Object.entries(proxyOptions.headers ?? {})
246
- .map(([name, value]) => `${name}: ${String(value)}\r\n`)
247
- .join("");
248
- recordPoolBytes(poolId, "sent", Buffer.byteLength(requestLine + headerText + "\r\n", "utf8"));
249
- }
250
- // Every failure before the tunnel is established — the socket reset, the
251
- // connection refused, the peer closing early, the CONNECT request itself
252
- // erroring — is a handshake failure and is tagged as such, so a relay
253
- // pool can tell "this front door does not tunnel" from an error raised
254
- // later, inside a tunnel that did open.
255
- let established = false;
256
- const failHandshake = (error: Error): void => {
257
- if (established) {
258
- fail(error);
259
- return;
260
- }
261
- fail(new ProxyConnectHandshakeError(error));
262
- };
263
- proxyRequest.on(
264
- "connect",
265
- (response: IncomingMessage, socket: Duplex, head: Buffer) => {
266
- if (response.statusCode !== 200) {
267
- fail(new ProxyConnectRefusalError(response.statusCode ?? 502, response.statusMessage));
268
- socket.destroy();
269
- return;
270
- }
271
- established = true;
272
- // `head` is whatever the peer had already sent by the time CONNECT
273
- // completed. Unshifting it replays those bytes into the stream, but a
274
- // `data` listener attached afterwards never sees them — so they were
275
- // silently missing from the pool's metered totals.
276
- if (head.length > 0) {
277
- if (poolId) recordPoolBytes(poolId, "received", head.length);
278
- socket.unshift(head);
279
- }
280
- // Tally the tunnel from here on: `write` on this socket is the TLS
281
- // record stream, so what is counted is what actually went on the wire.
282
- if (poolId) {
283
- accountSocketBytes(socket, poolId);
284
- accountSocketWrites(socket, poolId);
285
- }
286
- callback?.(null, socket);
287
- },
288
- );
289
- proxyRequest.on("error", failHandshake);
290
- proxyRequest.end();
291
- return undefined;
292
- }
293
-
294
- /**
295
- * One pool's dual-scheme egress. Node validates `agent.protocol` per
296
- * request (`https.request` rejects an `http:` agent with `Protocol "https:"
297
- * not supported`), so a single Agent class cannot serve both target
298
- * schemes — the pair carries one flavor per scheme around one shared
299
- * CONNECT implementation.
300
- */
301
- export interface ProxyAgentPair {
302
- /** Flavor for `http:` targets (protocol `http:`). */
303
- readonly http: HttpAgent;
304
- /** Flavor for `https:` targets (protocol `https:`). */
305
- readonly https: HttpsAgent;
306
- /**
307
- * Relay endpoint for hosted HTTP front doors (Vercel/Workers/Netlify).
308
- * These endpoints are application relays, not RFC CONNECT proxies; the
309
- * validated fetcher sends `x-relay-target`/`x-relay-path` to this URL.
310
- */
311
- readonly relayEndpoint?: URL;
312
- }
313
-
314
- export function isProxyAgentPair(value: unknown): value is ProxyAgentPair {
315
- // Shape-checked (not instanceof): the socks pair holds two
316
- // SocksProxyAgent flavors, which are never HttpsAgent subclasses — what
317
- // matters is one agent per target scheme behind a shared CONNECT path.
318
- if (!value || typeof value !== "object") return false;
319
- const pair = value as Record<string, unknown>;
320
- const http = pair.http as { createConnection?: unknown } | undefined;
321
- const https = pair.https as { createConnection?: unknown } | undefined;
322
- return (
323
- typeof http?.createConnection === "function" &&
324
- typeof https?.createConnection === "function"
325
- );
326
- }
327
-
328
- class HttpProxyAgent extends HttpAgent {
329
- constructor(
330
- readonly proxy: URL,
331
- /** SSRF policy applied to the *proxy* hostname on every CONNECT so a
332
- * DNS-rebind of the proxy record cannot swap it to a private/link-local
333
- * address after the pool was validated at create-time. */
334
- private readonly policy: SsrfPolicy = {},
335
- config: AgentConfig = {},
336
- private readonly poolId?: string,
337
- ) {
338
- super({
339
- keepAlive: true,
340
- maxSockets: config.maxSockets ?? resolveProxyMaxSockets(),
341
- maxFreeSockets: config.maxFreeSockets ?? resolveProxyMaxFreeSockets(),
342
- timeout: config.keepAliveTimeout ?? PROXY_KEEP_ALIVE_TIMEOUT_MS,
343
- });
344
- }
345
-
346
- override createConnection(
347
- options: ClientRequestArgs,
348
- callback?: (error: Error | null, stream: Duplex) => void,
349
- ): Duplex | null | undefined {
350
- return createProxyConnection(this.proxy, this.policy, options, callback, this.poolId);
351
- }
352
- }
353
-
354
- class HttpsProxyAgent extends HttpsAgent {
355
- constructor(
356
- readonly proxy: URL,
357
- private readonly policy: SsrfPolicy = {},
358
- config: AgentConfig = {},
359
- private readonly poolId?: string,
360
- ) {
361
- super({
362
- keepAlive: true,
363
- maxSockets: config.maxSockets ?? resolveProxyMaxSockets(),
364
- maxFreeSockets: config.maxFreeSockets ?? resolveProxyMaxFreeSockets(),
365
- timeout: config.keepAliveTimeout ?? PROXY_KEEP_ALIVE_TIMEOUT_MS,
366
- });
367
- }
368
- override createConnection(
369
- options: ClientRequestArgs,
370
- callback?: (error: Error | null, stream: Duplex) => void,
371
- ): Duplex | null | undefined {
372
- return createProxyConnection(this.proxy, this.policy, options, (err, rawSocket) => {
373
- if (err || !rawSocket) {
374
- callback?.(err, rawSocket);
375
- return;
376
- }
377
- const targetHost = options.hostname ?? options.host ?? "";
378
- const tlsSocket = tls.connect({
379
- socket: rawSocket,
380
- servername: isIP(targetHost) ? undefined : targetHost,
381
- rejectUnauthorized: this.options.rejectUnauthorized !== false,
382
- });
383
- tlsSocket.once("secureConnect", () => {
384
- callback?.(null, tlsSocket);
385
- });
386
- tlsSocket.once("error", (tlsErr) => {
387
- rawSocket.destroy();
388
- callback?.(tlsErr, undefined as unknown as Duplex);
389
- });
390
- }, this.poolId);
391
- }
392
- }
393
- export function createHttpProxyAgent(
394
- endpoint: string,
395
- credential?: string,
396
- policy: SsrfPolicy = {},
397
- config: AgentConfig = {},
398
- poolId?: string,
399
- ): ProxyAgentPair {
400
- let url: URL;
401
- try {
402
- url = new URL(endpoint);
403
- } catch {
404
- throw new InvalidPoolAgentConfigError(`invalid HTTP proxy endpoint: ${endpoint}`);
405
- }
406
- const proxy = new URL(withCredential(url, credential).toString());
407
- return {
408
- http: new HttpProxyAgent(proxy, policy, config, poolId),
409
- https: new HttpsProxyAgent(proxy, policy, config, poolId),
410
- ...(isRelayHost(proxy.hostname) ? { relayEndpoint: proxy } : {}),
411
- };
412
- }
413
-
414
- /**
415
- * Tally a SOCKS5 agent's tunnel.
416
- *
417
- * The socks client owns socket creation, so this wraps `createConnection`
418
- * instead of the CONNECT callback the HTTP path uses. The socket handed back is
419
- * the raw tunnel (Node applies TLS afterwards for `https:` targets), which is
420
- * exactly the layer whose bytes the proxy bills. The SOCKS negotiation itself
421
- * is a handful of bytes exchanged before this point and is not counted.
422
- */
423
- function accountSocksAgent(agent: SocksProxyAgent, poolId?: string): void {
424
- if (!poolId) return;
425
- const original = agent.createConnection.bind(agent) as (
426
- options: ClientRequestArgs,
427
- callback?: (error: Error | null, stream: Duplex) => void,
428
- ) => Duplex | null | undefined;
429
- agent.createConnection = ((options: ClientRequestArgs, callback?: (error: Error | null, stream: Duplex) => void) =>
430
- original(options, (error, socket) => {
431
- if (socket) {
432
- accountSocketBytes(socket, poolId);
433
- accountSocketWrites(socket, poolId);
434
- }
435
- callback?.(error, socket);
436
- })) as unknown as typeof agent.createConnection;
437
- }
438
-
439
- export function createSocks5Agent(
440
- endpoint: string,
441
- credential?: string,
442
- policy: SsrfPolicy = {},
443
- config: AgentConfig = {},
444
- poolId?: string,
445
- ): ProxyAgentPair {
446
- const authority = endpoint.includes("://") ? endpoint : `socks5://${endpoint}`;
447
- let url: URL;
448
- try {
449
- url = new URL(authority);
450
- } catch {
451
- throw new InvalidPoolAgentConfigError(`invalid SOCKS5 endpoint: ${endpoint}`);
452
- }
453
- // The proxy hostname is resolved by the socks client itself, so the policy
454
- // is applied through the socket's `lookup`: every connect re-resolves and
455
- // re-validates the proxy address instead of trusting the create-time check.
456
- const options = {
457
- socketOptions: { lookup: createSsrfLookup(policy) },
458
- keepAlive: true,
459
- maxSockets: config.maxSockets ?? resolveProxyMaxSockets(),
460
- maxFreeSockets: config.maxFreeSockets ?? resolveProxyMaxFreeSockets(),
461
- timeout: config.keepAliveTimeout ?? PROXY_KEEP_ALIVE_TIMEOUT_MS,
462
- };
463
- const http = new SocksProxyAgent(withCredential(url, credential), options);
464
- accountSocksAgent(http, poolId);
465
- // Same latent Node check as HTTP pools: an `http:`-protocol agent is
466
- // rejected for `https:` targets, so the https flavor carries an
467
- // overridden protocol (per-instance shadow — the shared SocksProxyAgent
468
- // prototype is untouched). Behavior is identical; only the check reads it.
469
- const https = new SocksProxyAgent(withCredential(url, credential), options);
470
- https.protocol = "https:";
471
- accountSocksAgent(https, poolId);
472
- return {
473
- http: http as unknown as HttpAgent,
474
- https: https as unknown as HttpsAgent,
475
- };
476
- }
477
-
478
- export class PoolBindingError extends Error {}
1
+ /**
2
+ * Pool egress agents: the HTTP CONNECT pair, the SOCKS5 pair, and the
3
+ * per-connect SSRF revalidation they share. `PoolAgentResolver` (resolver.ts)
4
+ * caches instances of these; the selector owns admission.
5
+ */
6
+ import { isIP } from "node:net";
7
+ import { accountSocketBytes, accountSocketWrites, recordPoolBytes } from "./byte-accounting";
8
+ import {
9
+ Agent as HttpAgent,
10
+ request as httpRequest,
11
+ type ClientRequestArgs,
12
+ type IncomingMessage,
13
+ type RequestOptions,
14
+ } from "node:http";
15
+ import { Agent as HttpsAgent, request as httpsRequest } from "node:https";
16
+ import * as tls from "node:tls";
17
+ import type { Duplex } from "node:stream";
18
+ import { SocksProxyAgent } from "socks-proxy-agent";
19
+ import { resolveAllAddresses, validateResolvedAddresses } from "../ssrf";
20
+ import type { SsrfPolicy } from "../../config/network";
21
+ import { resolveProxyMaxFreeSockets, resolveProxyMaxSockets } from "../../config/network";
22
+ import type { AgentConfig } from "../types";
23
+
24
+ /**
25
+ * Idle keep-alive socket timeout for every pool agent. Matches the 60s DB pool
26
+ * idle window so long-idle resources in one process reap together.
27
+ */
28
+ const PROXY_KEEP_ALIVE_TIMEOUT_MS = 60_000;
29
+
30
+ /**
31
+ * The transport a network pool dials outbound traffic through. Every kind is
32
+ * dialled directly by the resolver; no pool spawns a child process.
33
+ *
34
+ * `http` and `https` differ only in which flavor answers a `https:` target, so a
35
+ * hosted relay front door is recorded as the protocol it actually speaks. A
36
+ * serverless host that cannot answer CONNECT at all (see `isRelayHost`) is
37
+ * dialled through the `x-relay-target` contract instead, which
38
+ * `createHttpProxyAgent` selects from the hostname.
39
+ */
40
+ export const TRANSPORT_KINDS = ["http", "https", "socks5"] as const;
41
+
42
+ /** One outbound transport kind. */
43
+ export type TransportKind = (typeof TRANSPORT_KINDS)[number];
44
+
45
+
46
+ /** Config keys reserved for endpoint/label; every other key is opaque non-secret transport config. */
47
+ export const RESERVED_ENDPOINT_CONFIG_KEYS: Record<string, true> = {
48
+ endpoint: true,
49
+ label: true,
50
+ };
51
+
52
+ export function splitEndpointConfig(config: Record<string, unknown>): {
53
+ endpoint?: string;
54
+ label?: string;
55
+ rest: Record<string, unknown>;
56
+ } {
57
+ const rest: Record<string, unknown> = {};
58
+ for (const [key, value] of Object.entries(config)) {
59
+ if (!RESERVED_ENDPOINT_CONFIG_KEYS[key]) rest[key] = value;
60
+ }
61
+ return {
62
+ ...(typeof config.endpoint === "string" ? { endpoint: config.endpoint } : {}),
63
+ ...(typeof config.label === "string" ? { label: config.label } : {}),
64
+ rest,
65
+ };
66
+ }
67
+
68
+ /** Derives the application-level `https` distinction the DB enum collapses into `http`. */
69
+ export function deriveKind(dbKind: string, endpoint: string): TransportKind {
70
+ if (dbKind === "http") return endpoint.startsWith("https:") ? "https" : "http";
71
+ return dbKind as TransportKind;
72
+ }
73
+
74
+ // Pool agents — single egress
75
+ export type PoolAgent = HttpAgent | HttpsAgent | ProxyAgentPair;
76
+
77
+ class InvalidPoolAgentConfigError extends Error {}
78
+
79
+ function withCredential(url: URL, credential: string | undefined): URL {
80
+ if (!credential) return url;
81
+ const separator = credential.indexOf(":");
82
+ const username = separator === -1 ? credential : credential.slice(0, separator);
83
+ const password = separator === -1 ? "" : credential.slice(separator + 1);
84
+ url.username = encodeURIComponent(username);
85
+ url.password = encodeURIComponent(password);
86
+ return url;
87
+ }
88
+
89
+ /**
90
+ * Hosted relay front doors expose an application HTTP endpoint rather than
91
+ * an RFC CONNECT proxy. Keep this classification beside the pool factory so
92
+ * the dispatch and health-check paths cannot accidentally tunnel them with
93
+ * CONNECT. This mirrors the supported relay-host policy in Cartethyia 21.
94
+ */
95
+ function isRelayHost(hostname: string): boolean {
96
+ const normalized = hostname.trim().toLowerCase().replace(/\.$/, "");
97
+ return (
98
+ normalized.endsWith(".vercel.app") ||
99
+ normalized.endsWith(".workers.dev") ||
100
+ normalized.endsWith(".netlify.app") ||
101
+ // Deno Deploy project URLs. `*.deno.dev` is the project hostname Deno
102
+ // Deploy assigns, so a deployed relay answers there exactly like a
103
+ // Vercel/Workers front door.
104
+ normalized.endsWith(".deno.dev")
105
+ );
106
+ }
107
+
108
+ /**
109
+ * Per-connect DNS lookup that validates every resolved address of the dialed
110
+ * host against the SSRF policy, pinning the validated address for the socket.
111
+ * Shared by the HTTP CONNECT handshake and the SOCKS5 proxy socket: both dial
112
+ * a *proxy* host that was validated at create-time, so a DNS-rebind of that
113
+ * record must not be able to swap it to a private/link-local address later.
114
+ */
115
+ function createSsrfLookup(policy: SsrfPolicy): NonNullable<RequestOptions["lookup"]> {
116
+ return ((
117
+ hostname: string,
118
+ lookupOptions: { all?: boolean; family?: number } | number,
119
+ cb: (
120
+ error: Error | null,
121
+ address?: string | Array<{ address: string; family: number }>,
122
+ family?: number,
123
+ ) => void,
124
+ ): void => {
125
+ const abort = new AbortController();
126
+ const timer = setTimeout(() => abort.abort(), 5_000);
127
+ void resolveAllAddresses(hostname, abort.signal)
128
+ .then((addrs) => {
129
+ try {
130
+ validateResolvedAddresses(addrs, policy);
131
+ } catch (error) {
132
+ cb(error as Error);
133
+ return;
134
+ }
135
+ const first = addrs[0];
136
+ if (!first) {
137
+ cb(new Error(`no addresses resolved for ${hostname}`));
138
+ return;
139
+ }
140
+ const family = first.includes(":") ? 6 : 4;
141
+ const opts = typeof lookupOptions === "number" ? { family: lookupOptions } : lookupOptions;
142
+ if (opts?.all) {
143
+ cb(null, addrs.map((address) => ({ address, family: address.includes(":") ? 6 : 4 })));
144
+ return;
145
+ }
146
+ cb(null, first, family);
147
+ })
148
+ .catch((error: unknown) => cb(error as Error))
149
+ .finally(() => clearTimeout(timer));
150
+ }) as unknown as NonNullable<RequestOptions["lookup"]>;
151
+ }
152
+
153
+ /**
154
+ * A proxy answered the CONNECT handshake with a non-200 status instead of
155
+ * establishing the tunnel. Typed so the caller can read the status without
156
+ * re-parsing text: the health machine treats 402/407 as "reachable but
157
+ * unusable", and a relay pool treats the whole class as "this front door
158
+ * does not do CONNECT, use the relay form".
159
+ */
160
+ export class ProxyConnectRefusalError extends Error {
161
+ constructor(
162
+ readonly statusCode: number,
163
+ statusMessage?: string,
164
+ ) {
165
+ super(`Proxy CONNECT failed: ${statusCode}${statusMessage ? ` ${statusMessage}` : ""}`);
166
+ this.name = "ProxyConnectRefusalError";
167
+ }
168
+ }
169
+
170
+ /**
171
+ * A CONNECT handshake that never reached "established" for a reason other than
172
+ * an explicit status refusal — the socket was reset, the connection refused, or
173
+ * the peer closed before answering.
174
+ *
175
+ * Tagged at the handshake on purpose. The distinction that matters to a caller
176
+ * is *when* the failure happened: this class means "the front door could not be
177
+ * reached as a tunnel", which is the signal to fall back to the relay form. A
178
+ * failure that happens *inside* an established tunnel — the target's own TLS
179
+ * handshake failing, say — is a different error from a different code path and
180
+ * must not be retried elsewhere, so it is deliberately not this class.
181
+ */
182
+ export class ProxyConnectHandshakeError extends Error {
183
+ constructor(cause: Error) {
184
+ super(`Proxy CONNECT handshake failed: ${cause.message}`);
185
+ this.name = "ProxyConnectHandshakeError";
186
+ this.cause = cause;
187
+ }
188
+ }
189
+
190
+ /**
191
+ * True when a CONNECT attempt failed at the handshake, so a relay pool
192
+ * should retry through the relay form. Only the two handshake classes qualify:
193
+ * a `ProxyConnectRefusalError` (the front door answered "no CONNECT") and a
194
+ * `ProxyConnectHandshakeError` (the front door reset or refused the socket). A
195
+ * caller cancellation and any error raised after the tunnel was established are
196
+ * never retried.
197
+ */
198
+ /**
199
+ * Shared HTTP CONNECT tunnel establishment (proxy handshake + SSRF-pinned
200
+ * proxy DNS). Used by both scheme flavors below; the returned socket is the
201
+ * raw CONNECT tunnel — node's http/https layer performs the *target* TLS
202
+ * handshake itself over it.
203
+ */
204
+ function createProxyConnection(
205
+ proxy: URL,
206
+ policy: SsrfPolicy,
207
+ options: ClientRequestArgs,
208
+ callback?: (error: Error | null, stream: Duplex) => void,
209
+ poolId?: string,
210
+ ): Duplex | null | undefined {
211
+ const targetHost = options.hostname ?? options.host ?? "";
212
+ const targetPort = options.port ?? 443;
213
+ const isHttpsProxy = proxy.protocol === "https:";
214
+ const fail = (error: Error): void => {
215
+ callback?.(error, undefined as unknown as Duplex);
216
+ };
217
+ const lookup = createSsrfLookup(policy);
218
+ const proxyOptions: ClientRequestArgs = {
219
+ hostname: proxy.hostname,
220
+ port: proxy.port ? Number(proxy.port) : isHttpsProxy ? 443 : 80,
221
+ method: "CONNECT",
222
+ path: `${targetHost}:${targetPort}`,
223
+ headers: { host: `${targetHost}:${targetPort}` },
224
+ agent: false,
225
+ lookup,
226
+ // SNI is mandatory for name-based TLS front doors (Vercel serves a
227
+ // fallback cert without it → altnames mismatch). Skipped for literal
228
+ // IPs, which TLS rejects as a servername.
229
+ ...(isHttpsProxy && !isIP(proxy.hostname) ? { servername: proxy.hostname } : {}),
230
+ };
231
+ if (proxy.username || proxy.password) {
232
+ const auth = `${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password)}`;
233
+ proxyOptions.headers = {
234
+ ...proxyOptions.headers,
235
+ "proxy-authorization": `Basic ${Buffer.from(auth).toString("base64")}`,
236
+ };
237
+ }
238
+ const proxyRequest = (isHttpsProxy ? httpsRequest : httpRequest)(proxyOptions);
239
+ // The CONNECT exchange itself costs bytes on this pool's egress. It is tens
240
+ // of bytes per tunnel, so it barely moves a quota on its own — but a pool
241
+ // that churns tunnels without ever establishing one spends real traffic and
242
+ // would otherwise meter as zero.
243
+ if (poolId) {
244
+ const requestLine = `CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\n`;
245
+ const headerText = Object.entries(proxyOptions.headers ?? {})
246
+ .map(([name, value]) => `${name}: ${String(value)}\r\n`)
247
+ .join("");
248
+ recordPoolBytes(poolId, "sent", Buffer.byteLength(requestLine + headerText + "\r\n", "utf8"));
249
+ }
250
+ // Every failure before the tunnel is established — the socket reset, the
251
+ // connection refused, the peer closing early, the CONNECT request itself
252
+ // erroring — is a handshake failure and is tagged as such, so a relay
253
+ // pool can tell "this front door does not tunnel" from an error raised
254
+ // later, inside a tunnel that did open.
255
+ let established = false;
256
+ const failHandshake = (error: Error): void => {
257
+ if (established) {
258
+ fail(error);
259
+ return;
260
+ }
261
+ fail(new ProxyConnectHandshakeError(error));
262
+ };
263
+ proxyRequest.on(
264
+ "connect",
265
+ (response: IncomingMessage, socket: Duplex, head: Buffer) => {
266
+ if (response.statusCode !== 200) {
267
+ fail(new ProxyConnectRefusalError(response.statusCode ?? 502, response.statusMessage));
268
+ socket.destroy();
269
+ return;
270
+ }
271
+ established = true;
272
+ // `head` is whatever the peer had already sent by the time CONNECT
273
+ // completed. Unshifting it replays those bytes into the stream, but a
274
+ // `data` listener attached afterwards never sees them — so they were
275
+ // silently missing from the pool's metered totals.
276
+ if (head.length > 0) {
277
+ if (poolId) recordPoolBytes(poolId, "received", head.length);
278
+ socket.unshift(head);
279
+ }
280
+ // Tally the tunnel from here on: `write` on this socket is the TLS
281
+ // record stream, so what is counted is what actually went on the wire.
282
+ if (poolId) {
283
+ accountSocketBytes(socket, poolId);
284
+ accountSocketWrites(socket, poolId);
285
+ }
286
+ callback?.(null, socket);
287
+ },
288
+ );
289
+ proxyRequest.on("error", failHandshake);
290
+ proxyRequest.end();
291
+ return undefined;
292
+ }
293
+
294
+ /**
295
+ * One pool's dual-scheme egress. Node validates `agent.protocol` per
296
+ * request (`https.request` rejects an `http:` agent with `Protocol "https:"
297
+ * not supported`), so a single Agent class cannot serve both target
298
+ * schemes — the pair carries one flavor per scheme around one shared
299
+ * CONNECT implementation.
300
+ */
301
+ export interface ProxyAgentPair {
302
+ /** Flavor for `http:` targets (protocol `http:`). */
303
+ readonly http: HttpAgent;
304
+ /** Flavor for `https:` targets (protocol `https:`). */
305
+ readonly https: HttpsAgent;
306
+ /**
307
+ * Relay endpoint for hosted HTTP front doors (Vercel/Workers/Netlify).
308
+ * These endpoints are application relays, not RFC CONNECT proxies; the
309
+ * validated fetcher sends `x-relay-target`/`x-relay-path` to this URL.
310
+ */
311
+ readonly relayEndpoint?: URL;
312
+ }
313
+
314
+ export function isProxyAgentPair(value: unknown): value is ProxyAgentPair {
315
+ // Shape-checked (not instanceof): the socks pair holds two
316
+ // SocksProxyAgent flavors, which are never HttpsAgent subclasses — what
317
+ // matters is one agent per target scheme behind a shared CONNECT path.
318
+ if (!value || typeof value !== "object") return false;
319
+ const pair = value as Record<string, unknown>;
320
+ const http = pair.http as { createConnection?: unknown } | undefined;
321
+ const https = pair.https as { createConnection?: unknown } | undefined;
322
+ return (
323
+ typeof http?.createConnection === "function" &&
324
+ typeof https?.createConnection === "function"
325
+ );
326
+ }
327
+
328
+ class HttpProxyAgent extends HttpAgent {
329
+ constructor(
330
+ readonly proxy: URL,
331
+ /** SSRF policy applied to the *proxy* hostname on every CONNECT so a
332
+ * DNS-rebind of the proxy record cannot swap it to a private/link-local
333
+ * address after the pool was validated at create-time. */
334
+ private readonly policy: SsrfPolicy = {},
335
+ config: AgentConfig = {},
336
+ private readonly poolId?: string,
337
+ ) {
338
+ super({
339
+ keepAlive: true,
340
+ maxSockets: config.maxSockets ?? resolveProxyMaxSockets(),
341
+ maxFreeSockets: config.maxFreeSockets ?? resolveProxyMaxFreeSockets(),
342
+ timeout: config.keepAliveTimeout ?? PROXY_KEEP_ALIVE_TIMEOUT_MS,
343
+ });
344
+ }
345
+
346
+ override createConnection(
347
+ options: ClientRequestArgs,
348
+ callback?: (error: Error | null, stream: Duplex) => void,
349
+ ): Duplex | null | undefined {
350
+ return createProxyConnection(this.proxy, this.policy, options, callback, this.poolId);
351
+ }
352
+ }
353
+
354
+ class HttpsProxyAgent extends HttpsAgent {
355
+ constructor(
356
+ readonly proxy: URL,
357
+ private readonly policy: SsrfPolicy = {},
358
+ config: AgentConfig = {},
359
+ private readonly poolId?: string,
360
+ ) {
361
+ super({
362
+ keepAlive: true,
363
+ maxSockets: config.maxSockets ?? resolveProxyMaxSockets(),
364
+ maxFreeSockets: config.maxFreeSockets ?? resolveProxyMaxFreeSockets(),
365
+ timeout: config.keepAliveTimeout ?? PROXY_KEEP_ALIVE_TIMEOUT_MS,
366
+ });
367
+ }
368
+ override createConnection(
369
+ options: ClientRequestArgs,
370
+ callback?: (error: Error | null, stream: Duplex) => void,
371
+ ): Duplex | null | undefined {
372
+ return createProxyConnection(this.proxy, this.policy, options, (err, rawSocket) => {
373
+ if (err || !rawSocket) {
374
+ callback?.(err, rawSocket);
375
+ return;
376
+ }
377
+ const targetHost = options.hostname ?? options.host ?? "";
378
+ const tlsSocket = tls.connect({
379
+ socket: rawSocket,
380
+ servername: isIP(targetHost) ? undefined : targetHost,
381
+ rejectUnauthorized: this.options.rejectUnauthorized !== false,
382
+ });
383
+ tlsSocket.once("secureConnect", () => {
384
+ callback?.(null, tlsSocket);
385
+ });
386
+ tlsSocket.once("error", (tlsErr) => {
387
+ rawSocket.destroy();
388
+ callback?.(tlsErr, undefined as unknown as Duplex);
389
+ });
390
+ }, this.poolId);
391
+ }
392
+ }
393
+ export function createHttpProxyAgent(
394
+ endpoint: string,
395
+ credential?: string,
396
+ policy: SsrfPolicy = {},
397
+ config: AgentConfig = {},
398
+ poolId?: string,
399
+ ): ProxyAgentPair {
400
+ let url: URL;
401
+ try {
402
+ url = new URL(endpoint);
403
+ } catch {
404
+ throw new InvalidPoolAgentConfigError(`invalid HTTP proxy endpoint: ${endpoint}`);
405
+ }
406
+ const proxy = new URL(withCredential(url, credential).toString());
407
+ return {
408
+ http: new HttpProxyAgent(proxy, policy, config, poolId),
409
+ https: new HttpsProxyAgent(proxy, policy, config, poolId),
410
+ ...(isRelayHost(proxy.hostname) ? { relayEndpoint: proxy } : {}),
411
+ };
412
+ }
413
+
414
+ /**
415
+ * Tally a SOCKS5 agent's tunnel.
416
+ *
417
+ * The socks client owns socket creation, so this wraps `createConnection`
418
+ * instead of the CONNECT callback the HTTP path uses. The socket handed back is
419
+ * the raw tunnel (Node applies TLS afterwards for `https:` targets), which is
420
+ * exactly the layer whose bytes the proxy bills. The SOCKS negotiation itself
421
+ * is a handful of bytes exchanged before this point and is not counted.
422
+ */
423
+ function accountSocksAgent(agent: SocksProxyAgent, poolId?: string): void {
424
+ if (!poolId) return;
425
+ const original = agent.createConnection.bind(agent) as (
426
+ options: ClientRequestArgs,
427
+ callback?: (error: Error | null, stream: Duplex) => void,
428
+ ) => Duplex | null | undefined;
429
+ agent.createConnection = ((options: ClientRequestArgs, callback?: (error: Error | null, stream: Duplex) => void) =>
430
+ original(options, (error, socket) => {
431
+ if (socket) {
432
+ accountSocketBytes(socket, poolId);
433
+ accountSocketWrites(socket, poolId);
434
+ }
435
+ callback?.(error, socket);
436
+ })) as unknown as typeof agent.createConnection;
437
+ }
438
+
439
+ export function createSocks5Agent(
440
+ endpoint: string,
441
+ credential?: string,
442
+ policy: SsrfPolicy = {},
443
+ config: AgentConfig = {},
444
+ poolId?: string,
445
+ ): ProxyAgentPair {
446
+ const authority = endpoint.includes("://") ? endpoint : `socks5://${endpoint}`;
447
+ let url: URL;
448
+ try {
449
+ url = new URL(authority);
450
+ } catch {
451
+ throw new InvalidPoolAgentConfigError(`invalid SOCKS5 endpoint: ${endpoint}`);
452
+ }
453
+ // The proxy hostname is resolved by the socks client itself, so the policy
454
+ // is applied through the socket's `lookup`: every connect re-resolves and
455
+ // re-validates the proxy address instead of trusting the create-time check.
456
+ const options = {
457
+ socketOptions: { lookup: createSsrfLookup(policy) },
458
+ keepAlive: true,
459
+ maxSockets: config.maxSockets ?? resolveProxyMaxSockets(),
460
+ maxFreeSockets: config.maxFreeSockets ?? resolveProxyMaxFreeSockets(),
461
+ timeout: config.keepAliveTimeout ?? PROXY_KEEP_ALIVE_TIMEOUT_MS,
462
+ };
463
+ const http = new SocksProxyAgent(withCredential(url, credential), options);
464
+ accountSocksAgent(http, poolId);
465
+ // Same latent Node check as HTTP pools: an `http:`-protocol agent is
466
+ // rejected for `https:` targets, so the https flavor carries an
467
+ // overridden protocol (per-instance shadow — the shared SocksProxyAgent
468
+ // prototype is untouched). Behavior is identical; only the check reads it.
469
+ const https = new SocksProxyAgent(withCredential(url, credential), options);
470
+ https.protocol = "https:";
471
+ accountSocksAgent(https, poolId);
472
+ return {
473
+ http: http as unknown as HttpAgent,
474
+ https: https as unknown as HttpsAgent,
475
+ };
476
+ }
477
+
478
+ export class PoolBindingError extends Error {}