Sfoglia il codice sorgente

fix(net): route by the policy, and give a child the routing its parent has

Second review pass on the outbound proxy work.

The installed dispatcher was undici's EnvHttpProxyAgent, which reuses the HTTP
proxy for `https:` whenever no HTTPS proxy is present. That is exactly the state
this package resolves after refusing a SOCKS or malformed URL the user named for
`https:`, so the scheme the diagnostic reported as direct was tunnelled anyway.
The dispatcher is now an Agent whose per-origin factory calls `proxyForUrl`, so
routing and `proxyForUrl` cannot disagree by parsing the same list twice.

`childProxyEnv` returned only the names the user exported, which left a child
Node direct whenever the proxy came from `ALL_PROXY` or from cordis.yml — Node's
`NODE_USE_ENV_PROXY` reads neither — and stripped the merged loopback bypass so
the child sent its own localhost traffic to the proxy. A scheme the user named
in either casing still reaches the child exactly as written; one they named in
neither now carries the resolved value, and the bypass list is always the merged
one.

A nested install (the plugin mounted over the launcher's policy) recorded the
outer policy's published values as the user's, then cleared the record on
disposal, so every later child inherited the normalization instead. The record
now belongs to the outermost install and is restored, not dropped.

`web_fetch` read the active policy twice — once to skip address pinning, again
inside the transport — so a disposal landing between the two reads produced an
unpinned direct connection to a host nothing validated. One snapshot now decides
both.

Also: the node:http proxy test asserted a route the engines range does not always
have, and the gate could not see undici bound through `await import('undici')`,
the form this repository actually uses.
Yichen Jiang 1 mese fa
parent
commit
cfc9b3bdef

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.md
-2026-08-27-outbound-proxy-policy.md: 0213ab056bb3c4eb417788b739daca0adc5be669
-2026-08-27-outbound-proxy-policy.zh.md: 312b9197acfb47edb51eb4413e15c57b492cdce6
+2026-08-27-outbound-proxy-policy.md: 5c017d8c36321877981383f083b739f7b5622ccb
+2026-08-27-outbound-proxy-policy.zh.md: 7efe83a75c4c04695dc422cb87365226e249be1e

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.md

@@ -20,7 +20,7 @@ Resolution reads the launcher's snapshot rather than `process.env`, which is wha
 
 **A new `packages/net/` group.** The package must depend on `undici` (Node exposes no `node:undici`), so it cannot join the zero-dependency `util/` group; and `boot`, `web`, `subprocess`, and `workflow` all consume it, so joining any one of them would invert three dependencies. It is deliberately not a capability seam: transport policy has one implementation and one answer per process, so there is nothing to swap.
 
-**The installed agent reads back what was resolved, not the raw environment.** `installGlobalProxy` publishes the policy into the proxy environment variables and then constructs `EnvHttpProxyAgent` with no options. Passing the fields explicitly instead would let undici fall back to reading the environment for any field left `undefined` — including a SOCKS or malformed value this package had already rejected, which `new ProxyAgent` would then throw on during boot. Publishing also normalizes what children inherit: the `ALL_PROXY` fallback lands as a concrete `HTTP_PROXY`, and the bypass list arrives with loopback merged.
+**The installed dispatcher routes by the policy, not by an environment it re-parses.** `installGlobalProxy` builds an `Agent` whose per-origin `factory` asks `proxyForUrl` where that origin goes, and returns a `ProxyAgent` or undici's own default client for it. undici's `EnvHttpProxyAgent` was the first choice and is wrong for this policy: when no `HTTPS_PROXY` is present it sets its HTTPS agent to the HTTP one, so a scheme this package keeps direct after refusing a SOCKS or malformed URL would still be tunnelled while the diagnostic said otherwise. Routing through the one predicate removes that class of divergence by construction rather than by test. Publishing the policy into the environment remains, but now serves only the readers that have no policy object: Node's `proxyEnv` option and every spawned child.
 
 This keeps `proxyForUrl()` and the dispatcher answering from one set of values. They must agree: if they disagreed about a URL, `web-fetch-http` would pin a connection the dispatcher meant to tunnel.
 
@@ -78,6 +78,6 @@ The suite is hermetic against the developer's own environment: `plugin.spec.ts`
 
 `verify-no-bare-dispatcher.spec.ts` proves the gate rejects the exact shape this package was introduced to fix, accepts `createDispatcher`, accepts an annotated exemption, and passes on the current tree.
 
-The egress suite carries the negative case for telemetry — restoring the SDK's own default agent reaches no proxy — so an upgrade cannot quietly un-proxy it. Its positive case branches on the runtime, because the exporter's agent needs Node 22.21+ or 24.5+. A parity suite pins the two bypass matchers (`proxyForUrl` and the installed `EnvHttpProxyAgent`) against each other over the documented `NO_PROXY` vocabulary.
+The egress suite carries the negative case for telemetry — restoring the SDK's own default agent reaches no proxy — so an upgrade cannot quietly un-proxy it. Its positive case branches on the runtime, because the exporter's agent needs Node 22.21+ or 24.5+. A parity suite checks `proxyForUrl` against where a real `fetch` actually went for every form in the documented `NO_PROXY` vocabulary; since the dispatcher routes by that same predicate, what it now catches is a form `bypassesProxy` reads differently from how the vocabulary documents it, and any future dispatcher that reintroduces a second matcher.
 
 No recorded-session snapshot changes: nothing here alters a model-visible input or product-user-visible transcript output.

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.zh.md

@@ -20,7 +20,7 @@ Node 内置的 `fetch` 会忽略 `HTTP_PROXY` 与 `HTTPS_PROXY`。开发者运
 
 **新增 `packages/net/` 分组。** 本包必须依赖 `undici`(Node 不暴露 `node:undici`),因此无法加入零依赖的 `util/` 组;而 `boot`、`web`、`subprocess` 与 `workflow` 都消费它,放进其中任何一组都会让另外三条依赖反向。它刻意不是能力接缝:传输策略每个进程只有一种实现、一个答案,没有可替换的对象。
 
-**已安装的 agent 读回的是解析结果,而非原始环境。** `installGlobalProxy` 把策略发布到代理环境变量中,再以无选项方式构造 `EnvHttpProxyAgent`。若改为显式传字段,undici 会对任何留空的字段回退去读环境——包括本包已经拒绝的 SOCKS 或畸形值,而 `new ProxyAgent` 会因此在启动期抛出。发布同时也规范化了子进程继承到的内容:`ALL_PROXY` 兜底落为具体的 `HTTP_PROXY`,绕过列表也已并入 loopback。
+**已安装的 dispatcher 按策略路由,而不是重新解析一遍环境。** `installGlobalProxy` 构造一个 `Agent`,其按 origin 调用的 `factory` 会询问 `proxyForUrl` 该 origin 的去向,并据此返回 `ProxyAgent` 或 undici 自带的默认客户端。undici 的 `EnvHttpProxyAgent` 曾是首选,但对这套策略是错的:没有 `HTTPS_PROXY` 时它会把 HTTPS agent 设为 HTTP agent,于是本包在拒绝某个 SOCKS 或畸形 URL 后本应保持直连的 scheme 仍会被隧道转发,而诊断却声称直连。让路由走同一个谓词,从构造上而非靠测试消除了这一类分歧。把策略发布到环境中的做法保留下来,但如今只服务那些拿不到策略对象的读者:Node 的 `proxyEnv` 选项,以及每个派生的子进程。
 
 这样 `proxyForUrl()` 与 dispatcher 就从同一组值给出答案。两者必须一致:一旦对某个 URL 产生分歧,`web-fetch-http` 就会把 dispatcher 本打算隧道转发的连接固定到某个地址上。
 
@@ -78,6 +78,6 @@ userland undici 能触及 Node 内置的 `fetch`,依赖于两者都会写入 l
 
 `verify-no-bare-dispatcher.spec.ts` 证明该门禁能拒掉本包所要修复的那种写法、接受 `createDispatcher`、接受带注释的豁免,并在当前代码树上通过。
 
-出网测试为遥测保留了负向用例——恢复 SDK 自带的默认 agent 就触及不到代理——因此升级无法悄悄把它变回直连。其正向用例按运行时分支,因为导出器的 agent 需要 Node 22.21+ 或 24.5+。另有一组一致性测试,用文档所述的 `NO_PROXY` 词汇把两套绕过匹配器(`proxyForUrl` 与已安装的 `EnvHttpProxyAgent`)相互固定。
+出网测试为遥测保留了负向用例——恢复 SDK 自带的默认 agent 就触及不到代理——因此升级无法悄悄把它变回直连。其正向用例按运行时分支,因为导出器的 agent 需要 Node 22.21+ 或 24.5+。另有一组一致性测试,对文档所述 `NO_PROXY` 词汇中的每种形态,把 `proxyForUrl` 的判断与真实 `fetch` 的实际去向相互核对;由于 dispatcher 正是按同一谓词路由,它现在能抓住的是 `bypassesProxy` 对某种形态的读法与词汇文档不一致,以及未来任何重新引入第二个匹配器的 dispatcher。
 
 无录制会话快照变更:本次改动不影响任何模型可见输入或产品用户可见的 transcript 输出。

+ 2 - 2
docs/user/guide/network-proxy.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/user/guide/network-proxy.md
-network-proxy.md: 33f84766967ccac501629e930eaaef1b1c24d8b9
-network-proxy.zh.md: 82aa63bb042d873c1fb16090a2a289dac033c4aa
+network-proxy.md: 98722f4562bb81b4b3d015770fb04de495e09e95
+network-proxy.zh.md: 07c8455f37f1022e4c4da0001d97b30e8dafc308

+ 1 - 1
docs/user/guide/network-proxy.md

@@ -45,7 +45,7 @@ You do not need to list `localhost` or `127.0.0.1`. DSH always bypasses loopback
 
 ## Limits worth knowing
 
-**SOCKS proxies are not supported.** A `socks5://` value is reported at startup and skipped, and DSH connects directly. Point the variables at your proxy application's HTTP port instead — most expose both, and the HTTP one is usually a neighbouring port number.
+**SOCKS proxies are not supported.** A `socks5://` value is reported at startup and skipped, and DSH connects directly for the scheme that named it — setting `HTTPS_PROXY=socks5://…` alongside a usable `HTTP_PROXY` leaves `https:` direct rather than borrowing the HTTP proxy. Point the variables at your proxy application's HTTP port instead — most expose both, and the HTTP one is usually a neighbouring port number.
 
 **`ALL_PROXY` alone is enough.** DSH falls back to it for both schemes, even though Node and curl differ on this. Setting `HTTPS_PROXY` explicitly is still clearer.
 

+ 1 - 1
docs/user/guide/network-proxy.zh.md

@@ -45,7 +45,7 @@ export NO_PROXY=internal.example.com,.corp.example.com,registry.local
 
 ## 值得知道的限制
 
-**不支持 SOCKS 代理。** `socks5://` 形式的值会在启动时被报告并跳过,DSH 转为直连。请把变量指向代理软件的 HTTP 端口——多数软件两者都提供,且 HTTP 端口通常就在相邻的端口号上。
+**不支持 SOCKS 代理。** `socks5://` 形式的值会在启动时被报告并跳过,指定它的那个 scheme 转为直连——把 `HTTPS_PROXY=socks5://…` 与一个可用的 `HTTP_PROXY` 一起设置时,`https:` 会保持直连,而不会去借用 HTTP 代理。请把变量指向代理软件的 HTTP 端口——多数软件两者都提供,且 HTTP 端口通常就在相邻的端口号上。
 
 **只设 `ALL_PROXY` 也够用。** DSH 会用它为两种协议兜底,尽管 Node 与 curl 在这一点上并不一致。显式设置 `HTTPS_PROXY` 仍然更清楚。
 

+ 2 - 2
packages/net/http-proxy/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/net/http-proxy/README.md
-README.md: 3c0cf6ff7deb915d11104ef5a7fb622dfb951385
-README.zh.md: c6d26b05c1d4fcb4603518a0725cad988efae65a
+README.md: 8d28134ba076b0184987995b44924327c26f8096
+README.zh.md: 0711bb39d5077996c29992fe6a89913eaf6ec31e

+ 2 - 2
packages/net/http-proxy/README.md

@@ -59,9 +59,9 @@ A proxy value the package cannot use — a SOCKS or PAC URL, an unparseable stri
 
 ### Design philosophy
 
-**One resolution, two readers.** `proxyForUrl()` and the installed dispatcher must never disagree about a URL, or `dsh-web-fetch-http` would pin a connection the dispatcher meant to tunnel. Installation therefore publishes the resolved policy into the proxy environment variables and constructs `EnvHttpProxyAgent` with no options, so the agent reads back exactly what was resolved rather than re-parsing the raw environment under slightly different rules.
+**One resolution, one matcher.** `proxyForUrl()` and the installed dispatcher must never disagree about a URL, or `dsh-web-fetch-http` would pin a connection the dispatcher meant to tunnel. The dispatcher is therefore an `Agent` whose per-origin `factory` calls `proxyForUrl()` itself, so there is no second parser to drift from the first. undici's `EnvHttpProxyAgent` cannot serve here: with no `HTTPS_PROXY` present it reuses the HTTP proxy for `https:`, which would tunnel a scheme this package keeps direct after refusing the URL the user named for it.
 
-**A child inherits what the user exported, not what this process resolved.** The published values exist for undici; `childProxyEnv()` restores each name to its original before a child is spawned. Normalizing them into a child would hand `curl` an `HTTPS_PROXY` invented from the HTTP one, or replace a SOCKS proxy the user set for `curl` with an HTTP proxy they never named for that scheme.
+**A child inherits the user's own values, and the resolved policy for what they left unset.** A scheme the user named in either casing reaches a child exactly as they wrote it, so a SOCKS proxy `curl` uses is never replaced by an HTTP one named for another scheme. A scheme they named in neither casing carries the resolved value instead, because otherwise the child's routing diverges from its parent's: Node's `NODE_USE_ENV_PROXY` reads neither `ALL_PROXY` nor a proxy that came from `cordis.yml`. The bypass list is always the resolved one — it only ever adds the loopback entries, so nothing the user wrote is lost. The cost of one routing answer for parent and child alike is that `curl` also sees the `https:` proxy this package derives from the HTTP one.
 
 ### Source map
 

+ 2 - 2
packages/net/http-proxy/README.zh.md

@@ -59,9 +59,9 @@ loopback 始终被绕过。否则 Harness 自己的 Web UI、Connection 传输
 
 ### 设计理念
 
-**一次解析,两个读者。** `proxyForUrl()` 与已安装的 dispatcher 绝不能对同一个 URL 给出不同答案,否则 `dsh-web-fetch-http` 会把 dispatcher 本打算隧道转发的连接固定到某个地址上。因此安装时会把解析出的策略发布到代理环境变量中,并以无选项方式构造 `EnvHttpProxyAgent`,让该 agent 读回的正是解析结果,而不是按略有差异的规则重新解析原始环境。
+**一次解析,一个匹配器。** `proxyForUrl()` 与已安装的 dispatcher 绝不能对同一个 URL 给出不同答案,否则 `dsh-web-fetch-http` 会把 dispatcher 本打算隧道转发的连接固定到某个地址上。因此该 dispatcher 是一个 `Agent`,其按 origin 调用的 `factory` 自身调用 `proxyForUrl()`,不存在可能与第一个解析器产生漂移的第二个解析器。undici 的 `EnvHttpProxyAgent` 在此无法胜任:没有 `HTTPS_PROXY` 时它让 `https:` 复用 HTTP 代理,于是本包在拒绝用户为该 scheme 指定的 URL 后本应保持直连的 scheme 仍会被隧道转发。
 
-**子进程继承的是用户导出的值,而非本进程解析出的值。** 写回环境只服务 undici;派生子进程前,`childProxyEnv()` 会把每个变量名还原为原值。把规范化结果塞给子进程,会让 `curl` 拿到一个由 HTTP 代理凭空推出的 `HTTPS_PROXY`,或让用户为 `curl` 设置的 SOCKS 代理被替换成他们从未为该协议指定过的 HTTP 代理。
+**子进程继承用户自己的值,以及用户未设置部分的解析结果。** 用户以任一大小写指定过的 scheme,会以他们书写的形式原样传给子进程,因此用户为 `curl` 设置的 SOCKS 代理绝不会被替换成为其他 scheme 指定的 HTTP 代理。两种大小写都未指定的 scheme 则携带解析值,否则子进程的路由会与父进程分歧:Node 的 `NODE_USE_ENV_PROXY` 既不读 `ALL_PROXY`,也不读来自 `cordis.yml` 的代理。绕过列表始终采用解析结果——它只会追加 loopback 条目,用户写下的内容不会丢失。让父子进程只有一个路由答案的代价是:`curl` 也会看到本包由 HTTP 代理推导出的 `https:` 代理。
 
 ### 源码地图
 

+ 1 - 1
packages/net/http-proxy/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-http-proxy",
   "description": "Process-wide outbound HTTP proxy policy for DeepSeek Harness: resolve it from the launch environment and install it as undici's global dispatcher",
-  "version": "0.1.1-rc.2",
+  "version": "0.1.2-alpha.1",
   "publishConfig": {
     "access": "public"
   },

+ 82 - 25
packages/net/http-proxy/src/install.ts

@@ -8,7 +8,7 @@
  * @module @deepseek-ai/dsh-http-proxy/install
  */
 
-import type { Agent, Dispatcher } from 'undici'
+import type { Agent, Dispatcher, Pool } from 'undici'
 import { DIRECT_POLICY, POLICY_ENV_NAMES, proxyForUrl, type ProxyPolicy } from './policy.ts'
 
 
@@ -16,10 +16,14 @@ import { DIRECT_POLICY, POLICY_ENV_NAMES, proxyForUrl, type ProxyPolicy } from '
 let active: ProxyPolicy | undefined
 
 /**
- * The proxy environment as it stood before the active policy was published, or `undefined` when none
- * is installed. A spawned child receives these, not the published ones: normalizing what this process
- * resolved into a child's environment would replace a value the user set for another tool — a SOCKS
- * proxy this package refuses but `curl` uses, or an `HTTPS_PROXY` the user never wrote at all.
+ * The proxy environment as the user exported it, or `undefined` when no policy is installed.
+ *
+ * Owned by the OUTERMOST install: a nested one — the plugin mounted over the launcher's policy —
+ * would otherwise record the outer policy's published values as if the user had written them, and
+ * hand every child a normalization the user never asked for.
+ *
+ * {@link childProxyEnv} keeps a value the user set rather than the one this process resolved from
+ * it, so a SOCKS proxy `curl` can use is not replaced by an HTTP proxy named for another scheme.
  */
 let inheritedProxyEnv: Readonly<Record<string, string | undefined>> | undefined
 
@@ -34,9 +38,10 @@ export function currentProxyPolicy(): ProxyPolicy | undefined {
 }
 
 /**
- * Publish a policy through the proxy environment variables so both undici and every spawned child
- * observe the one resolved answer — including the `ALL_PROXY` fallback and the merged loopback
- * bypass, neither of which they would derive on their own.
+ * Publish a policy through the proxy environment variables, which is how the consumers that read an
+ * environment rather than a policy object — `node:http`'s `proxyEnv` and every spawned child — see
+ * the one resolved answer, including the `ALL_PROXY` fallback and the merged loopback bypass that
+ * neither derives on its own. The global dispatcher does not read these; it routes by the policy.
  *
  * @param policy - the policy to publish.
  * @returns a function restoring every name this call changed.
@@ -49,7 +54,8 @@ function applyPolicyEnv(policy: ProxyPolicy): () => void {
   for (const names of Object.values(POLICY_ENV_NAMES)) {
     for (const name of names) previous.set(name, process.env[name])
   }
-  inheritedProxyEnv = Object.fromEntries(previous)
+  const previousInherited = inheritedProxyEnv
+  inheritedProxyEnv = previousInherited ?? Object.fromEntries(previous)
   for (const [field, names] of Object.entries(POLICY_ENV_NAMES)) {
     const value = policy[field as keyof typeof POLICY_ENV_NAMES]
     for (const name of names) {
@@ -62,16 +68,44 @@ function applyPolicyEnv(policy: ProxyPolicy): () => void {
       if (value === undefined) Reflect.deleteProperty(process.env, name)
       else process.env[name] = value
     }
-    inheritedProxyEnv = undefined
+    inheritedProxyEnv = previousInherited
   }
 }
 
+/**
+ * Build the global dispatcher for one policy.
+ *
+ * Routing runs through {@link proxyForUrl} per origin, so `fetch` and every caller that asks where a
+ * URL goes read the same answer from the same matcher. undici's `EnvHttpProxyAgent` cannot express
+ * this policy: with no `HTTPS_PROXY` present it reuses the HTTP proxy for `https:`, which would
+ * tunnel a scheme this package deliberately keeps direct after refusing the SOCKS or malformed URL
+ * the user named for it — the route and the diagnostic would then disagree.
+ *
+ * @param policy - the policy to route by; it must proxy at least one scheme.
+ * @returns the dispatcher to install, owning every per-origin agent its factory created.
+ */
+async function createPolicyDispatcher(policy: ProxyPolicy): Promise<Dispatcher> {
+  const { Agent, Pool, ProxyAgent } = await import('undici')
+  return new Agent({
+    factory(origin, options) {
+      // undici declares this parameter as `Object`, discarding the pool options it actually passes.
+      const passed = options as Pool.Options
+      const proxy = proxyForUrl(policy, new URL(origin.toString()))
+      if (proxy !== undefined) return new ProxyAgent({ ...passed, uri: proxy })
+      // What undici's own default factory builds for these options, which `factory` replaces
+      // wholesale. It reaches for a bare `Client` only at `connections: 1`, an option this
+      // dispatcher never carries: it is constructed with undici's defaults.
+      return new Pool(origin, passed)
+    },
+  })
+}
+
 /**
  * Route this process's outbound HTTP through `policy`.
  *
  * Installing replaces undici's global dispatcher, which is what Node's built-in `fetch` resolves, so
  * every caller that issues a plain `fetch()` is covered without knowing this package exists. A policy
- * that proxies nothing installs no dispatcher and leaves the environment untouched.
+ * that proxies nothing installs a direct dispatcher and leaves the environment untouched.
  *
  * Worker threads do not inherit the global dispatcher; each one calls this with the policy its host
  * passed through `workerData`.
@@ -105,11 +139,9 @@ export async function installGlobalProxy(policy: ProxyPolicy): Promise<() => Pro
     }
   }
   const restoreEnv = applyPolicyEnv(policy)
-  const { EnvHttpProxyAgent, getGlobalDispatcher, setGlobalDispatcher } = await import('undici')
+  const { getGlobalDispatcher, setGlobalDispatcher } = await import('undici')
   const previousDispatcher = getGlobalDispatcher()
-  // Constructed with no options on purpose: it reads the names applyPolicyEnv just wrote, so the
-  // agent and `proxyForUrl` answer from the same values instead of each parsing the raw environment.
-  const agent = new EnvHttpProxyAgent()
+  const agent = await createPolicyDispatcher(policy)
   setGlobalDispatcher(agent)
   active = policy
   return async () => {
@@ -132,11 +164,19 @@ export async function installGlobalProxy(policy: ProxyPolicy): Promise<() => Pro
  * @param options - agent options; applied to whichever agent the policy selects. On the proxied path
  *   `connect` governs the connection to the PROXY, not to the origin, so a lookup meant to pin an
  *   origin address belongs only on a URL the policy bypasses.
+ * @param policy - the policy to route by, defaulting to the active one. A caller that already
+ *   branched on {@link proxyForUrl} MUST pass the same policy object it branched on: reading the
+ *   active policy again would let a mount or disposal between the two reads return a direct agent
+ *   for a URL the caller cleared as proxied, dropping the address checks that branch skipped.
  * @returns a dispatcher the caller owns and must close once the response body is consumed.
  */
-export async function createDispatcher(url: URL, options: Agent.Options = {}): Promise<Dispatcher> {
+export async function createDispatcher(
+  url: URL,
+  options: Agent.Options = {},
+  policy: ProxyPolicy = active ?? DIRECT_POLICY,
+): Promise<Dispatcher> {
   const undici = await import('undici')
-  const proxy = proxyForUrl(active ?? DIRECT_POLICY, url)
+  const proxy = proxyForUrl(policy, url)
   if (proxy === undefined) return new undici.Agent(options)
   return new undici.ProxyAgent({ ...options, uri: proxy })
 }
@@ -182,12 +222,19 @@ export function proxyUrlFor(url: URL): string | undefined {
 /**
  * The proxy environment a spawned child needs.
  *
- * A child inherits the parent environment, which this process rewrote to its own resolved policy so
- * undici reads back exactly what was resolved. That normalization must not reach the child: it would
- * hand `curl` an `HTTPS_PROXY` this package invented from the HTTP one, or replace a SOCKS proxy the
- * user set for `curl` with an HTTP proxy they never named for that scheme. The result therefore
- * restores each name to the value the user exported — `undefined` for a name they never set — and
- * adds only the flag that makes a child Node honor them.
+ * A child inherits the parent environment, which this process rewrote to its own resolved policy.
+ * Handing that normalization straight through would replace values the user set for other tools, so
+ * each proxy name the user exported is restored to what they wrote: a SOCKS proxy `curl` uses is
+ * not swapped for the HTTP one this package fell back to for that scheme.
+ *
+ * A scheme the user named in neither casing carries the resolved value instead of being removed.
+ * Without that the child's routing silently diverges from its parent's: `NODE_USE_ENV_PROXY` reads
+ * neither `ALL_PROXY` nor a proxy that came from `cordis.yml`, so the child would connect directly
+ * while the parent proxies.
+ *
+ * The bypass list is always the resolved one. It only ever adds the loopback entries to what
+ * the user wrote, so nothing is lost, and the child stops sending its own localhost traffic to a
+ * proxy that cannot route it.
  *
  * The flag reaches only Node 22.21+ and 24+; an older runtime keeps that child direct. Such a child
  * also matches bypass entries with Node's own `NO_PROXY` rules, which differ from this package's in
@@ -201,6 +248,16 @@ export function proxyUrlFor(url: URL): string | undefined {
  *   object when no proxy is active.
  */
 export function childProxyEnv(): Readonly<Record<string, string | undefined>> {
-  if (active === undefined || active.source === 'none' || inheritedProxyEnv === undefined) return {}
-  return { ...inheritedProxyEnv, NODE_USE_ENV_PROXY: '1' }
+  const policy = active
+  const inherited = inheritedProxyEnv
+  if (policy === undefined || policy.source === 'none' || inherited === undefined) return {}
+  const overlay: Record<string, string | undefined> = { NODE_USE_ENV_PROXY: '1' }
+  for (const [field, names] of Object.entries(POLICY_ENV_NAMES)) {
+    const resolved = policy[field as keyof typeof POLICY_ENV_NAMES]
+    // Naming a scheme in either casing claims that scheme: the child then gets exactly what the
+    // user wrote, in the casing they wrote it, rather than a value derived for this process.
+    const named = field !== 'noProxy' && names.some(name => inherited[name] !== undefined)
+    for (const name of names) overlay[name] = named ? inherited[name] : resolved
+  }
+  return overlay
 }

+ 133 - 2
packages/net/http-proxy/tests/install.spec.ts

@@ -37,6 +37,10 @@ beforeAll(async () => {
     response.writeHead(200, { 'content-type': 'text/plain' })
     response.end('VIA-PROXY')
   })
+  proxy.on('connect', (request, socket) => {
+    proxied.push(`CONNECT ${request.url ?? ''}`)
+    socket.end()
+  })
   origin = createServer((_request, response) => { response.end('DIRECT') })
   const [proxyAddress, originAddress] = await Promise.all([listen(proxy), listen(origin)])
   proxyUrl = `http://127.0.0.1:${String(proxyAddress.port)}`
@@ -51,6 +55,9 @@ afterEach(() => {
   proxied = []
 })
 
+/** A second proxy URL, never dialed: it only has to differ from {@link proxyUrl} in an assertion. */
+const nestedUrl = 'http://127.0.0.1:9'
+
 /** A policy proxying everything, since the resolved default always bypasses the loopback these tests use. */
 function proxyAll(noProxy = ''): ProxyPolicy {
   return { httpProxy: proxyUrl, httpsProxy: proxyUrl, noProxy, source: 'env' }
@@ -129,6 +136,21 @@ describe('installGlobalProxy', () => {
     }
     expect(currentProxyPolicy()).toBeUndefined()
   })
+  it('keeps a scheme direct when the policy refused the proxy the user named for it', async () => {
+    // What `HTTPS_PROXY=socks5://…` plus `HTTP_PROXY=http://p` resolves to: http proxied, https
+    // direct. undici's own EnvHttpProxyAgent cannot express this — with no HTTPS proxy present it
+    // reuses the HTTP one, tunnelling the scheme the diagnostic told the user stayed direct.
+    const dispose = await installGlobalProxy({ httpProxy: proxyUrl, noProxy: '', source: 'env' })
+    try {
+      await expect(fetch('https://refused-scheme.invalid/')).rejects.toThrow()
+      expect(proxied).toEqual([])
+      // The same policy still tunnels http, so the empty expectation above is not vacuous.
+      await expect((await fetch(originUrl)).text()).resolves.toBe('VIA-PROXY')
+      expect(proxied).toEqual([`GET ${originUrl}`])
+    } finally {
+      await dispose()
+    }
+  })
 })
 
 describe('createDispatcher', () => {
@@ -168,6 +190,23 @@ describe('createDispatcher', () => {
       await dispatcher.close()
     }
   })
+
+  it('routes by the policy it was handed, not one replaced after the caller branched', async () => {
+    const dispose = await installGlobalProxy(proxyAll())
+    const branched = currentProxyPolicy()
+    expect(branched).toBeDefined()
+    // The caller has already decided this hop is proxied and skipped its address checks. Unmounting
+    // the plugin here is what a hot reload does mid-request; reading the active policy again would
+    // hand back a direct agent and connect to an origin nothing validated.
+    await dispose()
+    const dispatcher = await createDispatcher(new URL(originUrl), {}, branched)
+    try {
+      const undici = await import('undici')
+      await expect((await undici.fetch(originUrl, { dispatcher })).text()).resolves.toBe('VIA-PROXY')
+    } finally {
+      await dispatcher.close()
+    }
+  })
 })
 
 describe('childProxyEnv', () => {
@@ -199,7 +238,10 @@ describe('childProxyEnv', () => {
       expect(child.https_proxy).toBe('socks5://127.0.0.1:1080')
       expect(child.HTTPS_PROXY).toBeUndefined()
       expect(child.HTTP_PROXY).toBe(proxyUrl)
-      expect(child.no_proxy).toBeUndefined()
+      // The bypass list is the resolved one even though the user set none: it only adds entries,
+      // and without it the child sends its own loopback traffic to a proxy that cannot route it.
+      expect(child.no_proxy).toBe('example.com')
+      expect(child.NO_PROXY).toBe('example.com')
       expect(child.NODE_USE_ENV_PROXY).toBe('1')
     } finally {
       await dispose()
@@ -209,6 +251,83 @@ describe('childProxyEnv', () => {
       }
     }
   })
+  it('fills a scheme the user named in neither casing, so a child Node is not left direct', async () => {
+    const saved = Object.fromEntries(PROXY_ENV_NAMES.map(name => [name, process.env[name]]))
+    for (const name of PROXY_ENV_NAMES) Reflect.deleteProperty(process.env, name)
+    // The user exported only ALL_PROXY. `NODE_USE_ENV_PROXY` never reads that name, so a child
+    // Node would connect directly while this process proxies — the seam this fill closes.
+    process.env.ALL_PROXY = proxyUrl
+    const dispose = await installGlobalProxy(proxyAll('example.com'))
+    try {
+      const child = childProxyEnv()
+      expect(child.HTTP_PROXY).toBe(proxyUrl)
+      expect(child.http_proxy).toBe(proxyUrl)
+      expect(child.HTTPS_PROXY).toBe(proxyUrl)
+      expect(child.https_proxy).toBe(proxyUrl)
+    } finally {
+      await dispose()
+      for (const [name, value] of Object.entries(saved)) {
+        if (value === undefined) Reflect.deleteProperty(process.env, name)
+        else process.env[name] = value
+      }
+    }
+  })
+
+  it('propagates a proxy that only a composition declared', async () => {
+    const saved = Object.fromEntries(PROXY_ENV_NAMES.map(name => [name, process.env[name]]))
+    for (const name of PROXY_ENV_NAMES) Reflect.deleteProperty(process.env, name)
+    const dispose = await installGlobalProxy({ ...proxyAll('example.com'), source: 'config' })
+    try {
+      // Nothing was exported, so every name carries the configured policy rather than being removed.
+      expect(childProxyEnv()).toEqual({
+        NODE_USE_ENV_PROXY: '1',
+        http_proxy: proxyUrl,
+        HTTP_PROXY: proxyUrl,
+        https_proxy: proxyUrl,
+        HTTPS_PROXY: proxyUrl,
+        no_proxy: 'example.com',
+        NO_PROXY: 'example.com',
+      })
+    } finally {
+      await dispose()
+      for (const [name, value] of Object.entries(saved)) {
+        if (value === undefined) Reflect.deleteProperty(process.env, name)
+        else process.env[name] = value
+      }
+    }
+  })
+
+  it('keeps the outermost install\'s record of what the user exported across a nested one', async () => {
+    const saved = Object.fromEntries(PROXY_ENV_NAMES.map(name => [name, process.env[name]]))
+    for (const name of PROXY_ENV_NAMES) Reflect.deleteProperty(process.env, name)
+    // The user exported one name, in one casing.
+    process.env.HTTP_PROXY = proxyUrl
+    const nested: ProxyPolicy = { httpProxy: nestedUrl, httpsProxy: nestedUrl, noProxy: '', source: 'env' }
+    // The launcher installs first; mounting the plugin installs a second policy over it.
+    const disposeOuter = await installGlobalProxy(proxyAll('example.com'))
+    try {
+      const disposeInner = await installGlobalProxy(nested)
+      try {
+        const child = childProxyEnv()
+        // Recording the outer install's published environment as the user's would show the
+        // lowercase name it wrote and the outer proxy for a scheme the user never named.
+        expect(child.http_proxy).toBeUndefined()
+        expect(child.https_proxy).toBe(nestedUrl)
+      } finally {
+        await disposeInner()
+      }
+      // Unmounting the inner install must leave the outer one still able to describe that
+      // environment; clearing the record instead sends every later child the normalized values.
+      expect(childProxyEnv().HTTP_PROXY).toBe(proxyUrl)
+      expect(childProxyEnv().http_proxy).toBeUndefined()
+    } finally {
+      await disposeOuter()
+      for (const [name, value] of Object.entries(saved)) {
+        if (value === undefined) Reflect.deleteProperty(process.env, name)
+        else process.env[name] = value
+      }
+    }
+  })
 })
 
 describe('installGlobalProxy over an existing installation', () => {
@@ -250,6 +369,16 @@ describe('applyPolicyEnv restoration', () => {
 })
 
 describe('createNodeHttpAgent', () => {
+  /**
+   * Whether this runtime's `http.Agent` honors `proxyEnv`, the option this agent routes through.
+   * Added in Node 24.5 and backported to 22.21; the engines range admits 22.19, 22.20, and
+   * 24.0–24.4, where the option is ignored and the request stays direct.
+   */
+  function supportsAgentProxyEnv(): boolean {
+    const [major = 0, minor = 0] = process.versions.node.split('.').map(Number)
+    return (major === 24 && minor >= 5) || major > 24 || (major === 22 && minor >= 21)
+  }
+
   /** Drive a real `node:http` request, which the global dispatcher never reaches. */
   function get(target: string, agent: http.Agent): Promise<string> {
     return new Promise((resolve) => {
@@ -265,7 +394,9 @@ describe('createNodeHttpAgent', () => {
     const dispose = await installGlobalProxy(proxyAll())
     const agent = await createNodeHttpAgent('http:')
     try {
-      await expect(get(originUrl, agent)).resolves.toBe('VIA-PROXY')
+      // An older runtime ignores the unknown `proxyEnv` option and connects directly — the seam
+      // this agent's documentation names, asserted rather than left to fail the suite there.
+      await expect(get(originUrl, agent)).resolves.toBe(supportsAgentProxyEnv() ? 'VIA-PROXY' : 'DIRECT')
     } finally {
       agent.destroy()
       await dispose()

+ 13 - 6
packages/net/http-proxy/tests/matcher-parity.spec.ts

@@ -4,10 +4,15 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'
 import { installGlobalProxy, proxyForUrl, type ProxyPolicy } from '../src/index.ts'
 
 /**
- * `proxyForUrl` and the installed `EnvHttpProxyAgent` are two matchers over one bypass list. They are
- * fed the same values, but their parsers are independent: a form they judge differently would route a
- * plain `fetch` one way and `web_fetch` the other. These cases pin the forms in the documented
- * vocabulary against the agent's real behavior.
+ * `proxyForUrl` answers where a URL goes; these cases check that answer against where a real `fetch`
+ * actually went, for every form in the documented bypass vocabulary. The installed dispatcher routes
+ * by this same predicate, so the two cannot drift apart by parsing the list twice — what a case can
+ * still catch is `bypassesProxy` reading a form differently from how the vocabulary documents it,
+ * and any future dispatcher that reintroduces a second matcher.
+ *
+ * The remaining second matcher is Node's, on the `node:http` path: `createNodeHttpAgent` hands it
+ * the published `NO_PROXY` and Node applies its own rules, which differ in separators and IPv4-range
+ * support. That seam is documented rather than asserted here, because the difference is real.
  */
 const CASES: readonly { readonly noProxy: string; readonly path: string; readonly bypassed: boolean }[] = [
   { noProxy: '', path: '/plain', bypassed: false },
@@ -51,8 +56,10 @@ describe('bypass matcher parity', () => {
     const url = new URL(`http://probe.invalid${path}`)
     const dispose = await installGlobalProxy(policy(noProxy))
     try {
-      // A bypassed target has no route here, so the fetch fails; a proxied one reaches the recorder.
-      await fetch(url).then(response => response.text()).catch(() => undefined)
+      // A bypassed target has no route here, so the fetch fails; a proxied one reaches the recorder
+      // in milliseconds. The deadline bounds the failing path, whose DNS miss is otherwise as slow
+      // as the machine's resolver decides — and only that path, so it cannot mask a proxied hop.
+      await fetch(url, { signal: AbortSignal.timeout(1500) }).then(response => response.text()).catch(() => undefined)
       const agentProxied = seen.length > 0
       expect({ ours: proxyForUrl(policy(noProxy), url) !== undefined, agent: agentProxied })
         .toEqual({ ours: !bypassed, agent: !bypassed })

+ 50 - 4
packages/subprocess/subprocess/tests/egress.spec.ts

@@ -97,17 +97,63 @@ describe('child process egress', () => {
     else expect(seen).toEqual([])
   })
 
-  it('does not invent a proxy name the user never exported', async () => {
+  it('a child Node reaches a proxy the user gave only as ALL_PROXY', async () => {
+    process.env.ALL_PROXY = proxyUrl
+    const { policy } = resolveProxyPolicy(
+      createLaunchEnvironmentSnapshot([{ source: 'process', values: { ALL_PROXY: proxyUrl } }]),
+    )
+    const dispose = await installGlobalProxy(policy)
+    let childEnv: Record<string, string> = {}
+    try {
+      childEnv = scrubbedParentEnv()
+      await childFetch('http://all-proxy-probe.invalid/x', childEnv)
+    } finally {
+      await dispose()
+    }
+    // `NODE_USE_ENV_PROXY` reads neither casing of `ALL_PROXY`, so a child handed only the user's
+    // own names connects directly while this process proxies. The resolved value fills that gap.
+    expect(childEnv.ALL_PROXY).toBe(proxyUrl)
+    expect(childEnv.HTTP_PROXY).toBe(proxyUrl)
+    if (supportsEnvProxy()) expect(seen.join('|')).toContain('all-proxy-probe.invalid')
+    else expect(seen).toEqual([])
+  })
+
+  it('keeps a proxy the user set for another tool, and fills only a scheme they never named', async () => {
+    // A SOCKS proxy this package refuses but `curl` uses, alongside an HTTP proxy it accepts.
+    process.env.HTTP_PROXY = proxyUrl
+    process.env.https_proxy = 'socks5://127.0.0.1:1080'
+    const { policy } = resolveProxyPolicy(
+      createLaunchEnvironmentSnapshot([{
+        source: 'process',
+        values: { HTTP_PROXY: proxyUrl, https_proxy: 'socks5://127.0.0.1:1080' },
+      }]),
+    )
+    const dispose = await installGlobalProxy(policy)
+    try {
+      const child = scrubbedParentEnv()
+      // The user named `https:`, so their value survives in the casing they wrote it, even though
+      // this process refused it and routes that scheme directly.
+      expect(child.https_proxy).toBe('socks5://127.0.0.1:1080')
+      expect(child.HTTPS_PROXY).toBeUndefined()
+      // The bypass list is always the resolved one; it only ever adds the loopback entries.
+      expect(child.NO_PROXY).toBe(policy.noProxy)
+    } finally {
+      await dispose()
+    }
+  })
+
+  it('gives a child the same routing as its parent for a scheme the user never named', async () => {
     process.env.HTTP_PROXY = proxyUrl
     const { policy } = resolveProxyPolicy(
       createLaunchEnvironmentSnapshot([{ source: 'process', values: { HTTP_PROXY: proxyUrl } }]),
     )
     const dispose = await installGlobalProxy(policy)
     try {
-      // This process resolved an HTTPS proxy by falling back to the HTTP one; a child must not see
-      // a name the user never set, because `curl` performs no such fallback of its own.
+      // This process routes `https:` through the HTTP proxy, matching undici. A child that did not
+      // see the name would diverge from its parent; `curl`, which performs no such fallback of its
+      // own, gains it here — the deliberate cost of one routing answer for parent and child alike.
       expect(process.env.HTTPS_PROXY).toBe(proxyUrl)
-      expect(scrubbedParentEnv().HTTPS_PROXY).toBeUndefined()
+      expect(scrubbedParentEnv().HTTPS_PROXY).toBe(proxyUrl)
     } finally {
       await dispose()
     }

+ 11 - 4
packages/web/web-fetch-http/src/network.ts

@@ -10,7 +10,7 @@ import { lookup as systemLookup } from 'node:dns/promises'
 import type { LookupAddress, LookupOptions } from 'node:dns'
 import { isIP } from 'node:net'
 import type { Agent, Response } from 'undici'
-import { createDispatcher } from '@deepseek-ai/dsh-http-proxy'
+import { createDispatcher, type ProxyPolicy } from '@deepseek-ai/dsh-http-proxy'
 import ipaddr from 'ipaddr.js'
 import { WebError } from '@deepseek-ai/dsh-web'
 
@@ -166,6 +166,7 @@ function embeddedIpv4Address(bytes: readonly number[], prefixLength: Nat64Prefix
  * @param addresses - public addresses returned by {@link resolvePublicAddresses}.
  * @param headers - request headers.
  * @param signal - request and body-read cancellation signal.
+ * @param policy - the proxy policy the caller already branched on; omitted, the active one is read.
  * @returns a response plus the dispatcher disposer its consumer must call.
  */
 export async function requestPinned(
@@ -173,11 +174,12 @@ export async function requestPinned(
   addresses: readonly PublicAddress[],
   headers: Record<string, string>,
   signal: AbortSignal,
+  policy?: ProxyPolicy,
 ): Promise<PinnedResponse> {
   return await requestWith(url, headers, signal, {
     autoSelectFamily: true,
     connect: { lookup: createPinnedLookup(addresses) },
-  })
+  }, policy)
 }
 
 /**
@@ -191,14 +193,17 @@ export async function requestPinned(
  * @param url - validated HTTP(S) URL the active policy routes through a proxy.
  * @param headers - request headers.
  * @param signal - request and body-read cancellation signal.
+ * @param policy - the proxy policy the caller branched on; passing it keeps this hop on the route
+ *   that decision assumed even if the policy is replaced while the request is in flight.
  * @returns a response plus the dispatcher disposer its consumer must call.
  */
 export async function requestProxied(
   url: URL,
   headers: Record<string, string>,
   signal: AbortSignal,
+  policy?: ProxyPolicy,
 ): Promise<PinnedResponse> {
-  return await requestWith(url, headers, signal, {})
+  return await requestWith(url, headers, signal, {}, policy)
 }
 
 /**
@@ -211,6 +216,7 @@ export async function requestProxied(
  * @param headers - request headers.
  * @param signal - request and body-read cancellation signal.
  * @param options - agent options applied to whichever agent the policy selects.
+ * @param policy - the policy to route by, defaulting to the active one.
  * @returns a response plus the dispatcher disposer its consumer must call.
  */
 async function requestWith(
@@ -218,12 +224,13 @@ async function requestWith(
   headers: Record<string, string>,
   signal: AbortSignal,
   options: Agent.Options,
+  policy?: ProxyPolicy,
 ): Promise<PinnedResponse> {
   // Keep the Node-only transport out of browser-worker startup. The preview
   // can load the provider and fail loud at its DNS stub without evaluating
   // Undici; a real request on Node resolves this maintained dependency here.
   const { fetch } = await import('undici')
-  const dispatcher = await createDispatcher(url, options)
+  const dispatcher = await createDispatcher(url, options, policy)
   try {
     // proxy-exempt: the dispatcher is createDispatcher's, which already applied the active policy.
     const response = await fetch(url, { method: 'GET', redirect: 'manual', headers, signal, dispatcher })

+ 8 - 3
packages/web/web-fetch-http/src/provider.ts

@@ -124,11 +124,16 @@ export class HttpFetchProvider implements WebFetchProvider {
       // DNS, so there is no local address to validate, and pinning one would connect directly and
       // bypass the proxy. A hop the policy bypasses — every loopback and every `NO_PROXY` entry —
       // still takes the resolved-and-pinned path unchanged.
-      if (proxyForUrl(currentProxyPolicy() ?? DIRECT_POLICY, url) !== undefined) {
-        return await publicHttpNetwork.requestProxied(url, headers, signal)
+      //
+      // One snapshot decides both the branch and the dispatcher. Reading the active policy again
+      // inside the transport would let a mount or disposal land between the two reads and return a
+      // direct, unpinned agent for a URL this branch cleared as proxied.
+      const policy = currentProxyPolicy() ?? DIRECT_POLICY
+      if (proxyForUrl(policy, url) !== undefined) {
+        return await publicHttpNetwork.requestProxied(url, headers, signal, policy)
       }
       const addresses = await this.resolveAddresses(url.hostname, signal)
-      return await publicHttpNetwork.request(url, addresses, headers, signal)
+      return await publicHttpNetwork.request(url, addresses, headers, signal, policy)
     } catch (error: unknown) {
       if (error instanceof WebError) throw error
       throw translateAbortOrNetwork(error, signal)

+ 37 - 0
scripts/verify-no-bare-dispatcher.spec.ts

@@ -59,6 +59,43 @@ describe('bare dispatcher check', () => {
     `)).toEqual(['constructs an undici agent'])
   })
 
+  it('rejects a destructured dynamic import, the form this repository loads undici with', () => {
+    expect(reasons(`
+      const { Agent } = await import('undici')
+      const agent = new Agent({})
+    `)).toEqual(['constructs an undici agent'])
+  })
+
+  it('rejects a renamed binding from a dynamic import', () => {
+    expect(reasons(`
+      const { ProxyAgent: Tunnel } = await import('undici')
+      const agent = new Tunnel({ uri })
+    `)).toEqual(['constructs an undici agent'])
+  })
+
+  it('rejects a namespace bound by a dynamic import', () => {
+    expect(reasons(`
+      const undici = await import('undici')
+      const agent = new undici.Agent({})
+    `)).toEqual(['constructs an undici agent'])
+  })
+
+  it('finds a dynamic import nested inside a function, not only at the top level', () => {
+    expect(reasons(`
+      export async function requestWith(url) {
+        const { Agent } = await import('undici')
+        return new Agent({})
+      }
+    `)).toEqual(['constructs an undici agent'])
+  })
+
+  it('accepts a dynamic import of an unrelated module that exports Agent', () => {
+    expect(reasons(`
+      const { Agent } = await import('./our-own-agent.ts')
+      const agent = new Agent({})
+    `)).toEqual([])
+  })
+
   it('accepts an unrelated class that happens to be named Agent', () => {
     expect(reasons(`
       import { Agent } from './our-own-agent.ts'

+ 56 - 15
scripts/verify-no-bare-dispatcher.ts

@@ -11,7 +11,9 @@
  *
  * Discovery is syntax-aware, as `scripts/AGENTS.md` requires: a line-wise regex misses the
  * `{ dispatcher }` shorthand and a `new Alias(...)` whose import renamed `Agent`, and both bypass the
- * proxy exactly as the spelled-out forms do.
+ * proxy exactly as the spelled-out forms do. Bindings from a dynamic `await import('undici')` count
+ * the same as static ones — that is how this repository loads undici wherever the transport must
+ * stay out of a browser-worker's startup graph.
  */
 
 import { globSync, readFileSync } from 'node:fs'
@@ -52,8 +54,42 @@ export interface DispatcherViolation {
 }
 
 /**
- * Local names bound to an undici agent class, including `import { Agent as X }` renames and a
- * namespace import's own name so `undici.Agent` is recognised too.
+ * Whether an expression is `import('undici')`, with or without `await`. The dynamic form is how
+ * this repository loads undici everywhere the transport must stay out of a browser-worker's startup
+ * graph, so a gate blind to it would miss the repository's own idiom.
+ *
+ * @param expression - a variable declaration's initializer, when it has one.
+ * @returns true when evaluating it yields the undici module.
+ */
+function isUndiciImport(expression: ts.Expression | undefined): boolean {
+  if (expression === undefined) return false
+  const call = ts.isAwaitExpression(expression) ? expression.expression : expression
+  if (!ts.isCallExpression(call) || call.expression.kind !== ts.SyntaxKind.ImportKeyword) return false
+  const [specifier] = call.arguments
+  return specifier !== undefined && ts.isStringLiteral(specifier) && specifier.text === AGENT_MODULE
+}
+
+/**
+ * Record the names one destructured dynamic import binds to an agent class.
+ *
+ * @param pattern - the binding pattern of `const { Agent, ProxyAgent: P } = await import('undici')`.
+ * @param agents - collector the local names are added to.
+ */
+function collectDestructuredAgents(pattern: ts.ObjectBindingPattern, agents: Set<string>): void {
+  for (const element of pattern.elements) {
+    if (!ts.isIdentifier(element.name)) continue
+    const property = element.propertyName
+    const imported = property === undefined
+      ? element.name.text
+      : ts.isIdentifier(property) || ts.isStringLiteral(property) ? property.text : undefined
+    if (imported !== undefined && AGENT_EXPORTS.has(imported)) agents.add(element.name.text)
+  }
+}
+
+/**
+ * Local names bound to an undici agent class, including `import { Agent as X }` renames, the
+ * destructured and namespace forms of a dynamic `import('undici')`, and a namespace import's own
+ * name so `undici.Agent` is recognised too.
  *
  * @param source - the parsed file.
  * @returns agent identifiers and namespace identifiers bound in this file.
@@ -61,20 +97,25 @@ export interface DispatcherViolation {
 function agentBindings(source: ts.SourceFile): { agents: Set<string>; namespaces: Set<string> } {
   const agents = new Set<string>()
   const namespaces = new Set<string>()
-  for (const statement of source.statements) {
-    if (!ts.isImportDeclaration(statement)) continue
-    if (!ts.isStringLiteral(statement.moduleSpecifier) || statement.moduleSpecifier.text !== AGENT_MODULE) continue
-    const bindings = statement.importClause?.namedBindings
-    if (bindings === undefined) continue
-    if (ts.isNamespaceImport(bindings)) {
-      namespaces.add(bindings.name.text)
-      continue
-    }
-    for (const element of bindings.elements) {
-      const imported = (element.propertyName ?? element.name).text
-      if (AGENT_EXPORTS.has(imported)) agents.add(element.name.text)
+  const visit = (node: ts.Node): void => {
+    if (ts.isImportDeclaration(node)) {
+      if (ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text === AGENT_MODULE) {
+        const bindings = node.importClause?.namedBindings
+        if (bindings !== undefined && ts.isNamespaceImport(bindings)) namespaces.add(bindings.name.text)
+        else if (bindings !== undefined) {
+          for (const element of bindings.elements) {
+            const imported = (element.propertyName ?? element.name).text
+            if (AGENT_EXPORTS.has(imported)) agents.add(element.name.text)
+          }
+        }
+      }
+    } else if (ts.isVariableDeclaration(node) && isUndiciImport(node.initializer)) {
+      if (ts.isIdentifier(node.name)) namespaces.add(node.name.text)
+      else if (ts.isObjectBindingPattern(node.name)) collectDestructuredAgents(node.name, agents)
     }
+    ts.forEachChild(node, visit)
   }
+  ts.forEachChild(source, visit)
   return { agents, namespaces }
 }