install.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315
  1. /**
  2. * Proxy installation: the transport half of this package. It owns undici's global dispatcher and the
  3. * process-wide record of which policy is active.
  4. *
  5. * `undici` is imported dynamically so the pure {@link ProxyPolicy} half stays loadable where no Node
  6. * transport exists, matching how `dsh-web-fetch-http` defers its own transport import.
  7. * @module @deepseek-ai/dsh-http-proxy/install
  8. */
  9. import type { Dispatcher, Pool } from 'undici'
  10. import {
  11. isSupportedProxyUrl,
  12. POLICY_ENV_NAMES,
  13. PROXY_ENV_NAMES,
  14. proxyForUrl,
  15. resolveProxyPolicy,
  16. type EnvLookup,
  17. type ProxyPolicy,
  18. } from './policy.ts'
  19. /** The active policy, or `undefined` until one is installed. Process-wide, like the dispatcher it tracks. */
  20. let active: ProxyPolicy | undefined
  21. /**
  22. * The proxy environment as the user exported it, or `undefined` when no policy is installed.
  23. *
  24. * Owned by the OUTERMOST install: one layered over the launcher's would otherwise record the outer
  25. * policy's published values as if the user had written them, and
  26. * hand every child a normalization the user never asked for.
  27. *
  28. * {@link proxyEnvironmentForChild} keeps a value the user set rather than the one this process resolved from
  29. * it, so a SOCKS proxy `curl` can use is not replaced by an HTTP proxy named for another scheme.
  30. */
  31. let inheritedProxyEnv: Readonly<Record<string, string | undefined>> | undefined
  32. /** The dispatcher installed with {@link active}, so a route can hand back the one already routing. */
  33. let installed: Dispatcher | undefined
  34. /**
  35. * How this process must send one request.
  36. *
  37. * A caller that branches on the answer needs the transport that answer assumed, or an install or
  38. * disposal landing between the two would send the request somewhere the branch did not clear. The
  39. * proxied arm therefore carries the dispatcher already routing by this policy: it is process-wide
  40. * and long-lived, so a caller uses it and never closes it. Disposal closes that dispatcher rather
  41. * than destroying it, so a request already dispatched when a policy is unmounted still finishes.
  42. */
  43. export type ProxyRoute =
  44. | { readonly proxied: true; readonly proxy: string; readonly dispatcher: Dispatcher }
  45. | { readonly proxied: false }
  46. /** A route that sends nothing through a proxy, shared because it carries no per-request state. */
  47. const DIRECT_ROUTE: ProxyRoute = { proxied: false }
  48. /**
  49. * Decide how to send one request, and hand back the transport that decision assumed.
  50. *
  51. * @param url - the request URL.
  52. * @returns the proxied route with its proxy URL and dispatcher, or the direct route.
  53. */
  54. export function proxyRouteFor(url: URL): ProxyRoute {
  55. const policy = active
  56. const dispatcher = installed
  57. if (policy === undefined || dispatcher === undefined) return DIRECT_ROUTE
  58. const proxy = proxyForUrl(policy, url)
  59. return proxy === undefined ? DIRECT_ROUTE : { proxied: true, proxy, dispatcher }
  60. }
  61. /**
  62. * Publish a policy through the proxy environment variables, which is how the consumers that read an
  63. * environment rather than a policy object — `node:http`'s `proxyEnv` and every spawned child — see
  64. * the one resolved answer, including the `ALL_PROXY` fallback and the merged loopback bypass that
  65. * neither derives on its own. The global dispatcher does not read these; it routes by the policy.
  66. *
  67. * @param policy - the policy to publish.
  68. * @returns a function restoring every name this call changed.
  69. */
  70. function applyPolicyEnv(policy: ProxyPolicy): () => void {
  71. const previousInherited = inheritedProxyEnv
  72. inheritedProxyEnv = previousInherited ?? snapshotProxyEnv()
  73. const published: Record<string, string | undefined> = {}
  74. for (const [field, names] of Object.entries(POLICY_ENV_NAMES)) {
  75. const value = policy[field as keyof typeof POLICY_ENV_NAMES]
  76. for (const name of names) published[name] = value
  77. }
  78. const restore = writeProxyEnv(published)
  79. return () => {
  80. restore()
  81. inheritedProxyEnv = previousInherited
  82. }
  83. }
  84. /**
  85. * Read every proxy name this package publishes, as `process.env` holds it now.
  86. *
  87. * @returns one entry per name in {@link POLICY_ENV_NAMES}; `undefined` marks an absent name.
  88. */
  89. function snapshotProxyEnv(): Record<string, string | undefined> {
  90. const snapshot: Record<string, string | undefined> = {}
  91. for (const names of Object.values(POLICY_ENV_NAMES)) {
  92. for (const name of names) snapshot[name] = process.env[name]
  93. }
  94. return snapshot
  95. }
  96. /**
  97. * Set every proxy name to the value `values` holds for it, removing a name whose value is `undefined`.
  98. *
  99. * @param values - the value each name in {@link POLICY_ENV_NAMES} should hold.
  100. * @returns a function restoring every name to what it held before this call.
  101. */
  102. function writeProxyEnv(values: Readonly<Record<string, string | undefined>>): () => void {
  103. // Snapshot EVERY name before writing any of them. Windows folds environment names case-insensitively,
  104. // so reading the uppercase spelling after writing the lowercase one would read back the value just
  105. // written and restore the policy instead of the user's environment.
  106. const previous = snapshotProxyEnv()
  107. for (const name of Object.keys(previous)) {
  108. const value = values[name]
  109. if (value === undefined) Reflect.deleteProperty(process.env, name)
  110. else process.env[name] = value
  111. }
  112. return () => {
  113. for (const [name, value] of Object.entries(previous)) {
  114. if (value === undefined) Reflect.deleteProperty(process.env, name)
  115. else process.env[name] = value
  116. }
  117. }
  118. }
  119. /**
  120. * Build the global dispatcher for one policy.
  121. *
  122. * Routing runs through {@link proxyForUrl} per origin, so `fetch` and every caller that asks where a
  123. * URL goes read the same answer from the same matcher. undici's `EnvHttpProxyAgent` cannot express
  124. * this policy: with no `HTTPS_PROXY` present it reuses the HTTP proxy for `https:`, which would
  125. * tunnel a scheme this package deliberately keeps direct after refusing the SOCKS or malformed URL
  126. * the user named for it — the route and the diagnostic would then disagree.
  127. *
  128. * @param policy - the policy to route by; it must proxy at least one scheme.
  129. * @returns the dispatcher to install, owning every per-origin agent its factory created.
  130. */
  131. async function createPolicyDispatcher(policy: ProxyPolicy): Promise<Dispatcher> {
  132. const { Agent, Pool, ProxyAgent } = await import('undici')
  133. return new Agent({
  134. factory(origin, options) {
  135. // undici declares this parameter as `Object`, discarding the pool options it actually passes.
  136. const passed = options as Pool.Options
  137. const proxy = proxyForUrl(policy, new URL(origin.toString()))
  138. if (proxy !== undefined) return new ProxyAgent({ ...passed, uri: proxy })
  139. // What undici's own default factory builds for these options, which `factory` replaces
  140. // wholesale. It reaches for a bare `Client` only at `connections: 1`, an option this
  141. // dispatcher never carries: it is constructed with undici's defaults.
  142. return new Pool(origin, passed)
  143. },
  144. })
  145. }
  146. /**
  147. * Route this process's outbound HTTP through `policy`.
  148. *
  149. * Installing replaces undici's global dispatcher, which is what Node's built-in `fetch` resolves, so
  150. * every caller that issues a plain `fetch()` is covered without knowing this package exists. A policy
  151. * that proxies nothing installs a direct dispatcher and leaves the environment untouched.
  152. *
  153. * A worker thread has its own `globalThis` and so its own dispatcher; installing here does not
  154. * reach it. No worker installs one today: both this repository ships — the workflow engine and the
  155. * PTC runtime — evaluate model-authored scripts, which must not receive a proxy URL that may carry
  156. * credentials. A worker that needs the policy has to be handed one explicitly and install it itself.
  157. *
  158. * @param policy - the resolved policy to install.
  159. * @returns a disposer restoring the previous dispatcher, policy, and environment, then closing the agent.
  160. */
  161. async function installGlobalProxy(policy: ProxyPolicy): Promise<() => Promise<void>> {
  162. const previousPolicy = active
  163. if (policy.source === 'none') {
  164. // A direct policy mounted over an installed one must actually stop proxying. Recording the policy
  165. // alone would leave the previous agent as the global dispatcher, so a plain `fetch()` would keep
  166. // tunnelling while `proxyForUrl()` reported a direct connection — and `mode: 'off'` would be a
  167. // silent no-op. With nothing installed there is nothing to displace.
  168. if (previousPolicy === undefined) {
  169. active = policy
  170. return () => {
  171. active = previousPolicy
  172. return Promise.resolve()
  173. }
  174. }
  175. const previousInstalled = installed
  176. // The install underneath published its normalized policy into `process.env`, which is what a
  177. // spawned child copies. With no policy active there is no normalization to stand behind, so the
  178. // user's own values return for the window and the outer install's come back when it ends. An
  179. // install underneath that proxied nothing published nothing, and there is nothing to put back.
  180. const restoreEnv = inheritedProxyEnv === undefined ? undefined : writeProxyEnv(inheritedProxyEnv)
  181. const undici = await import('undici')
  182. const previous = undici.getGlobalDispatcher()
  183. const direct = new undici.Agent()
  184. undici.setGlobalDispatcher(direct)
  185. active = policy
  186. installed = undefined
  187. return async () => {
  188. undici.setGlobalDispatcher(previous)
  189. active = previousPolicy
  190. installed = previousInstalled
  191. restoreEnv?.()
  192. await direct.close()
  193. }
  194. }
  195. const restoreEnv = applyPolicyEnv(policy)
  196. const { getGlobalDispatcher, setGlobalDispatcher } = await import('undici')
  197. const previousDispatcher = getGlobalDispatcher()
  198. const previousInstalled = installed
  199. const agent = await createPolicyDispatcher(policy)
  200. setGlobalDispatcher(agent)
  201. active = policy
  202. installed = agent
  203. return async () => {
  204. setGlobalDispatcher(previousDispatcher)
  205. active = previousPolicy
  206. installed = previousInstalled
  207. restoreEnv()
  208. await agent.close()
  209. }
  210. }
  211. /**
  212. * The proxy environment a spawned child needs.
  213. *
  214. * A child inherits the parent environment, which this process rewrote to its own resolved policy.
  215. * Handing that normalization straight through would replace values the user set for other tools, so
  216. * each proxy name the user exported is restored to what they wrote: a SOCKS proxy `curl` uses is
  217. * not swapped for the HTTP one this package fell back to for that scheme.
  218. *
  219. * A scheme the user named in neither casing carries the resolved value instead of being removed.
  220. * Without that the child's routing silently diverges from its parent's: `NODE_USE_ENV_PROXY` does
  221. * not read `ALL_PROXY`, so a child of a parent that resolved its proxy from that name would connect
  222. * directly while the parent proxies.
  223. *
  224. * The bypass list is always the resolved one. It only ever adds the loopback entries to what
  225. * the user wrote, so nothing is lost, and the child stops sending its own localhost traffic to a
  226. * proxy that cannot route it.
  227. *
  228. * The flag reaches only Node 22.21+ and 24+; an older runtime keeps that child direct. Such a child
  229. * also matches bypass entries with Node's own `NO_PROXY` rules, which differ from this package's in
  230. * their separators and IPv4-range support. Non-Node children (curl, git, pnpm) ignore the flag and
  231. * read the variables themselves.
  232. *
  233. * The flag is withheld when a proxy value the child receives is one this package refused. Node
  234. * parses `HTTP_PROXY` and `HTTPS_PROXY` under that flag before running the program, and exits on a
  235. * scheme other than `http:` or `https:` — so a SOCKS value kept for `curl` would stop every Node
  236. * child from starting. Without the flag such a child connects directly, as this process already
  237. * reported for that scheme, and `curl` still reads the value it was kept for.
  238. *
  239. * A worker thread is deliberately NOT served here — see the workflow engine, which runs
  240. * model-authored scripts and must not receive a proxy URL that may carry credentials.
  241. *
  242. * @returns names to apply to the child environment, where `undefined` means remove, or an empty
  243. * object when no proxy is active.
  244. */
  245. export function proxyEnvironmentForChild(): Readonly<Record<string, string | undefined>> {
  246. const policy = active
  247. const inherited = inheritedProxyEnv
  248. if (policy === undefined || policy.source === 'none' || inherited === undefined) return {}
  249. const overlay: Record<string, string | undefined> = { NODE_USE_ENV_PROXY: '1' }
  250. for (const [field, names] of Object.entries(POLICY_ENV_NAMES)) {
  251. const resolved = policy[field as keyof typeof POLICY_ENV_NAMES]
  252. // Naming a scheme in either casing claims that scheme: the child then gets exactly what the
  253. // user wrote, in the casing they wrote it, rather than a value derived for this process.
  254. const named = field !== 'noProxy' && names.some(name => inherited[name] !== undefined)
  255. for (const name of names) overlay[name] = named ? inherited[name] : resolved
  256. }
  257. const parsedByNode = [...POLICY_ENV_NAMES.httpProxy, ...POLICY_ENV_NAMES.httpsProxy]
  258. if (parsedByNode.some(name => overlay[name] !== undefined && !isSupportedProxyUrl(overlay[name]))) {
  259. delete overlay.NODE_USE_ENV_PROXY
  260. }
  261. return overlay
  262. }
  263. /**
  264. * Resolve this process's proxy policy from `env` and install it.
  265. *
  266. * Resolution, reporting, and installation are one operation because no caller needs them apart: the
  267. * launcher does all three in sequence before the first plugin mounts, and a policy resolved but not
  268. * installed routes nothing.
  269. *
  270. * A value the environment supplies but this package cannot use is reported and skipped rather than
  271. * thrown: the variable may have been exported for another tool, and a proxy the harness cannot use
  272. * must not stop the agent from starting.
  273. *
  274. * @param env - the launch environment, whose own layering already prefers real variables over `.env` files.
  275. * @param report - receives one message per rejected value, in the order the values were considered.
  276. * @returns a disposer restoring the previous dispatcher, policy, and environment.
  277. */
  278. export async function installProxyFromEnvironment(
  279. env: EnvLookup,
  280. report: (message: string) => void,
  281. ): Promise<() => Promise<void>> {
  282. const { policy, diagnostics } = resolveProxyPolicy(env)
  283. for (const diagnostic of diagnostics) report(diagnostic.message)
  284. return await installGlobalProxy(policy)
  285. }
  286. /**
  287. * The environment overlay that removes every proxy name from a spawned child.
  288. *
  289. * A harness that replays a recorded session must reach its own fixture server, not the proxy a
  290. * developer or a CI runner exported; `undefined` is how a spawn removes a name it inherits.
  291. *
  292. * @returns one entry per proxy name, each `undefined`.
  293. */
  294. export function clearedProxyEnv(): Record<string, undefined> {
  295. return Object.fromEntries(PROXY_ENV_NAMES.map(name => [name, undefined]))
  296. }