Ver código fonte

refactor(http-proxy): converge the proxy API on four functions

The package exported six functions, four of them shaped by one SDK's
transport each: a dispatcher factory, a `node:http` agent factory, a
proxy-URL lookup, and a policy accessor. Review asked whether the call
sites could converge instead of the package growing an export per SDK.

They could, and each removal took a whole shape with it:

- The OTLP exporter moves to the SDK's `fetch` delegate, retiring
  `createNodeHttpAgent`. Its Node-version floor goes too: `proxyEnv` on
  an `http.Agent` needs 22.21 or 24.5, inside the engines range, so
  telemetry was direct on 22.19, 22.20, and 24.0-24.4. The cost is
  `compression`, a Node-transport option; the plugin now refuses it,
  `keepAlive`, and `httpAgentOptions` at load instead of ignoring them.
- `web-fetch-http` builds its own address-pinning agent under an
  annotated `proxy-exempt:` exemption, retiring `createDispatcher`.
  Pinning is per-request state a process-wide dispatcher cannot hold.
- E2B reads `route.proxy`, retiring `proxyUrlFor`.

What remains is `installProxyFromEnvironment`, `proxyRouteFor`,
`proxyEnvironmentForChild`, and `clearedProxyEnv` — one per way a caller
can need the policy. Installation absorbs resolution and diagnostic
reporting, which no caller needed apart.

`proxyRouteFor` also closes a defect the old accessor made expressible:
`web-fetch-http` read the policy to decide whether to pin, then read it
again to build a transport, so an unmount between the two returned a
direct, unpinned agent for a URL the first read had cleared as proxied.
A route carries the answer and the transport that answer assumed.

Every egress spec now installs through `installProxyFromEnvironment`, so
no test asserts a policy object a real launch could not produce.
Yichen Jiang 1 mês atrás
pai
commit
8470ddef1d
41 arquivos alterados com 621 adições e 669 exclusões
  1. 2 2
      .agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.i18n.yaml
  2. 17 7
      .agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.md
  3. 17 7
      .agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.zh.md
  4. 1 1
      THIRD_PARTY_NOTICES.md
  5. 5 6
      apps/cli/src/profile-boot.ts
  6. 2 2
      docs/config-catalog.i18n.yaml
  7. 11 7
      docs/config-catalog.md
  8. 11 7
      docs/config-catalog.zh.md
  9. 2 2
      docs/user/guide/network-proxy.i18n.yaml
  10. 0 1
      docs/user/guide/network-proxy.md
  11. 0 1
      docs/user/guide/network-proxy.zh.md
  12. 3 3
      packages/e2b/e2b/src/index.ts
  13. 15 9
      packages/e2b/e2b/tests/egress.spec.ts
  14. 5 4
      packages/llm/llm-deepseek/tests/egress.spec.ts
  15. 5 4
      packages/llm/llm-pi-ai/tests/egress.spec.ts
  16. 5 4
      packages/mcp/mcp-client/tests/egress.spec.ts
  17. 2 2
      packages/session/session-telemetry-otel/package.json
  18. 50 18
      packages/session/session-telemetry-otel/src/index.ts
  19. 18 54
      packages/session/session-telemetry-otel/tests/egress.spec.ts
  20. 20 7
      packages/session/session-telemetry-otel/tests/otel.spec.ts
  21. 2 2
      packages/subprocess/subprocess/src/index.ts
  22. 15 17
      packages/subprocess/subprocess/tests/egress.spec.ts
  23. 1 9
      packages/test-support/loader-smoke/src/index.ts
  24. 1 9
      packages/test-support/session-snapshot/src/harness.ts
  25. 2 2
      packages/util/http-proxy/README.i18n.yaml
  26. 14 9
      packages/util/http-proxy/README.md
  27. 15 10
      packages/util/http-proxy/README.zh.md
  28. 11 25
      packages/util/http-proxy/src/index.ts
  29. 83 78
      packages/util/http-proxy/src/install.ts
  30. 179 236
      packages/util/http-proxy/tests/install.spec.ts
  31. 13 8
      packages/util/http-proxy/tests/matcher-parity.spec.ts
  32. 39 57
      packages/web/web-fetch-http/src/network.ts
  33. 7 8
      packages/web/web-fetch-http/src/provider.ts
  34. 16 10
      packages/web/web-fetch-http/tests/proxy.spec.ts
  35. 5 4
      packages/web/web-search-deepseek/tests/egress.spec.ts
  36. 5 4
      packages/web/web-search-exa/tests/egress.spec.ts
  37. 5 4
      packages/web/web-search-perplexity/tests/egress.spec.ts
  38. 9 9
      packages/workflow/workflow-worker-thread/tests/egress.spec.ts
  39. 2 17
      pnpm-lock.yaml
  40. 1 1
      scripts/verify-no-bare-dispatcher.spec.ts
  41. 5 2
      scripts/verify-no-bare-dispatcher.ts

+ 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: c15c1d08537a5751ac57651fe7ef94e0088d0896
-2026-08-27-outbound-proxy-policy.zh.md: ee4b9b2768bd6a75565b2f09ef88c9bb334e4ed4
+2026-08-27-outbound-proxy-policy.md: 88bfe542d322d2caee5f5e220ed211e0169fa614
+2026-08-27-outbound-proxy-policy.zh.md: f5afee9de40e2032cfd4eda3bc9bfb1c627e71aa

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

@@ -24,7 +24,13 @@ An earlier revision put it in a new `net/` package group, reasoning that dependi
 
 The plugin that revision shipped is gone with it. It let a composition declare the policy in `cordis.yml`, but no shipped bundle mounted it, so the launcher's path was the only reachable one — and its `Config` was the sole supplier of a configuration branch nothing else could reach.
 
-**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.
+**Four functions, because the call sites converged rather than the package growing an export each.** An earlier revision exported six: a dispatcher factory, a `node:http` agent factory, a proxy-URL lookup, a policy accessor, an installer, and a child-environment builder. Each existed for one SDK's transport, which is how a transport-policy package turns into a catalogue of other packages' constraints. Review asked whether the call sites could converge instead; they could, and each removal took a whole shape with it. The exporter moved to the SDK's `fetch` delegate, retiring the `node:http` factory. `web-fetch-http` builds its own pinning agent under an annotated exemption, retiring the dispatcher factory. E2B reads `route.proxy`, retiring the proxy-URL lookup.
+
+What remains is `installProxyFromEnvironment`, `proxyRouteFor`, `proxyEnvironmentForChild`, and `clearedProxyEnv` — one per way a caller can need the policy, none per SDK. Installation absorbed resolution and diagnostic reporting, which no caller needed apart: a resolved policy that is not installed routes nothing.
+
+`proxyRouteFor` also closes a defect the old accessor made expressible. `web-fetch-http` read the policy to decide whether to pin, then read it again to build a transport; an unmount between the two returned a direct, unpinned agent for a URL the first read had cleared as proxied. A route carries both, so the branch and the request cannot disagree. Its dispatcher is the process-wide one, closed rather than destroyed on disposal, so a request already in flight when a policy is unmounted still finishes.
+
+**The installed dispatcher routes by the policy, not by an environment it re-parses.** Installation 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 one reader only: a spawned child, which has no policy object to consult.
 
 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.
 
@@ -36,15 +42,19 @@ This keeps `proxyForUrl()` and the dispatcher answering from one set of values.
 
 The URL-level policy is untouched: `http(s)` only, no embedded credentials, the length cap, and the cross-origin redirect refusal all still apply on every hop.
 
-**A spawned child gets the policy through its environment; a model-executing worker gets nothing.** `childProxyEnv()` merges into `scrubbedParentEnv()`, the one function every spawner already shares. The workflow worker does NOT receive it: it executes the model-authored script body, and a proxy URL may carry `user:password`. That is the same containment the code runtime keeps and `docs/defensive-patterns.md` requires, so a workflow's own requests go direct.
+**A spawned child gets the policy through its environment; a model-executing worker gets nothing.** `proxyEnvironmentForChild()` merges into `scrubbedParentEnv()`, the one function every spawner already shares. The workflow worker does NOT receive it: it executes the model-authored script body, and a proxy URL may carry `user:password`. That is the same containment the code runtime keeps and `docs/defensive-patterns.md` requires, so a workflow's own requests go direct.
 
 This accepts a documented seam. Such a context matches bypass entries by Node's rules, which differ from this package's in separators and IPv4-range support, and the flag exists only on Node 22.21+ and 24+.
 
-**Two SDKs do not reach `globalThis.fetch`, and reading their code said otherwise.** The audit first classified the OTLP exporter and the E2B SDK as covered, on a grep that found `globalThis.fetch` in `@opentelemetry/otlp-exporter-base`. That match is the *browser* transport; on Node the delegate selects `http-exporter-transport`, which posts through `node:http` — where a global dispatcher does not reach. E2B is a second shape again: it builds its own undici `Agent`/`ProxyAgent` and takes a `proxy` URL that it never reads from the environment. Both were measured direct and both are now wired — the exporter through `createNodeHttpAgent` as its `httpAgentOptions` factory, E2B through `proxyUrlFor` into `Sandbox.create`.
+**Two SDKs do not reach `globalThis.fetch`, and reading their code said otherwise.** The audit first classified the OTLP exporter and the E2B SDK as covered, on a grep that found `globalThis.fetch` in `@opentelemetry/otlp-exporter-base`. That match is the *browser* transport; on Node the delegate selects `http-exporter-transport`, which posts through `node:http` — where a global dispatcher does not reach. E2B is a second shape again: it builds its own undici `Agent`/`ProxyAgent` and takes a `proxy` URL that it never reads from the environment. Both were measured direct, and both were fixed by moving the call site onto a transport the dispatcher already covers rather than by giving this package a second export for each.
+
+The exporter now composes `OTLPExporterBase` with `createLegacyOtlpBrowserExportDelegate` — a published entry point of the same SDK package, and the one that posts through `fetch`. E2B is handed `route.proxy` from `proxyRouteFor`, the same call `web-fetch-http` makes.
+
+Switching the exporter to `fetch` costs `compression`: gzip belongs to the SDK's Node transport, and a realistic OTLP batch measured 6.4x smaller with it. Nothing shipped enabled it, and telemetry that ignores the proxy simply fails inside a corporate network, so routing wins. What the exporter would silently ignore, the plugin now refuses at load — `exporter.compression`, `exporter.keepAlive`, and `exporter.httpAgentOptions` throw with the reason, so no deployment pays the difference without seeing it. In exchange the Node-version floor disappears: `proxyEnv` on an `http.Agent` needs 22.21 or 24.5, inside the engines range, so telemetry used to stay direct on 22.19, 22.20, and 24.0–24.4.
 
 **Every call site carries an egress test, because reading the code was not enough.** `egress.spec.ts` in each owning package drives that site's real code path at an unresolvable `.invalid` host through a fake proxy and asserts the proxy saw the request. Nine of them cover the search backends, pi-ai discovery, MCP over HTTP, telemetry, E2B, a spawned child Node, and a worker thread. The gate below cannot see inside a dependency; these can, and they are what turns "an SDK changed its transport" from a silent regression into a failing test.
 
-**A gate keeps the defect from returning.** `verify-no-bare-dispatcher` parses the TypeScript AST — `scripts/AGENTS.md` requires syntax-aware discovery, and a line-wise regex missed both the `{ dispatcher }` shorthand this repository already uses and a `new Alias(...)` behind a renamed import. It rejects an undici agent construction and an explicit `dispatcher` option outside the owning package. `createDispatcher(url, options)` is the sanctioned replacement, and a line that must genuinely ignore the proxy says so with a `proxy-exempt:` comment. The rule exists because `web-fetch-http`'s original `new Agent` was entirely reasonable when it was written — proxying simply did not exist yet, and nothing would have caught it.
+**A gate keeps the defect from returning.** `verify-no-bare-dispatcher` parses the TypeScript AST — `scripts/AGENTS.md` requires syntax-aware discovery, and a line-wise regex missed both the `{ dispatcher }` shorthand this repository already uses and a `new Alias(...)` behind a renamed import. It rejects an undici agent construction and an explicit `dispatcher` option outside the owning package. `proxyRouteFor(url)` is the sanctioned replacement, and the one call site that genuinely owns its transport — `web-fetch-http`, pinning a request to addresses it validated — says so with a `proxy-exempt:` comment. The rule exists because `web-fetch-http`'s original `new Agent` was entirely reasonable when it was written — proxying simply did not exist yet, and nothing would have caught it.
 
 ## Alternatives considered
 
@@ -76,12 +86,12 @@ The suite is hermetic against the developer's own environment: `plugin.spec.ts`
 
 ## Testing
 
-`packages/util/http-proxy` holds 89 tests at 100% per-file coverage. Resolution covers precedence, the `ALL_PROXY` fallback, blank-shadowing, the SOCKS and malformed diagnostics, and the HTTPS-only environment that leaves `http:` direct; routing covers the whole loopback range structurally, and bypass matching covers suffixes, ports, both IPv6 spellings, and the CIDR entry that deliberately does not match. Installation drives a real loopback proxy and asserts the absolute-form request arrives, that a bypassed target does not, and that disposal restores the dispatcher, the policy, and the environment.
+`packages/util/http-proxy` holds 84 tests at 100% per-file coverage. Resolution covers precedence, the `ALL_PROXY` fallback, blank-shadowing, the SOCKS and malformed diagnostics, and the HTTPS-only environment that leaves `http:` direct; routing covers the whole loopback range structurally, and bypass matching covers suffixes, ports, both IPv6 spellings, and the CIDR entry that deliberately does not match. Installation drives a real loopback proxy and asserts the absolute-form request arrives, that a bypassed target does not, and that disposal restores the dispatcher, the policy, and the environment. Every case installs through `installProxyFromEnvironment`, so no test can assert a policy object a real launch could not produce.
 
 `packages/web/web-fetch-http/tests/proxy.spec.ts` asserts the decision that matters most: under a proxy the public-address resolver is never called, while a bypassed hop still calls it exactly once, and the cross-origin redirect refusal survives on the proxied path.
 
-`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.
+`verify-no-bare-dispatcher.spec.ts` proves the gate rejects the exact shape this package was introduced to fix, accepts `proxyRouteFor`, 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 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.
+The egress suite carries the negative case for telemetry — a `node:http` request under the same installed policy reaches no proxy — so a return to the SDK's Node transport cannot quietly un-proxy it. Its positive case no longer branches on the runtime, because `fetch` reaches the dispatcher on every supported Node. 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.

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

@@ -24,7 +24,13 @@ Node 内置的 `fetch` 会忽略 `HTTP_PROXY` 与 `HTTPS_PROXY`。开发者运
 
 那次修订一并引入的插件也随之删除。它让某个组合可以把策略写进 `cordis.yml`,但没有任何随附 bundle 挂载它,因此启动器那条路径是唯一可达的——而它的 `Config` 是那条配置分支唯一的供给方,别处无从到达。
 
-**已安装的 dispatcher 按策略路由,而不是重新解析一遍环境。** `installGlobalProxy` 构造一个 `Agent`,其按 origin 调用的 `factory` 会询问 `proxyForUrl` 该 origin 的去向,并据此返回 `ProxyAgent` 或 undici 自带的默认客户端。undici 的 `EnvHttpProxyAgent` 曾是首选,但对这套策略是错的:没有 `HTTPS_PROXY` 时它会把 HTTPS agent 设为 HTTP agent,于是本包在拒绝某个 SOCKS 或畸形 URL 后本应保持直连的 scheme 仍会被隧道转发,而诊断却声称直连。让路由走同一个谓词,从构造上而非靠测试消除了这一类分歧。把策略发布到环境中的做法保留下来,但如今只服务那些拿不到策略对象的读者:Node 的 `proxyEnv` 选项,以及每个派生的子进程。
+**四个函数——收敛的是调用方,而不是让本包为每个 SDK 各加一个导出。** 早先一版导出六个:dispatcher 工厂、`node:http` agent 工厂、代理 URL 查询、策略访问器、安装器与子进程环境构造器。每一个都为某个 SDK 的传输而存在,而这正是一个传输策略包退化成「别的包的约束目录」的过程。Review 问能不能反过来让调用方收敛;能,而且每删掉一个导出都带走了一整种写法。导出器改用 SDK 的 `fetch` delegate,`node:http` agent 工厂随之退场。`web-fetch-http` 在带注释的豁免下自建 pin agent,dispatcher 工厂随之退场。E2B 读 `route.proxy`,代理 URL 查询随之退场。
+
+剩下的是 `installProxyFromEnvironment`、`proxyRouteFor`、`proxyEnvironmentForChild` 与 `clearedProxyEnv`——按「调用方需要策略的方式」各一个,而不是按 SDK 各一个。安装吸收了解析与诊断上报,因为没有调用方需要把它们分开:解析出来却不安装的策略什么也路由不了。
+
+`proxyRouteFor` 还堵掉了旧访问器让人写得出来的一个缺陷。`web-fetch-http` 先读策略决定是否 pin,再读一次去构造传输;两次读取之间发生卸载,就会为第一次读取已判定走代理的 URL 返回一个直连且未 pin 的 agent。路由把两者一起交出,分支与请求便无从分歧。它携带的是进程级 dispatcher,dispose 时是 close 而非 destroy,因此策略被卸载时已经发出的请求仍会跑完。
+
+**已安装的 dispatcher 按策略路由,而不是重新解析一遍环境。** 安装过程构造一个 `Agent`,其按 origin 调用的 `factory` 会询问 `proxyForUrl` 该 origin 的去向,并据此返回 `ProxyAgent` 或 undici 自带的默认客户端。undici 的 `EnvHttpProxyAgent` 曾是首选,但对这套策略是错的:没有 `HTTPS_PROXY` 时它会把 HTTPS agent 设为 HTTP agent,于是本包在拒绝某个 SOCKS 或畸形 URL 后本应保持直连的 scheme 仍会被隧道转发,而诊断却声称直连。让路由走同一个谓词,从构造上而非靠测试消除了这一类分歧。把策略发布到环境中的做法保留下来,但如今只服务一类读者:派生的子进程——它没有策略对象可查。
 
 这样 `proxyForUrl()` 与 dispatcher 就从同一组值给出答案。两者必须一致:一旦对某个 URL 产生分歧,`web-fetch-http` 就会把 dispatcher 本打算隧道转发的连接固定到某个地址上。
 
@@ -36,15 +42,19 @@ Node 内置的 `fetch` 会忽略 `HTTP_PROXY` 与 `HTTPS_PROXY`。开发者运
 
 URL 层策略未受影响:仅 `http(s)`、禁止内嵌凭据、长度上限与跨域重定向拒绝在每一跳上依然生效。
 
-**派生的子进程通过环境获得策略;执行模型代码的 worker 什么也不获得。** `childProxyEnv()` 并入 `scrubbedParentEnv()`——每个 spawner 本就共享的那一个函数。workflow worker **不**接收它:它执行的是模型编写的脚本体,而代理 URL 可能携带 `user:password`。这与 code runtime 保持的containment 相同,也是 `docs/defensive-patterns.md` 的要求,因此 workflow 自身的请求直连。
+**派生的子进程通过环境获得策略;执行模型代码的 worker 什么也不获得。** `proxyEnvironmentForChild()` 并入 `scrubbedParentEnv()`——每个 spawner 本就共享的那一个函数。workflow worker **不**接收它:它执行的是模型编写的脚本体,而代理 URL 可能携带 `user:password`。这与 code runtime 保持的隔离相同,也是 `docs/defensive-patterns.md` 的要求,因此 workflow 自身的请求直连。
 
 这接受了一处已记录的接缝。此类上下文按 Node 自己的规则匹配绕过条目,其分隔符与 IPv4 区间支持与本包不同,且该标志仅存在于 Node 22.21+ 与 24+。
 
-**有两个 SDK 并不落到 `globalThis.fetch`,而读代码给出的答案是相反的。** 审计最初把 OTLP 导出器与 E2B SDK 判为已覆盖,依据是在 `@opentelemetry/otlp-exporter-base` 里 grep 到了 `globalThis.fetch`。那处命中属于**浏览器**传输;在 Node 上 delegate 选择的是 `http-exporter-transport`,它通过 `node:http` 投递——那里全局 dispatcher 触及不到。E2B 又是另一种形态:它自建 undici `Agent`/`ProxyAgent`,并接受一个自己从不从环境读取的 `proxy` URL。两者都实测为直连,现均已接通——导出器通过把 `createNodeHttpAgent` 作为其 `httpAgentOptions` 工厂,E2B 通过把 `proxyUrlFor` 传入 `Sandbox.create`。
+**有两个 SDK 并不落到 `globalThis.fetch`,而读代码给出的答案是相反的。** 审计最初把 OTLP 导出器与 E2B SDK 判为已覆盖,依据是在 `@opentelemetry/otlp-exporter-base` 里 grep 到了 `globalThis.fetch`。那处命中属于**浏览器**传输;在 Node 上 delegate 选择的是 `http-exporter-transport`,它通过 `node:http` 投递——那里全局 dispatcher 触及不到。E2B 又是另一种形态:它自建 undici `Agent`/`ProxyAgent`,并接受一个自己从不从环境读取的 `proxy` URL。两者都实测为直连,而修复方式不是给本包各加一个导出,而是把调用点搬到 dispatcher 本就覆盖的传输上。
+
+导出器改为用 `OTLPExporterBase` 组合 `createLegacyOtlpBrowserExportDelegate`——同一个 SDK 包的公开入口,也是通过 `fetch` 投递的那一个。E2B 则接收 `proxyRouteFor` 给出的 `route.proxy`,与 `web-fetch-http` 调的是同一个函数。
+
+把导出器换到 `fetch` 的代价是 `compression`:gzip 属于该 SDK 的 Node 传输,实测一批真实规模的 OTLP 数据启用后体积只有 1/6.4。目前没有任何随附配置启用它,而在企业代理网络里,不遵循代理的遥测干脆发不出去,因此路由优先。导出器本会静默忽略的选项,现在由插件在加载期拒绝——`exporter.compression`、`exporter.keepAlive` 与 `exporter.httpAgentOptions` 会带着原因抛错,任何部署都不会在看不见的情况下承担这个差价。换来的是 Node 版本下限消失:`http.Agent` 的 `proxyEnv` 需要 22.21 或 24.5,而这落在 engines 范围之内,因此遥测过去在 22.19、22.20 与 24.0–24.4 上一直是直连。
 
 **每个出网点都配一份出网测试,因为读代码不够。** 各所属包中的 `egress.spec.ts` 驱动该点的真实代码路径,目标是无法解析的 `.invalid` 主机,穿过一个假代理,并断言代理确实收到了请求。九份测试覆盖搜索后端、pi-ai 发现、走 HTTP 的 MCP、遥测、E2B、派生的子 Node 与 worker 线程。下面那条门禁看不进依赖内部;这些能,它们把「某个 SDK 换了传输」从静默回归变成失败的测试。
 
-**用门禁防止该缺陷复现。** `verify-no-bare-dispatcher` 解析 TypeScript AST——`scripts/AGENTS.md` 要求 source-ownership 门禁使用语法感知发现,而逐行正则漏掉了本仓库已在使用的 `{ dispatcher }` 简写,以及重命名导入后的 `new Alias(...)`。它在所属包之外拒绝 undici agent 构造与显式 `dispatcher` 选项。`createDispatcher(url, options)` 是受支持的替代;确实必须忽略代理的行用 `proxy-exempt:` 注释说明。这条规则之所以存在,是因为 `web-fetch-http` 里原本那行 `new Agent` 在写下时完全合理——那时根本还没有代理这回事,也没有任何机制会拦下它。
+**用门禁防止该缺陷复现。** `verify-no-bare-dispatcher` 解析 TypeScript AST——`scripts/AGENTS.md` 要求 source-ownership 门禁使用语法感知发现,而逐行正则漏掉了本仓库已在使用的 `{ dispatcher }` 简写,以及重命名导入后的 `new Alias(...)`。它在所属包之外拒绝 undici agent 构造与显式 `dispatcher` 选项。`proxyRouteFor(url)` 是受支持的替代;唯一一处确实自有传输的调用点——`web-fetch-http`,它把请求钉在已校验的地址上——用 `proxy-exempt:` 注释说明。这条规则之所以存在,是因为 `web-fetch-http` 里原本那行 `new Agent` 在写下时完全合理——那时根本还没有代理这回事,也没有任何机制会拦下它。
 
 ## Alternatives considered
 
@@ -76,12 +86,12 @@ userland undici 能触及 Node 内置的 `fetch`,依赖于两者都会写入 l
 
 ## Testing
 
-`packages/util/http-proxy` 有 89 个测试,per-file 覆盖率 100%。解析覆盖优先级、`ALL_PROXY` 兜底、空值遮蔽、SOCKS 与畸形值诊断,以及只设 https 变量时 `http:` 保持直连;路由以结构化方式覆盖整个 loopback 网段,绕过匹配覆盖后缀、端口、两种 IPv6 写法,以及刻意不匹配的 CIDR 条目。安装驱动一个真实的 loopback 代理,断言绝对形式的请求确实抵达、被绕过的目标不抵达,且 dispose 会还原 dispatcher、策略与环境。
+`packages/util/http-proxy` 有 84 个测试,per-file 覆盖率 100%。解析覆盖优先级、`ALL_PROXY` 兜底、空值遮蔽、SOCKS 与畸形值诊断,以及只设 https 变量时 `http:` 保持直连;路由以结构化方式覆盖整个 loopback 网段,绕过匹配覆盖后缀、端口、两种 IPv6 写法,以及刻意不匹配的 CIDR 条目。安装驱动一个真实的 loopback 代理,断言绝对形式的请求确实抵达、被绕过的目标不抵达,且 dispose 会还原 dispatcher、策略与环境。所有用例一律经 `installProxyFromEnvironment` 安装,因此没有测试能断言一次真实启动无法产生的策略对象。
 
 `packages/web/web-fetch-http/tests/proxy.spec.ts` 断言了最关键的那个决定:经由代理时公网地址解析器完全不被调用,而被绕过的一跳仍恰好调用一次,且跨域重定向拒绝在代理路径上依然成立。
 
-`verify-no-bare-dispatcher.spec.ts` 证明该门禁能拒掉本包所要修复的那种写法、接受 `createDispatcher`、接受带注释的豁免,并在当前代码树上通过。
+`verify-no-bare-dispatcher.spec.ts` 证明该门禁能拒掉本包所要修复的那种写法、接受 `proxyRouteFor`、接受带注释的豁免,并在当前代码树上通过。
 
-出网测试为遥测保留了负向用例——恢复 SDK 自带的默认 agent 就触及不到代理——因此升级无法悄悄把它变回直连。其正向用例按运行时分支,因为导出器的 agent 需要 Node 22.21+ 或 24.5+。另有一组一致性测试,对文档所述 `NO_PROXY` 词汇中的每种形态,把 `proxyForUrl` 的判断与真实 `fetch` 的实际去向相互核对;由于 dispatcher 正是按同一谓词路由,它现在能抓住的是 `bypassesProxy` 对某种形态的读法与词汇文档不一致,以及未来任何重新引入第二个匹配器的 dispatcher。
+出网测试为遥测保留了负向用例——在同一份已安装策略下发一个 `node:http` 请求,触及不到代理——因此改回 SDK 的 Node 传输无法悄悄把遥测变回直连。其正向用例不再按运行时分支,因为在所有受支持的 Node 上 `fetch` 都会落到 dispatcher。另有一组一致性测试,对文档所述 `NO_PROXY` 词汇中的每种形态,把 `proxyForUrl` 的判断与真实 `fetch` 的实际去向相互核对;由于 dispatcher 正是按同一谓词路由,它现在能抓住的是 `bypassesProxy` 对某种形态的读法与词汇文档不一致,以及未来任何重新引入第二个匹配器的 dispatcher。
 
 无录制会话快照变更:本次改动不影响任何模型可见输入或产品用户可见的 transcript 输出。

+ 1 - 1
THIRD_PARTY_NOTICES.md

@@ -48,8 +48,8 @@ External packages that a workspace package resolves at runtime. The tier covers
 | [`@openai/codex`](https://github.com/openai/codex) | Apache-2.0 |
 | [`@opentelemetry/api`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
 | [`@opentelemetry/api-logs`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
-| [`@opentelemetry/exporter-logs-otlp-http`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
 | [`@opentelemetry/otlp-exporter-base`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
+| [`@opentelemetry/otlp-transformer`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
 | [`@opentelemetry/resources`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
 | [`@opentelemetry/sdk-logs`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
 | [`@shikijs/langs`](https://github.com/shikijs/shiki) | MIT |

+ 5 - 6
apps/cli/src/profile-boot.ts

@@ -30,7 +30,7 @@ import {
   type Profile,
 } from '@deepseek-ai/dsh-app-boot'
 import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
-import { installGlobalProxy, resolveProxyPolicy } from '@deepseek-ai/dsh-http-proxy'
+import { installProxyFromEnvironment } from '@deepseek-ai/dsh-http-proxy'
 import { DSH_LAUNCH_ENVIRONMENT_KEY, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
 import { provideCmdline, type AppReady } from '@deepseek-ai/dsh-cmdline'
 import { createProcessShutdown, type ProcessShutdown } from './process-shutdown.ts'
@@ -212,11 +212,10 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
   // proxy environment on its own, so every profile would otherwise connect directly. Resolving from
   // the launcher's snapshot — not `process.env` — is what lets a proxy declared in a `.env` layer
   // work, which the NODE_USE_ENV_PROXY flag cannot do because Node samples the environment at start.
-  const { policy: proxyPolicy, diagnostics } = resolveProxyPolicy(options.environment)
-  // A proxy variable may have been exported for other tools, so a value this harness cannot use is
-  // reported and skipped rather than being allowed to stop the agent from starting.
-  for (const diagnostic of diagnostics) process.stderr.write(`${NAME}: ${diagnostic.message}\n`)
-  const disposeProxy = await installGlobalProxy(proxyPolicy)
+  const disposeProxy = await installProxyFromEnvironment(
+    options.environment,
+    (message) => { process.stderr.write(`${NAME}: ${message}\n`) },
+  )
 
   const composed = await composeProfile(options.profile, options.patchFiles)
   const app: { current?: Context } = {}

+ 2 - 2
docs/config-catalog.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/config-catalog.md
-config-catalog.md: a8fa6d46fe1b3c7cedf2d465eb539587a12186af
-config-catalog.zh.md: 0523bc6ac0d53b802907abd3c81037f960092ec9
+config-catalog.md: f53dbb96e7e91f4c8c1d32dbe86f1ec13037f982
+config-catalog.zh.md: 8587d0171c0bd9bf0f6a9c8a727b77947606ce48

+ 11 - 7
docs/config-catalog.md

@@ -1967,11 +1967,15 @@ export interface Config {
   mode?: SessionTelemetryMode
   /**
    * Passed verbatim to the SDK's OTLP/HTTP log exporter — the complete
-   * `OTLPExporterNodeConfigBase` shape (`headers`, `timeoutMillis`,
-   * `compression`, `keepAlive`, …), owned and documented by the SDK. `url`
-   * is the one field this package requires and validates itself.
-   */
-  exporter?: OTLPExporterNodeConfigBase & {
+   * `OTLPExporterConfigBase` shape (`headers`, `timeoutMillis`,
+   * `concurrencyLimit`, …), owned and documented by the SDK. `url` is the
+   * one field this package requires and validates itself.
+   *
+   * The transport is the SDK's `fetch` one, so the three options that exist
+   * only for its `node:http` transport — `compression`, `keepAlive`, and
+   * `httpAgentOptions` — are refused at load rather than ignored.
+   */
+  exporter?: OTLPExporterConfigBase & {
     /** Full logs endpoint (e.g. `https://collector.example.com/v1/logs`). Required outside `DISABLED`; validated at load. */
     url?: string
   }
@@ -1992,9 +1996,9 @@ export enum SessionTelemetryMode {
 }
 ```
 
-Depends on: `BatchLogRecordProcessorOptions` (`@opentelemetry/sdk-logs`) · `OTLPExporterNodeConfigBase` (`@opentelemetry/otlp-exporter-base`)
+Depends on: `BatchLogRecordProcessorOptions` (`@opentelemetry/sdk-logs`) · `OTLPExporterConfigBase` (`@opentelemetry/otlp-exporter-base`)
 
-Source: [`packages/session/session-telemetry-otel/src/index.ts:92`](../packages/session/session-telemetry-otel/src/index.ts)
+Source: [`packages/session/session-telemetry-otel/src/index.ts:94`](../packages/session/session-telemetry-otel/src/index.ts)
 
 <a id="deepseek-aidsh-session-title"></a>
 

+ 11 - 7
docs/config-catalog.zh.md

@@ -1969,11 +1969,15 @@ export interface Config {
   mode?: SessionTelemetryMode
   /**
    * Passed verbatim to the SDK's OTLP/HTTP log exporter — the complete
-   * `OTLPExporterNodeConfigBase` shape (`headers`, `timeoutMillis`,
-   * `compression`, `keepAlive`, …), owned and documented by the SDK. `url`
-   * is the one field this package requires and validates itself.
-   */
-  exporter?: OTLPExporterNodeConfigBase & {
+   * `OTLPExporterConfigBase` shape (`headers`, `timeoutMillis`,
+   * `concurrencyLimit`, …), owned and documented by the SDK. `url` is the
+   * one field this package requires and validates itself.
+   *
+   * The transport is the SDK's `fetch` one, so the three options that exist
+   * only for its `node:http` transport — `compression`, `keepAlive`, and
+   * `httpAgentOptions` — are refused at load rather than ignored.
+   */
+  exporter?: OTLPExporterConfigBase & {
     /** Full logs endpoint (e.g. `https://collector.example.com/v1/logs`). Required outside `DISABLED`; validated at load. */
     url?: string
   }
@@ -1994,9 +1998,9 @@ export enum SessionTelemetryMode {
 }
 ```
 
-依赖:`BatchLogRecordProcessorOptions`(`@opentelemetry/sdk-logs`)· `OTLPExporterNodeConfigBase`(`@opentelemetry/otlp-exporter-base`)
+依赖:`BatchLogRecordProcessorOptions`(`@opentelemetry/sdk-logs`)· `OTLPExporterConfigBase`(`@opentelemetry/otlp-exporter-base`)
 
-来源:[`packages/session/session-telemetry-otel/src/index.ts:91`](../packages/session/session-telemetry-otel/src/index.ts)
+来源:[`packages/session/session-telemetry-otel/src/index.ts:94`](../packages/session/session-telemetry-otel/src/index.ts)
 
 <a id="deepseek-aidsh-session-title"></a>
 

+ 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: 22db4a583771ac730217a95a9e5662ef6516c7fd
-network-proxy.zh.md: a9479a582075327b35998491543e9d73056db0b5
+network-proxy.md: 3561ec5b0dfc4290ab29dfe66fc91b19031fa31d
+network-proxy.zh.md: 928f67db215650f2761ae5c929de3605b76b2520

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

@@ -67,7 +67,6 @@ Not every request DSH makes goes through the proxy:
 
 - **Anything on this machine.** Loopback is always direct: `localhost`, the whole `127.0.0.0/8` range, `::1`, and `0.0.0.0`. A proxy cannot usefully reach a service that only listens locally.
 - **Code the model writes.** The workflow and code-runtime workers never receive the proxy settings, so a script the model authors cannot read a proxy URL that may carry a password. Such a script reaches the network only if it configures that itself.
-- **Telemetry on an older Node.** The OTLP exporter uses Node's own HTTP client, which learned to honor these variables in Node 22.21 and 24.5. On 22.19, 22.20, and 24.0–24.4 telemetry connects directly.
 - **`web_fetch` to a literal private address.** A URL naming an address like `http://10.0.0.5/` is refused rather than handed to the proxy, the same refusal it gets with no proxy configured.
 
 ## Check that it worked

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

@@ -67,7 +67,6 @@ Node 只在进程启动时读取该变量,所以要在运行 `dsh` 之前导
 
 - **本机上的一切。** loopback 始终直连:`localhost`、整个 `127.0.0.0/8` 段、`::1` 与 `0.0.0.0`。代理无法有意义地访问一个只在本地监听的服务。
 - **模型编写的代码。** workflow 与 code-runtime worker 从不接收代理配置,因此模型编写的脚本读不到可能携带密码的代理 URL。这类脚本只有自行配置才能联网。
-- **较旧 Node 上的遥测。** OTLP 导出器使用 Node 自带的 HTTP 客户端,而它从 Node 22.21 与 24.5 起才遵循这些变量。在 22.19、22.20 与 24.0–24.4 上遥测直连。
 - **`web_fetch` 访问字面量私网地址。** 形如 `http://10.0.0.5/` 的 URL 会被拒绝而非交给代理,与未配置代理时得到的拒绝相同。
 
 ## 验证是否生效

+ 3 - 3
packages/e2b/e2b/src/index.ts

@@ -9,7 +9,7 @@ import { posix } from 'node:path'
 import { Context, Service } from '@deepseek-ai/cordis'
 import z from '@deepseek-ai/schemastery'
 import { FileType, Sandbox, SandboxNotFoundError } from 'e2b'
-import { proxyUrlFor } from '@deepseek-ai/dsh-http-proxy'
+import { proxyRouteFor } from '@deepseek-ai/dsh-http-proxy'
 import { e2bApiUrl } from './api-url.ts'
 
 export {
@@ -156,13 +156,13 @@ export class E2BRuntime extends Service {
     // URL instead and reads no environment of its own. The decision is made against the URL the SDK
     // will really call, so a bypass entry naming that host is honored and a loopback debug plane
     // stays direct.
-    const proxy = proxyUrlFor(new URL(e2bApiUrl()))
+    const route = proxyRouteFor(new URL(e2bApiUrl()))
     const sandbox = await Sandbox.create({
       apiKey: this.config.apiKey,
       timeoutMs: this.config.timeoutMs,
       secure: true,
       lifecycle: { onTimeout: 'kill' },
-      ...proxy === undefined ? {} : { proxy },
+      ...route.proxied ? { proxy: route.proxy } : {},
     })
     try {
       await sandbox.files.makeDir(this.cwd)

+ 15 - 9
packages/e2b/e2b/tests/egress.spec.ts

@@ -1,7 +1,7 @@
 import { createServer, type Server } from 'node:http'
 import type { AddressInfo } from 'node:net'
 import { afterAll, beforeAll, describe, expect, it } from 'vitest'
-import { installGlobalProxy, type ProxyPolicy } from '@deepseek-ai/dsh-http-proxy'
+import { installProxyFromEnvironment } from '@deepseek-ai/dsh-http-proxy'
 
 let seen: string[] = []
 let proxy: Server
@@ -21,12 +21,13 @@ beforeAll(async () => {
 })
 afterAll(async () => { await new Promise<void>((r) => { proxy.close(() => { r() }) }) })
 
-function policy(): ProxyPolicy {
-  return { httpProxy: proxyUrl, httpsProxy: proxyUrl, noProxy: '', source: 'env' }
+/** The launch environment of a user who exported one proxy for both schemes. */
+function proxyEnv(): { get(name: string): { value: string } | undefined } {
+  return { get: name => (name === 'HTTP_PROXY' || name === 'HTTPS_PROXY' ? { value: proxyUrl } : undefined) }
 }
 async function observe(run: () => Promise<unknown>): Promise<string[]> {
   seen = []
-  const dispose = await installGlobalProxy(policy())
+  const dispose = await installProxyFromEnvironment(proxyEnv(), () => undefined)
   try { await run().catch(() => undefined) } finally { await dispose() }
   return seen
 }
@@ -57,13 +58,18 @@ describe('e2b control-plane URL', () => {
 
   it('keeps the loopback debug plane direct instead of sending its API key to a proxy', async () => {
     const { e2bApiUrl } = await import('../src/api-url.ts')
-    const { proxyForUrl, resolveProxyPolicy } = await import('@deepseek-ai/dsh-http-proxy')
+    const { proxyRouteFor } = await import('@deepseek-ai/dsh-http-proxy')
     const { createLaunchEnvironmentSnapshot } = await import('@deepseek-ai/dsh-launch-environment')
-    // A resolved policy — the shape a real launch installs — always bypasses loopback.
-    const { policy: resolved } = resolveProxyPolicy(
+    // A real launch installs from the environment, and the resolved policy always bypasses loopback.
+    const dispose = await installProxyFromEnvironment(
       createLaunchEnvironmentSnapshot([{ source: 'process', values: { HTTP_PROXY: proxyUrl } }]),
+      () => undefined,
     )
-    expect(proxyForUrl(resolved, new URL(e2bApiUrl({ E2B_DEBUG: 'true' })))).toBeUndefined()
-    expect(proxyForUrl(resolved, new URL(e2bApiUrl({})))).toBe(proxyUrl)
+    try {
+      expect(proxyRouteFor(new URL(e2bApiUrl({ E2B_DEBUG: 'true' })))).toEqual({ proxied: false })
+      expect(proxyRouteFor(new URL(e2bApiUrl({})))).toMatchObject({ proxied: true, proxy: proxyUrl })
+    } finally {
+      await dispose()
+    }
   })
 })

+ 5 - 4
packages/llm/llm-deepseek/tests/egress.spec.ts

@@ -1,7 +1,7 @@
 import { createServer, type Server } from 'node:http'
 import type { AddressInfo } from 'node:net'
 import { afterAll, beforeAll, describe, expect, it } from 'vitest'
-import { installGlobalProxy, type ProxyPolicy } from '@deepseek-ai/dsh-http-proxy'
+import { installProxyFromEnvironment } from '@deepseek-ai/dsh-http-proxy'
 
 let seen: string[] = []
 let proxy: Server
@@ -21,12 +21,13 @@ beforeAll(async () => {
 })
 afterAll(async () => { await new Promise<void>((r) => { proxy.close(() => { r() }) }) })
 
-function policy(): ProxyPolicy {
-  return { httpProxy: proxyUrl, httpsProxy: proxyUrl, noProxy: '', source: 'env' }
+/** The launch environment of a user who exported one proxy for both schemes. */
+function proxyEnv(): { get(name: string): { value: string } | undefined } {
+  return { get: name => (name === 'HTTP_PROXY' || name === 'HTTPS_PROXY' ? { value: proxyUrl } : undefined) }
 }
 async function observe(run: () => Promise<unknown>): Promise<string[]> {
   seen = []
-  const dispose = await installGlobalProxy(policy())
+  const dispose = await installProxyFromEnvironment(proxyEnv(), () => undefined)
   try { await run().catch(() => undefined) } finally { await dispose() }
   return seen
 }

+ 5 - 4
packages/llm/llm-pi-ai/tests/egress.spec.ts

@@ -1,7 +1,7 @@
 import { createServer, type Server } from 'node:http'
 import type { AddressInfo } from 'node:net'
 import { afterAll, beforeAll, describe, expect, it } from 'vitest'
-import { installGlobalProxy, type ProxyPolicy } from '@deepseek-ai/dsh-http-proxy'
+import { installProxyFromEnvironment } from '@deepseek-ai/dsh-http-proxy'
 
 let seen: string[] = []
 let proxy: Server
@@ -21,12 +21,13 @@ beforeAll(async () => {
 })
 afterAll(async () => { await new Promise<void>((r) => { proxy.close(() => { r() }) }) })
 
-function policy(): ProxyPolicy {
-  return { httpProxy: proxyUrl, httpsProxy: proxyUrl, noProxy: '', source: 'env' }
+/** The launch environment of a user who exported one proxy for both schemes. */
+function proxyEnv(): { get(name: string): { value: string } | undefined } {
+  return { get: name => (name === 'HTTP_PROXY' || name === 'HTTPS_PROXY' ? { value: proxyUrl } : undefined) }
 }
 async function observe(run: () => Promise<unknown>): Promise<string[]> {
   seen = []
-  const dispose = await installGlobalProxy(policy())
+  const dispose = await installProxyFromEnvironment(proxyEnv(), () => undefined)
   try { await run().catch(() => undefined) } finally { await dispose() }
   return seen
 }

+ 5 - 4
packages/mcp/mcp-client/tests/egress.spec.ts

@@ -1,7 +1,7 @@
 import { createServer, type Server } from 'node:http'
 import type { AddressInfo } from 'node:net'
 import { afterAll, beforeAll, describe, expect, it } from 'vitest'
-import { installGlobalProxy, type ProxyPolicy } from '@deepseek-ai/dsh-http-proxy'
+import { installProxyFromEnvironment } from '@deepseek-ai/dsh-http-proxy'
 
 let seen: string[] = []
 let proxy: Server
@@ -21,12 +21,13 @@ beforeAll(async () => {
 })
 afterAll(async () => { await new Promise<void>((r) => { proxy.close(() => { r() }) }) })
 
-function policy(): ProxyPolicy {
-  return { httpProxy: proxyUrl, httpsProxy: proxyUrl, noProxy: '', source: 'env' }
+/** The launch environment of a user who exported one proxy for both schemes. */
+function proxyEnv(): { get(name: string): { value: string } | undefined } {
+  return { get: name => (name === 'HTTP_PROXY' || name === 'HTTPS_PROXY' ? { value: proxyUrl } : undefined) }
 }
 async function observe(run: () => Promise<unknown>): Promise<string[]> {
   seen = []
-  const dispose = await installGlobalProxy(policy())
+  const dispose = await installProxyFromEnvironment(proxyEnv(), () => undefined)
   try { await run().catch(() => undefined) } finally { await dispose() }
   return seen
 }

+ 2 - 2
packages/session/session-telemetry-otel/package.json

@@ -29,11 +29,11 @@
   "dependencies": {
     "@opentelemetry/api": "^1.9.1",
     "@opentelemetry/api-logs": "^0.220.0",
-    "@opentelemetry/exporter-logs-otlp-http": "^0.220.0",
     "@opentelemetry/otlp-exporter-base": "^0.220.0",
     "@opentelemetry/resources": "^2.9.0",
     "@opentelemetry/sdk-logs": "^0.220.0",
-    "@deepseek-ai/schemastery": "workspace:^"
+    "@deepseek-ai/schemastery": "workspace:^",
+    "@opentelemetry/otlp-transformer": "^0.220.0"
   },
   "peerDependencies": {
     "@deepseek-ai/dsh-command-feedback": "workspace:^",

+ 50 - 18
packages/session/session-telemetry-otel/src/index.ts

@@ -31,9 +31,11 @@ import {
   LoggerProvider,
   type BatchLogRecordProcessorOptions,
 } from '@opentelemetry/sdk-logs'
-import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http'
-import { createNodeHttpAgent } from '@deepseek-ai/dsh-http-proxy'
-import type { OTLPExporterNodeConfigBase } from '@opentelemetry/otlp-exporter-base'
+import { OTLPExporterBase } from '@opentelemetry/otlp-exporter-base'
+import { createLegacyOtlpBrowserExportDelegate } from '@opentelemetry/otlp-exporter-base/browser-http'
+import { JsonLogsSerializer } from '@opentelemetry/otlp-transformer'
+import type { OTLPExporterConfigBase } from '@opentelemetry/otlp-exporter-base'
+import type { ReadableLogRecord } from '@opentelemetry/sdk-logs'
 import { SeverityNumber, type AnyValue, type Logger } from '@opentelemetry/api-logs'
 import { resourceFromAttributes } from '@opentelemetry/resources'
 
@@ -94,11 +96,15 @@ export interface Config {
   mode?: SessionTelemetryMode
   /**
    * Passed verbatim to the SDK's OTLP/HTTP log exporter — the complete
-   * `OTLPExporterNodeConfigBase` shape (`headers`, `timeoutMillis`,
-   * `compression`, `keepAlive`, …), owned and documented by the SDK. `url`
-   * is the one field this package requires and validates itself.
+   * `OTLPExporterConfigBase` shape (`headers`, `timeoutMillis`,
+   * `concurrencyLimit`, …), owned and documented by the SDK. `url` is the
+   * one field this package requires and validates itself.
+   *
+   * The transport is the SDK's `fetch` one, so the three options that exist
+   * only for its `node:http` transport — `compression`, `keepAlive`, and
+   * `httpAgentOptions` — are refused at load rather than ignored.
    */
-  exporter?: OTLPExporterNodeConfigBase & {
+  exporter?: OTLPExporterConfigBase & {
     /** Full logs endpoint (e.g. `https://collector.example.com/v1/logs`). Required outside `DISABLED`; validated at load. */
     url?: string
   }
@@ -132,6 +138,12 @@ export const DEFAULT_SHUTDOWN_TIMEOUT_MILLIS = 3_000
 // protocol limit, not a deployment default.
 const MAX_TIMER_DELAY_MILLIS = 2_147_483_647
 
+/**
+ * Exporter options the SDK defines only for its `node:http` transport. They reach the `fetch`
+ * transport this package uses, which silently ignores every one of them.
+ */
+const NODE_TRANSPORT_ONLY_EXPORTER_OPTIONS = ['compression', 'keepAlive', 'httpAgentOptions'] as const
+
 /** Severity mapping from the Service Definition's three-level vocabulary to OTel severity numbers. */
 const SEVERITY: Record<SessionTelemetrySeverity, { severityNumber: SeverityNumber; severityText: string }> = {
   info: { severityNumber: SeverityNumber.INFO, severityText: 'INFO' },
@@ -168,7 +180,8 @@ export class OpenTelemetrySessionBackend extends SessionTelemetryBackend {
       return
     }
 
-    const url = config.exporter?.url
+    const exporter = config.exporter ?? {}
+    const url = exporter.url
     if (url === undefined || url.length === 0) {
       throw new Error('session-telemetry-otel: exporter.url is required (the full OTLP logs endpoint)')
     }
@@ -182,6 +195,13 @@ export class OpenTelemetrySessionBackend extends SessionTelemetryBackend {
     if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
       throw new Error(`session-telemetry-otel: exporter.url must be http(s), got ${parsed.protocol}`)
     }
+    // Options that exist only for the SDK's `node:http` transport, which this package no longer
+    // uses. The exporter would accept and ignore each one, so a deployment that asked for gzip
+    // would quietly send uncompressed batches; refusing at load is what makes the change visible.
+    const nodeOnly = NODE_TRANSPORT_ONLY_EXPORTER_OPTIONS.filter(name => name in exporter)
+    if (nodeOnly.length > 0) {
+      throw new Error(`session-telemetry-otel: exporter.${nodeOnly.join(', exporter.')} not supported: telemetry is exported through fetch so a configured proxy carries it, and the node:http transport those options belong to would need an http.Agent this package no longer builds`)
+    }
     // The one processor field checked beyond the SDK's own validation: the
     // SDK accepts a non-positive batch size, but its shutdown drain then
     // splices empty batches without consuming the queue — dispose would hang
@@ -214,16 +234,28 @@ export class OpenTelemetrySessionBackend extends SessionTelemetryBackend {
           // (service.name/version); the transport-level user-agent is the
           // SDK's own, per the axiom.
           //
-          // The one added default is the agent. On Node this exporter posts through `node:http`,
-          // which undici's global dispatcher does not reach, so telemetry would be the one egress
-          // that ignores a configured proxy. A composition supplying its own `httpAgentOptions`
-          // keeps it; one supplying only `keepAlive` still decides it, because the SDK stops
-          // interpreting that field the moment an agent factory is present.
-          exporter: new OTLPLogExporter({
-            httpAgentOptions: (protocol: string) =>
-              createNodeHttpAgent(protocol, { keepAlive: config.exporter?.keepAlive ?? true }),
-            ...config.exporter,
-          }),
+          // The delegate is the SDK's `fetch` one rather than its Node `node:http` one. Both are
+          // published entry points of the same package; the `fetch` transport reaches undici's
+          // global dispatcher, so a configured proxy carries telemetry with no proxy-aware code
+          // here and with no Node-version floor. The Node transport would need an `http.Agent`,
+          // and Node only learned to route one from the environment in 22.21 and 24.5.
+          //
+          // What that costs: `compression` is a Node-transport option and has no effect here.
+          //
+          // The delegate is deprecated in favour of `createOtlpFetchExportDelegate`, which the SDK
+          // exports from no public subpath at 0.220 — this legacy wrapper is the only supported way
+          // to reach it, and does nothing but call it. Composing the public
+          // `createOtlpNetworkExportDelegate` instead would mean owning the fetch transport and its
+          // retry wrapper, both SDK-internal.
+          exporter: new OTLPExporterBase<ReadableLogRecord[]>(
+            // oxlint-disable-next-line typescript/no-deprecated -- the SDK exports its replacement from no public subpath at 0.220.
+            createLegacyOtlpBrowserExportDelegate(
+              exporter,
+              JsonLogsSerializer,
+              'v1/logs',
+              { 'Content-Type': 'application/json' },
+            ),
+          ),
         }),
       ],
     })

+ 18 - 54
packages/session/session-telemetry-otel/tests/egress.spec.ts

@@ -1,7 +1,7 @@
-import { createServer, type Server } from 'node:http'
+import http, { createServer, type Server } from 'node:http'
 import type { AddressInfo } from 'node:net'
 import { afterAll, beforeAll, describe, expect, it } from 'vitest'
-import { installGlobalProxy, type ProxyPolicy } from '@deepseek-ai/dsh-http-proxy'
+import { installProxyFromEnvironment } from '@deepseek-ai/dsh-http-proxy'
 
 let seen: string[] = []
 let proxy: Server
@@ -21,12 +21,13 @@ beforeAll(async () => {
 })
 afterAll(async () => { await new Promise<void>((r) => { proxy.close(() => { r() }) }) })
 
-function policy(): ProxyPolicy {
-  return { httpProxy: proxyUrl, httpsProxy: proxyUrl, noProxy: '', source: 'env' }
+/** The launch environment of a user who exported one proxy for both schemes. */
+function proxyEnv(): { get(name: string): { value: string } | undefined } {
+  return { get: name => (name === 'HTTP_PROXY' || name === 'HTTPS_PROXY' ? { value: proxyUrl } : undefined) }
 }
 async function observe(run: () => Promise<unknown>): Promise<string[]> {
   seen = []
-  const dispose = await installGlobalProxy(policy())
+  const dispose = await installProxyFromEnvironment(proxyEnv(), () => undefined)
   try { await run().catch(() => undefined) } finally { await dispose() }
   return seen
 }
@@ -65,61 +66,24 @@ async function exportThroughBackend(host: string, exporter: Record<string, unkno
 }
 
 
-/**
- * Whether this runtime's `http.Agent` honors `proxyEnv`, which is how the OTLP exporter reaches a
- * proxy. Added in Node 24.5 and backported to 22.21; the engines range admits 22.19, 22.20, and
- * 24.0–24.4, where telemetry 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)
-}
-
 describe('session-telemetry-otel egress', () => {
   it('exports through the proxy', async () => {
     const observed = (await observe(() => exportThroughBackend('otel-proxied.invalid'))).join('|')
-    // An older runtime ignores the unknown `proxyEnv` option and keeps telemetry direct — the
-    // documented seam, asserted rather than left to chance.
-    if (supportsAgentProxyEnv()) expect(observed).toContain('otel-proxied.invalid')
-    else expect(observed).toBe('')
+    // No runtime gate: the exporter posts through `fetch`, which resolves undici's global
+    // dispatcher on every Node this repository supports. The SDK's own `node:http` transport would
+    // have needed `http.Agent`'s `proxyEnv`, which arrived in 22.21 and 24.5 — inside the engines
+    // range, so telemetry would have stayed direct on 22.19, 22.20, and 24.0–24.4.
+    expect(observed).toContain('otel-proxied.invalid')
   })
 
-  it('reaches no proxy without the agent this package supplies — the gap it closes', async () => {
-    const observed = await observe(() => exportThroughBackend('otel-direct.invalid', {
-      httpAgentOptions: async (protocol: string) => {
-        const core = protocol === 'https:' ? await import('node:https') : await import('node:http')
-        return new core.Agent({ keepAlive: false })
-      },
+  it('reaches no proxy over node:http — the transport this exporter no longer uses', async () => {
+    const observed = await observe(() => new Promise<void>((resolve) => {
+      // The mechanism behind the case above, asserted rather than described: a global dispatcher is
+      // undici's, and `node:http` never consults it. An exporter built on the SDK's Node transport
+      // would take this path and leave telemetry direct however the proxy is configured.
+      http.get('http://otel-direct.invalid/v1/logs', (response) => { response.resume(); resolve() })
+        .on('error', () => { resolve() })
     }))
-    // The SDK's own default agent is this shape. Restoring it must fail loudly here rather than
-    // silently un-proxying telemetry on an upgrade. A per-test host keeps a late-arriving export
-    // from an earlier case out of this assertion.
     expect(observed.join('|')).not.toContain('otel-direct.invalid')
   })
 })
-
-describe('session-telemetry-otel exporter passthrough', () => {
-  it('lets a composition keep its own agent factory, which then owns the routing', async () => {
-    let called = 0
-    await observe(() => exportThroughBackend('otel-passthrough.invalid', {
-      httpAgentOptions: async () => {
-        called++
-        const core = await import('node:http')
-        return new core.Agent({ keepAlive: false })
-      },
-    }))
-    // The exporter option is documented as verbatim passthrough: a composition that supplies its own
-    // factory owns the transport, and this package's default must step aside.
-    expect(called).toBeGreaterThan(0)
-  })
-
-  it('honors exporter.keepAlive on the agent this package supplies', async () => {
-    const { createNodeHttpAgent } = await import('@deepseek-ai/dsh-http-proxy')
-    const agent = await createNodeHttpAgent('http:', { keepAlive: false })
-    try {
-      expect((agent as unknown as { options: { keepAlive?: boolean } }).options.keepAlive).toBe(false)
-    } finally {
-      agent.destroy()
-    }
-  })
-})

+ 20 - 7
packages/session/session-telemetry-otel/tests/otel.spec.ts

@@ -237,24 +237,37 @@ describe('OpenTelemetrySessionBackend wire', () => {
     const { url, captures } = await mockCollector()
     const ctx = new Context()
     await ctx.plugin(SessionStore)
-    // `compression` is a documented SDK exporter option; the advertised
-    // verbatim passthrough must hand it (and every other field) to the
-    // exporter rather than silently rebuilding url/headers only.
+    // `headers` is a documented SDK exporter option this package neither reads nor rebuilds; the
+    // advertised verbatim passthrough must hand it (and every other field) to the exporter rather
+    // than silently rebuilding url only.
     const fiber = await ctx.plugin(OpenTelemetrySessionBackend, {
       mode: SessionTelemetryMode.FULL,
-      exporter: { url, compression: 'gzip' },
-    } as Config)
-    const session = ctx.sessions.create(SessionId('gzip'), { meta: {} })
+      exporter: { url, headers: { 'x-probe': 'passthrough' } },
+    })
+    const session = ctx.sessions.create(SessionId('passthrough'), { meta: {} })
     session.append('turn/start', { turn: 1 })
     await fiber.dispose()
 
     expect(captures.length).toBeGreaterThan(0)
-    expect(captures[0]!.headers['content-encoding']).toBe('gzip')
+    expect(captures[0]!.headers['x-probe']).toBe('passthrough')
     const types = allRecords(captures).flatMap(({ record }) =>
       record.attributes?.flatMap(a => a.key === 'event.type' ? [a.value.stringValue] : []) ?? [])
     expect(types).toContain('turn/start')
   })
 
+  it('refuses an exporter option that belongs to the node:http transport', async () => {
+    const { url } = await mockCollector()
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    // Telemetry goes through `fetch` so a configured proxy carries it, and that transport ignores
+    // `compression`. Accepting the option would send uncompressed batches while the configuration
+    // said gzip; the deployment has to see the trade rather than pay it silently.
+    await expect(ctx.plugin(OpenTelemetrySessionBackend, {
+      mode: SessionTelemetryMode.FULL,
+      exporter: { url, compression: 'gzip' },
+    } as unknown as Config)).rejects.toThrow(/exporter\.compression not supported/)
+  })
+
   it('maps warn severity from record policy and leaves the seam flush hint unimplemented', async () => {
     const { url, captures } = await mockCollector()
     const { ctx, fiber } = await boot(url)

+ 2 - 2
packages/subprocess/subprocess/src/index.ts

@@ -9,7 +9,7 @@
  */
 
 import { Context, Service } from '@deepseek-ai/cordis'
-import { childProxyEnv } from '@deepseek-ai/dsh-http-proxy'
+import { proxyEnvironmentForChild } from '@deepseek-ai/dsh-http-proxy'
 import { DSH_ENV_PREFIX } from './types.ts'
 import type { SubprocessHandle, SubprocessSpawnSpec } from './types.ts'
 import type { SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from './types.ts'
@@ -70,7 +70,7 @@ export function scrubbedParentEnv(): Record<string, string> {
   // stdio server or subagent CLI would connect directly while its parent proxies. The same overlay
   // restores each proxy name to what the user exported, undoing this process's own normalization —
   // `undefined` removes a name the user never set.
-  for (const [name, value] of Object.entries(childProxyEnv())) {
+  for (const [name, value] of Object.entries(proxyEnvironmentForChild())) {
     if (value === undefined) Reflect.deleteProperty(env, name)
     else env[name] = value
   }

+ 15 - 17
packages/subprocess/subprocess/tests/egress.spec.ts

@@ -2,11 +2,7 @@ import { createServer, type Server } from 'node:http'
 import { spawn } from 'node:child_process'
 import type { AddressInfo } from 'node:net'
 import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'
-import {
-  PROXY_ENV_NAMES,
-  installGlobalProxy,
-  resolveProxyPolicy,
-} from '@deepseek-ai/dsh-http-proxy'
+import { clearedProxyEnv, installProxyFromEnvironment } from '@deepseek-ai/dsh-http-proxy'
 import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
 import { scrubbedParentEnv } from '../src/index.ts'
 
@@ -49,8 +45,9 @@ afterAll(async () => {
 
 beforeEach(() => {
   seen = []
-  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 names = Object.keys(clearedProxyEnv())
+  saved = Object.fromEntries(names.map(name => [name, process.env[name]]))
+  for (const name of names) Reflect.deleteProperty(process.env, name)
 })
 
 afterEach(() => {
@@ -78,10 +75,10 @@ describe('child process egress', () => {
   it('a child Node honors the proxy the user exported', async () => {
     // The user's own export is what a child inherits, so the scenario starts from one.
     process.env.HTTP_PROXY = proxyUrl
-    const { policy } = resolveProxyPolicy(
+    const dispose = await installProxyFromEnvironment(
       createLaunchEnvironmentSnapshot([{ source: 'process', values: { HTTP_PROXY: proxyUrl } }]),
+      () => undefined,
     )
-    const dispose = await installGlobalProxy(policy)
     let childEnv: Record<string, string> = {}
     try {
       childEnv = scrubbedParentEnv()
@@ -99,10 +96,10 @@ describe('child process egress', () => {
 
   it('a child Node reaches a proxy the user gave only as ALL_PROXY', async () => {
     process.env.ALL_PROXY = proxyUrl
-    const { policy } = resolveProxyPolicy(
+    const dispose = await installProxyFromEnvironment(
       createLaunchEnvironmentSnapshot([{ source: 'process', values: { ALL_PROXY: proxyUrl } }]),
+      () => undefined,
     )
-    const dispose = await installGlobalProxy(policy)
     let childEnv: Record<string, string> = {}
     try {
       childEnv = scrubbedParentEnv()
@@ -122,21 +119,22 @@ describe('child process egress', () => {
     // 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(
+    const dispose = await installProxyFromEnvironment(
       createLaunchEnvironmentSnapshot([{
         source: 'process',
         values: { HTTP_PROXY: proxyUrl, https_proxy: 'socks5://127.0.0.1:1080' },
       }]),
+      () => undefined,
     )
-    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)
+      // The bypass list is always the resolved one; the user set none, so it is the loopback
+      // entries alone — without them the child sends its own localhost traffic to the proxy.
+      expect(child.NO_PROXY).toBe('localhost,127.0.0.1,::1,[::1]')
     } finally {
       await dispose()
     }
@@ -144,10 +142,10 @@ describe('child process egress', () => {
 
   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(
+    const dispose = await installProxyFromEnvironment(
       createLaunchEnvironmentSnapshot([{ source: 'process', values: { HTTP_PROXY: proxyUrl } }]),
+      () => undefined,
     )
-    const dispose = await installGlobalProxy(policy)
     try {
       // 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

+ 1 - 9
packages/test-support/loader-smoke/src/index.ts

@@ -11,7 +11,7 @@
  * @module @deepseek-ai/dsh-loader-smoke
  */
 
-import { PROXY_ENV_NAMES } from '@deepseek-ai/dsh-http-proxy'
+import { clearedProxyEnv } from '@deepseek-ai/dsh-http-proxy'
 import { mkdtemp, rm } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
@@ -31,14 +31,6 @@ export const LOADER_SMOKE_TEST_TIMEOUT_MS = DEFAULT_PROCESS_TIMEOUT_MS + 15_000
 /** Which artifact an example bin is booted from: unbuilt `src` via tsx, or built `lib` via plain Node. */
 export type ExampleMode = 'src' | 'lib'
 
-/**
- * Proxy names cleared from every smoke child.
- * @returns an environment overlay removing each name that carries proxy configuration.
- */
-function clearedProxyEnv(): NodeJS.ProcessEnv {
-  return Object.fromEntries(PROXY_ENV_NAMES.map(name => [name, undefined]))
-}
-
 /** Environment variable selecting the mode; CI sets it to `lib`, dev leaves it unset (`src`). */
 export const EXAMPLE_MODE_ENV = 'DSH_EXAMPLE_MODE'
 

+ 1 - 9
packages/test-support/session-snapshot/src/harness.ts

@@ -35,17 +35,9 @@ import {
   type AgentUnderTest,
   type LaunchedAcpTestAgent,
 } from './launcher.ts'
-import { PROXY_ENV_NAMES } from '@deepseek-ai/dsh-http-proxy'
+import { clearedProxyEnv } from '@deepseek-ai/dsh-http-proxy'
 import { captureWorkspaceSnapshot, type WorkspaceSnapshotEntry } from './workspace.ts'
 
-/**
- * Proxy names removed from every replayed child.
- * @returns an environment overlay removing each name that carries proxy configuration.
- */
-function clearedProxyEnv(): NodeJS.ProcessEnv {
-  return Object.fromEntries(PROXY_ENV_NAMES.map(name => [name, undefined]))
-}
-
 export type { AgentUnderTest } from './launcher.ts'
 
 const DEFAULT_WAIT_TIMEOUT_MS = 10_000

+ 2 - 2
packages/util/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/util/http-proxy/README.md
-README.md: 1d27fc936c1921eb5b6e358cc4341fdfa5b5b52c
-README.zh.md: 7aad74ae76907aad8dc8d5f8716dd84a5e48a9de
+README.md: 2bad73fa7ab670d73adab6e9a82602b0fa374752
+README.zh.md: 85f49c2e33d2064f49acba9df2c935d81c36b175

+ 14 - 9
packages/util/http-proxy/README.md

@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-Node's built-in `fetch` ignores `HTTP_PROXY` and `HTTPS_PROXY`, so a harness behind a proxy would connect directly no matter what the user exported — the LLM request, every web search, MCP over HTTP, telemetry, and the sandbox SDK alike. This package resolves one proxy policy from the launcher's environment snapshot and installs it as undici's global dispatcher, which is exactly what `fetch` resolves. Ordinary call sites therefore need no change and no import: they write `fetch()` and are proxied. The package also owns the three places a global dispatcher cannot reach — a caller that needs its own agent options, a worker thread with its own `globalThis`, and a spawned child Node — and gives each one a single supported way through.
+Node's built-in `fetch` ignores `HTTP_PROXY` and `HTTPS_PROXY`, so a harness behind a proxy would connect directly no matter what the user exported — the LLM request, every web search, MCP over HTTP, telemetry, and the sandbox SDK alike. This package resolves one proxy policy from the launcher's environment snapshot and installs it as undici's global dispatcher, which is exactly what `fetch` resolves. Ordinary call sites therefore need no change and no import: they write `fetch()` and are proxied. Four functions cover everything the global dispatcher cannot reach on its own — install the policy, ask where one request goes, hand the policy to a spawned child, and strip it for a replay.
 
 ## Table of Contents
 
@@ -33,12 +33,17 @@ Plain `fetch()` is proxied, and so is any SDK that reaches `globalThis.fetch` 
 
 | You are writing | Use |
 |---|---|
-| A call needing its own agent options (pool size, timeouts, a DNS lookup) | `createDispatcher(url, options)` |
-| An SDK that takes a `node:http` agent | `createNodeHttpAgent(protocol, options)` |
-| An SDK that takes a proxy URL of its own | `proxyUrlFor(url)` |
-| A spawn whose environment you build yourself | apply `childProxyEnv()` to it (`undefined` means remove) |
+| A plain request, or an SDK that reaches `globalThis.fetch` | nothing — the global dispatcher already routes it |
+| A call that must branch on whether this request is proxied | `proxyRouteFor(url)` |
+| An SDK that takes a proxy URL of its own | `proxyRouteFor(url)`, and pass `route.proxy` |
+| A spawn whose environment you build yourself | apply `proxyEnvironmentForChild()` to it (`undefined` means remove) |
+| A harness that must reach its own fixture server | apply `clearedProxyEnv()` to the spawn |
 
-Constructing `new Agent(...)` and passing it as `dispatcher` overrides the global one and silently bypasses the proxy. `verify-no-bare-dispatcher` rejects that outside this package; a line that must genuinely ignore the proxy says so with a `proxy-exempt:` comment.
+`proxyRouteFor` answers with the transport that answer assumed, not just the answer: its proxied arm carries the dispatcher already routing by this policy. A caller that read the policy and then built its own transport could have an unmount land between the two and send the request somewhere its branch never cleared.
+
+An SDK that builds its own transport reaches none of this. The two this repository ships that did — the OTLP exporter and the E2B SDK — were changed to a transport that does: the exporter now posts through `fetch`, and E2B is handed `route.proxy`.
+
+Constructing `new Agent(...)` and passing it as `dispatcher` overrides the global one and silently bypasses the proxy. `verify-no-bare-dispatcher` rejects that outside this package. One call site legitimately owns its transport — `web-fetch-http` pins a request to addresses it validated, which is per-request state a process-wide dispatcher cannot hold — and says so with a `proxy-exempt:` comment on the line.
 
 That gate cannot see inside an SDK, so every outbound call site in the repository also carries an `egress.spec.ts` that drives its real code path through a fake proxy and asserts the proxy saw the request. A new call site adds one. It is the only thing that catches an SDK changing transports underneath us — which is exactly how the OTLP and E2B gaps were found.
 
@@ -68,8 +73,8 @@ A proxy value the package cannot use — a SOCKS or PAC URL, an unparseable stri
 | File | Holds |
 |---|---|
 | `src/policy.ts` | Resolution, bypass matching, and redaction. Imports no transport, so it stays loadable where undici is absent. |
-| `src/install.ts` | The global dispatcher, the active-policy record, `createDispatcher`, and `childProxyEnv`. Imports undici dynamically. |
-| `src/index.ts` | The package face: six functions and the types they use. |
+| `src/install.ts` | The global dispatcher, the active-policy record, the route, and the child environment. Imports undici dynamically. |
+| `src/index.ts` | The package face: four functions and one type. |
 
 ### Bypass matching
 
@@ -103,7 +108,7 @@ These limits define when the package is a poor fit. They are current package con
 
 - **No SOCKS, PAC, or operating-system proxy detection** — only `http(s)://` proxy URLs from the environment. A macOS or Windows system-proxy setting is not read, so a user who only toggled it in a proxy application must still export the variables; a SOCKS URL is reported and that scheme stays direct rather than borrowing another scheme's proxy.
 - **No custom certificate authority** — a TLS-intercepting corporate proxy needs `NODE_EXTRA_CA_CERTS` set on the process before launch, which this package neither sets nor validates.
-- **A separate Node context honors the policy only on a new enough runtime** — a spawned child reads it through Node's `NODE_USE_ENV_PROXY` (22.21+, 24+), and the OTLP exporter's agent through Node's `proxyEnv` option (22.21+, **24.5+**). The engines range admits 22.19, 22.20, and 24.0–24.4, where those two paths stay direct. Such a context also matches bypass entries with Node's own `NO_PROXY` rules, which differ from this package's in their separators and IPv4-range support.
+- **A spawned child honors the policy only on a new enough runtime** — it reads the published environment through Node's `NODE_USE_ENV_PROXY` (22.21+, 24+), and the engines range admits 22.19 and 22.20, where such a child stays direct. A child also matches bypass entries with Node's own `NO_PROXY` rules, which differ from this package's in their separators and IPv4-range support. Nothing in this process depends on a Node version: every in-process request reaches the global dispatcher.
 - **A worker that executes model-authored code gets no proxy at all** — neither the `code-runtime` worker nor the `workflow` worker receives proxy configuration, so their own requests go direct. A proxy URL may carry `user:password`, and both run scripts the model wrote.
 - **The regression gate sees source, not dependencies** — `verify-no-bare-dispatcher` parses `packages/*/*/src` and `apps/*/src`; tests, scripts, and the internals of a third-party SDK are outside it. That is why every outbound call site also carries an `egress.spec.ts`.
 

+ 15 - 10
packages/util/http-proxy/README.zh.md

@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-Node 内置的 `fetch` 会忽略 `HTTP_PROXY` 与 `HTTPS_PROXY`,因此在代理后面运行的 Harness 无论用户导出了什么都会直连——LLM(大语言模型)请求、每次 web 搜索、走 HTTP 的 MCP、遥测与沙箱 SDK 一概如此。本包从启动器的环境快照解析出一份代理策略,并把它装成 undici 的全局 dispatcher,而这正是 `fetch` 解析的对象。因此普通调用点无需改动、也无需引入本包:写 `fetch()` 就已经走代理。本包还负责全局 dispatcher 覆盖不到的三处——需要自定义 agent 选项的调用方、拥有独立 `globalThis` 的 worker 线程、以及派生出的子 Node 进程——并为每一处给出唯一受支持的走法。
+Node 内置的 `fetch` 会忽略 `HTTP_PROXY` 与 `HTTPS_PROXY`,因此在代理后面运行的 Harness 无论用户导出了什么都会直连——LLM(大语言模型)请求、每次 web 搜索、走 HTTP 的 MCP、遥测与沙箱 SDK 一概如此。本包从启动器的环境快照解析出一份代理策略,并把它装成 undici 的全局 dispatcher,而这正是 `fetch` 解析的对象。因此普通调用点无需改动、也无需引入本包:写 `fetch()` 就已经走代理。全局 dispatcher 自身够不到的场合由四个函数覆盖——安装策略、询问某个请求怎么发、把策略交给派生的子进程、以及为重放清掉它。
 
 ## 目录
 
@@ -29,16 +29,21 @@ Node 内置的 `fetch` 会忽略 `HTTP_PROXY` 与 `HTTPS_PROXY`,因此在代
 
 ### 编写新的出站调用
 
-普通 `fetch()` 已经走代理,任何最终落到 `globalThis.fetch` 的 SDK 也一样——MCP HTTP 传输与 pi-ai 提供方栈都是如此。但自建传输的 SDK **不会**,而本仓库随附的 SDK 里就有两个属于此类:OTLP 导出器通过 `node:http` 投递,E2B SDK 自建 undici dispatcher。不要对任何 SDK 想当然,去查。
+普通 `fetch()` 已经走代理,任何最终落到 `globalThis.fetch` 的 SDK 也一样——MCP HTTP 传输与 pi-ai 提供方栈都是如此。不要对任何 SDK 想当然,去查。
 
 | 你要写的东西 | 使用 |
 |---|---|
-| 需要自定义 agent 选项的调用(连接池、超时、DNS 查询) | `createDispatcher(url, options)` |
-| 接受 `node:http` agent 的 SDK | `createNodeHttpAgent(protocol, options)` |
-| 接受自有代理 URL 的 SDK | `proxyUrlFor(url)` |
-| 由你自己构造环境的派生进程 | 把 `childProxyEnv()` 应用到它上面(`undefined` 表示删除) |
+| 普通请求,或最终落到 `globalThis.fetch` 的 SDK | 什么都不用——全局 dispatcher 已经在路由它 |
+| 需要按“这次请求是否走代理”分支的调用 | `proxyRouteFor(url)` |
+| 接受自有代理 URL 的 SDK | `proxyRouteFor(url)`,把 `route.proxy` 传进去 |
+| 由你自己构造环境的派生进程 | 把 `proxyEnvironmentForChild()` 应用到它上面(`undefined` 表示删除) |
+| 必须连到自带 fixture 服务器的测试框架 | 把 `clearedProxyEnv()` 应用到该派生进程 |
 
-构造 `new Agent(...)` 再作为 `dispatcher` 传入会覆盖全局 dispatcher,从而静默绕开代理。`verify-no-bare-dispatcher` 会在本包之外拒绝该写法;确实必须忽略代理的行用 `proxy-exempt:` 注释说明理由。
+`proxyRouteFor` 给出的不只是答案,还有该答案所假定的传输:走代理的那一支携带着此刻正按该策略路由的 dispatcher。若调用方先读策略、再自建传输,卸载就可能落在两次读取之间,把请求发往其分支从未放行的去处。
+
+自建传输的 SDK 接触不到上述任何一条。本仓库随附的两个此类 SDK 都已改到能被覆盖的传输上:OTLP 导出器改为通过 `fetch` 投递,E2B 则接收 `route.proxy`。
+
+构造 `new Agent(...)` 再作为 `dispatcher` 传入会覆盖全局 dispatcher,从而静默绕开代理。`verify-no-bare-dispatcher` 会在本包之外拒绝该写法。有一处调用点确实自有传输——`web-fetch-http` 会把请求钉在它已校验过的地址上,而这是进程级 dispatcher 无法承载的单次请求状态——它在该行用 `proxy-exempt:` 注释说明。
 
 该门禁看不进 SDK 内部,因此仓库中每一个出网点都另有一份 `egress.spec.ts`:它驱动该点的真实代码路径穿过一个假代理,并断言代理确实收到了请求。新增出网点就补一份。它是唯一能发现 SDK 在我们脚下更换传输的手段——OTLP 与 E2B 这两个漏洞正是这样被发现的。
 
@@ -68,8 +73,8 @@ loopback 始终被绕过——`localhost`、整个 `127.0.0.0/8` 段、`::1`、`
 | 文件 | 承载 |
 |---|---|
 | `src/policy.ts` | 解析、绕过匹配与脱敏。不引入任何传输实现,因此在没有 undici 的环境中仍可加载。 |
-| `src/install.ts` | 全局 dispatcher、生效策略记录、`createDispatcher` 与 `childProxyEnv`。动态引入 undici。 |
-| `src/index.ts` | 本包的对外面:六个函数及其使用的类型。 |
+| `src/install.ts` | 全局 dispatcher、生效策略记录、路由与子进程环境。动态引入 undici。 |
+| `src/index.ts` | 本包的对外面:四个函数与一个类型。 |
 
 ### 绕过匹配
 
@@ -103,7 +108,7 @@ loopback 始终被绕过——`localhost`、整个 `127.0.0.0/8` 段、`::1`、`
 
 - **不支持 SOCKS、PAC 或操作系统代理探测**——只接受来自环境的 `http(s)://` 代理 URL。不会读取 macOS 或 Windows 的系统代理设置,因此仅在代理软件里拨了开关的用户仍须导出环境变量;SOCKS URL 会被报告,且该协议保持直连,不会借用另一协议的代理。
 - **不支持自定义证书颁发机构**——做 TLS 拦截的企业代理需要在启动前为进程设置 `NODE_EXTRA_CA_CERTS`,本包既不设置也不校验它。
-- **独立的 Node 上下文只在足够新的运行时上遵循策略**——派生的子进程通过 Node 的 `NODE_USE_ENV_PROXY` 读取(22.21+、24+),OTLP 导出器的 agent 则通过 Node 的 `proxyEnv` 选项(22.21+、**24.5+**)。engines 范围允许 22.19、22.20 与 24.0–24.4,在这些版本上这两条路径保持直连。此类上下文还会按 Node 自己的 `NO_PROXY` 规则匹配绕过条目,其分隔符与 IPv4 区间处理与本包不同。
+- **派生的子进程只在足够新的运行时上遵循策略**——它通过 Node 的 `NODE_USE_ENV_PROXY` 读取已发布的环境(22.21+、24+),而 engines 范围允许 22.19 与 22.20,在这两个版本上这样的子进程保持直连。子进程还会按 Node 自己的 `NO_PROXY` 规则匹配绕过条目,其分隔符与 IPv4 区间处理与本包不同。本进程内不依赖任何 Node 版本:每一次进程内请求都会落到全局 dispatcher。
 - **执行模型编写代码的 worker 完全不获得代理**——`code-runtime` worker 与 `workflow` worker 都不接收代理配置,它们自身的请求直连。代理 URL 可能携带 `user:password`,而两者运行的都是模型写的脚本。
 - **防回归门禁只看源码,看不到依赖内部**——`verify-no-bare-dispatcher` 解析 `packages/*/*/src` 与 `apps/*/src`;测试、脚本以及第三方 SDK 的内部都在其之外。这正是每个出网点还各配一份 `egress.spec.ts` 的原因。
 

+ 11 - 25
packages/util/http-proxy/src/index.ts

@@ -2,36 +2,22 @@
  * Outbound HTTP proxy support for DeepSeek Harness.
  *
  * Node's built-in `fetch` ignores `HTTP_PROXY` and friends, so every harness request would connect
- * directly no matter what the user exported. This library resolves one policy from the launch
+ * directly no matter what the user exported. The launcher resolves one policy from the launch
  * environment and installs it as undici's global dispatcher, which is what `fetch` resolves — so
- * LLM adapters, web search, MCP over HTTP, telemetry, and sandbox SDKs are all covered without
- * touching their code.
+ * LLM adapters, web search, MCP over HTTP, and telemetry are covered without touching their code.
  *
- * The launcher resolves and installs once, before the first plugin mounts. This is a library, not a
- * plugin: transport policy has one answer per process, so there is nothing for a composition to
- * mount, swap, or scope.
+ * This is a library, not a plugin: transport policy has one answer per process, so there is nothing
+ * for a composition to mount, swap, or scope.
  *
- * Six exports, one per way a caller can need the policy: resolve it, install it, get a dispatcher
- * for a request, get a `node:http` agent for an SDK that takes one, get a proxy URL for an SDK that
- * takes that, and get the environment a spawned child needs.
+ * Four functions, one per way a caller needs the policy — install it, ask how to send one request,
+ * build a child's environment, and strip the ambient one for a replay.
  * @module @deepseek-ai/dsh-http-proxy
  */
 
 export {
-  proxyForUrl,
-  resolveProxyPolicy,
-  PROXY_ENV_NAMES,
-  type EnvLookup,
-  type ProxyDiagnostic,
-  type ProxyPolicy,
-  type ProxyResolution,
-} from './policy.ts'
-
-export {
-  childProxyEnv,
-  createDispatcher,
-  createNodeHttpAgent,
-  currentProxyPolicy,
-  installGlobalProxy,
-  proxyUrlFor,
+  clearedProxyEnv,
+  installProxyFromEnvironment,
+  proxyEnvironmentForChild,
+  proxyRouteFor,
+  type ProxyRoute,
 } from './install.ts'

+ 83 - 78
packages/util/http-proxy/src/install.ts

@@ -1,15 +1,21 @@
 /**
- * Proxy installation: the transport half of this package. It owns undici's global dispatcher, the
- * process-wide record of which policy is active, and the dispatcher factory every other package uses
- * instead of constructing a bare agent.
+ * Proxy installation: the transport half of this package. It owns undici's global dispatcher and the
+ * process-wide record of which policy is active.
  *
  * `undici` is imported dynamically so the pure {@link ProxyPolicy} half stays loadable where no Node
  * transport exists, matching how `dsh-web-fetch-http` defers its own transport import.
  * @module @deepseek-ai/dsh-http-proxy/install
  */
 
-import type { Agent, Dispatcher, Pool } from 'undici'
-import { DIRECT_POLICY, POLICY_ENV_NAMES, proxyForUrl, type ProxyPolicy } from './policy.ts'
+import type { Dispatcher, Pool } from 'undici'
+import {
+  POLICY_ENV_NAMES,
+  PROXY_ENV_NAMES,
+  proxyForUrl,
+  resolveProxyPolicy,
+  type EnvLookup,
+  type ProxyPolicy,
+} from './policy.ts'
 
 
 /** The active policy, or `undefined` until one is installed. Process-wide, like the dispatcher it tracks. */
@@ -22,21 +28,42 @@ let active: ProxyPolicy | undefined
  * 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
+ * {@link proxyEnvironmentForChild} 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
 
+/** The dispatcher installed with {@link active}, so a route can hand back the one already routing. */
+let installed: Dispatcher | undefined
+
 /**
- * The policy governing this process's outbound requests.
+ * How this process must send one request.
  *
- * A caller that branches on the answer must hold this value and pass it back to
- * {@link createDispatcher}: reading it twice lets an install or disposal land between the two reads.
+ * A caller that branches on the answer needs the transport that answer assumed, or an install or
+ * disposal landing between the two would send the request somewhere the branch did not clear. The
+ * proxied arm therefore carries the dispatcher already routing by this policy: it is process-wide
+ * and long-lived, so a caller uses it and never closes it. Disposal closes that dispatcher rather
+ * than destroying it, so a request already dispatched when a policy is unmounted still finishes.
+ */
+export type ProxyRoute =
+  | { readonly proxied: true; readonly proxy: string; readonly dispatcher: Dispatcher }
+  | { readonly proxied: false }
+
+/** A route that sends nothing through a proxy, shared because it carries no per-request state. */
+const DIRECT_ROUTE: ProxyRoute = { proxied: false }
+
+/**
+ * Decide how to send one request, and hand back the transport that decision assumed.
  *
- * @returns the installed policy, or the direct one when {@link installGlobalProxy} has not run.
+ * @param url - the request URL.
+ * @returns the proxied route with its proxy URL and dispatcher, or the direct route.
  */
-export function currentProxyPolicy(): ProxyPolicy {
-  return active ?? DIRECT_POLICY
+export function proxyRouteFor(url: URL): ProxyRoute {
+  const policy = active
+  const dispatcher = installed
+  if (policy === undefined || dispatcher === undefined) return DIRECT_ROUTE
+  const proxy = proxyForUrl(policy, url)
+  return proxy === undefined ? DIRECT_ROUTE : { proxied: true, proxy, dispatcher }
 }
 
 /**
@@ -117,7 +144,7 @@ async function createPolicyDispatcher(policy: ProxyPolicy): Promise<Dispatcher>
  * @param policy - the resolved policy to install.
  * @returns a disposer restoring the previous dispatcher, policy, and environment, then closing the agent.
  */
-export async function installGlobalProxy(policy: ProxyPolicy): Promise<() => Promise<void>> {
+async function installGlobalProxy(policy: ProxyPolicy): Promise<() => Promise<void>> {
   const previousPolicy = active
   if (policy.source === 'none') {
     // A direct policy mounted over an installed one must actually stop proxying. Recording the policy
@@ -131,97 +158,39 @@ export async function installGlobalProxy(policy: ProxyPolicy): Promise<() => Pro
         return Promise.resolve()
       }
     }
+    const previousInstalled = installed
     const undici = await import('undici')
     const previous = undici.getGlobalDispatcher()
     const direct = new undici.Agent()
     undici.setGlobalDispatcher(direct)
     active = policy
+    installed = undefined
     return async () => {
       undici.setGlobalDispatcher(previous)
       active = previousPolicy
+      installed = previousInstalled
       await direct.close()
     }
   }
   const restoreEnv = applyPolicyEnv(policy)
   const { getGlobalDispatcher, setGlobalDispatcher } = await import('undici')
   const previousDispatcher = getGlobalDispatcher()
+  const previousInstalled = installed
   const agent = await createPolicyDispatcher(policy)
   setGlobalDispatcher(agent)
   active = policy
+  installed = agent
   return async () => {
     setGlobalDispatcher(previousDispatcher)
     active = previousPolicy
+    installed = previousInstalled
     restoreEnv()
     await agent.close()
   }
 }
 
-/**
- * Build a dispatcher for one request URL that honors the active policy.
- *
- * Use this wherever a call site needs its own agent options — connection limits, timeouts, a custom
- * DNS lookup. Constructing `new Agent(...)` directly and passing it as `dispatcher` silently bypasses
- * the global one and therefore the proxy, which is the defect this function exists to prevent.
- * `verify-no-bare-dispatcher` enforces that outside this package.
- *
- * @param url - the request URL, which decides whether the policy proxies or bypasses it.
- * @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 = {},
-  policy: ProxyPolicy = active ?? DIRECT_POLICY,
-): Promise<Dispatcher> {
-  const undici = await import('undici')
-  const proxy = proxyForUrl(policy, url)
-  if (proxy === undefined) return new undici.Agent(options)
-  return new undici.ProxyAgent({ ...options, uri: proxy })
-}
 
-/**
- * Build a `node:http` or `node:https` Agent that honors the active policy.
- *
- * The global dispatcher reaches undici, and therefore `fetch`, but not `node:http`. An SDK that
- * issues requests through the core modules — the OTLP exporter is the one this repository ships —
- * accepts an agent instead, and this is the agent to give it.
- *
- * Node's own `proxyEnv` option does the routing, reading the names {@link installGlobalProxy}
- * published. It reaches Node 22.21+ and 24.5+; an older runtime ignores the unknown option and
- * connects directly, the same seam a spawned child Node has.
- *
- * @param protocol - the target's protocol, `https:` selecting the TLS agent.
- * @param options - agent options merged under the proxy routing.
- * @returns an agent the caller passes to the SDK that needs one.
- */
-export async function createNodeHttpAgent(
-  protocol: string,
-  options: Readonly<Record<string, unknown>> = {},
-): Promise<import('node:http').Agent> {
-  const core = protocol === 'https:' ? await import('node:https') : await import('node:http')
-  const proxied = active !== undefined && active.source !== 'none'
-  // `proxyEnv` postdates the @types/node this workspace pins, so the option is applied through a
-  // widened record rather than the typed constructor overload.
-  const agentOptions = { ...options, ...proxied ? { proxyEnv: process.env } : {} }
-  return new core.Agent(agentOptions as ConstructorParameters<typeof core.Agent>[0])
-}
 
-/**
- * The proxy this URL is reached through, for an SDK that takes a proxy URL of its own rather than a
- * dispatcher or an agent. `undefined` means the SDK should connect directly.
- *
- * @param url - the endpoint the SDK will call.
- * @returns the proxy URL to hand the SDK, or `undefined` for a direct connection.
- */
-export function proxyUrlFor(url: URL): string | undefined {
-  return proxyForUrl(active ?? DIRECT_POLICY, url)
-}
 
 /**
  * The proxy environment a spawned child needs.
@@ -251,7 +220,7 @@ export function proxyUrlFor(url: URL): string | undefined {
  * @returns names to apply to the child environment, where `undefined` means remove, or an empty
  *   object when no proxy is active.
  */
-export function childProxyEnv(): Readonly<Record<string, string | undefined>> {
+export function proxyEnvironmentForChild(): Readonly<Record<string, string | undefined>> {
   const policy = active
   const inherited = inheritedProxyEnv
   if (policy === undefined || policy.source === 'none' || inherited === undefined) return {}
@@ -265,3 +234,39 @@ export function childProxyEnv(): Readonly<Record<string, string | undefined>> {
   }
   return overlay
 }
+
+/**
+ * Resolve this process's proxy policy from `env` and install it.
+ *
+ * Resolution, reporting, and installation are one operation because no caller needs them apart: the
+ * launcher does all three in sequence before the first plugin mounts, and a policy resolved but not
+ * installed routes nothing.
+ *
+ * A value the environment supplies but this package cannot use is reported and skipped rather than
+ * thrown: the variable may have been exported for another tool, and a proxy the harness cannot use
+ * must not stop the agent from starting.
+ *
+ * @param env - the launch environment, whose own layering already prefers real variables over `.env` files.
+ * @param report - receives one message per rejected value, in the order the values were considered.
+ * @returns a disposer restoring the previous dispatcher, policy, and environment.
+ */
+export async function installProxyFromEnvironment(
+  env: EnvLookup,
+  report: (message: string) => void,
+): Promise<() => Promise<void>> {
+  const { policy, diagnostics } = resolveProxyPolicy(env)
+  for (const diagnostic of diagnostics) report(diagnostic.message)
+  return await installGlobalProxy(policy)
+}
+
+/**
+ * The environment overlay that removes every proxy name from a spawned child.
+ *
+ * A harness that replays a recorded session must reach its own fixture server, not the proxy a
+ * developer or a CI runner exported; `undefined` is how a spawn removes a name it inherits.
+ *
+ * @returns one entry per proxy name, each `undefined`.
+ */
+export function clearedProxyEnv(): Record<string, undefined> {
+  return Object.fromEntries(PROXY_ENV_NAMES.map(name => [name, undefined]))
+}

+ 179 - 236
packages/util/http-proxy/tests/install.spec.ts

@@ -2,17 +2,13 @@ import { createServer, type Server } from 'node:http'
 import type { AddressInfo } from 'node:net'
 import { afterEach, beforeAll, afterAll, describe, expect, it } from 'vitest'
 import { getGlobalDispatcher } from 'undici'
-import http from 'node:http'
-import https from 'node:https'
 import {
-  childProxyEnv,
-  createDispatcher,
-  createNodeHttpAgent,
-  currentProxyPolicy,
-  installGlobalProxy,
-  proxyUrlFor,
-} from '../src/install.ts'
-import { DIRECT_POLICY, PROXY_ENV_NAMES, type ProxyPolicy } from '../src/policy.ts'
+  clearedProxyEnv,
+  installProxyFromEnvironment,
+  proxyEnvironmentForChild,
+  proxyRouteFor,
+} from '../src/index.ts'
+import { PROXY_ENV_NAMES } from '../src/policy.ts'
 
 /** Absolute-form request targets the fake proxy received; a populated entry proves a request was tunnelled. */
 let proxied: string[] = []
@@ -65,14 +61,42 @@ afterEach(() => {
 /** 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' }
+/** A launch environment built from the names a user would export, in the casings they wrote. */
+function env(values: Record<string, string>): { get(name: string): { value: string } | undefined } {
+  return { get: name => (name in values ? { value: values[name] as string } : undefined) }
 }
 
-describe('installGlobalProxy', () => {
+/** The environment of a user who exported one proxy for both schemes. */
+function proxyAll(noProxy?: string): { get(name: string): { value: string } | undefined } {
+  return env({ HTTP_PROXY: proxyUrl, HTTPS_PROXY: proxyUrl, ...noProxy === undefined ? {} : { NO_PROXY: noProxy } })
+}
+
+/** Install and collect whatever the resolution reported, so a case can assert on both. */
+async function install(
+  lookup: { get(name: string): { value: string } | undefined },
+): Promise<{ dispose: () => Promise<void>; reported: string[] }> {
+  const reported: string[] = []
+  const dispose = await installProxyFromEnvironment(lookup, (message) => { reported.push(message) })
+  return { dispose, reported }
+}
+
+/** Run one case from a known-empty proxy environment, then restore what the machine had. */
+async function withCleanProxyEnv(run: () => Promise<void>): Promise<void> {
+  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)
+  try {
+    await run()
+  } finally {
+    for (const [name, value] of Object.entries(saved)) {
+      if (value === undefined) Reflect.deleteProperty(process.env, name)
+      else process.env[name] = value
+    }
+  }
+}
+
+describe('installProxyFromEnvironment', () => {
   it('routes the built-in global fetch through the proxy', async () => {
-    const dispose = await installGlobalProxy(proxyAll())
+    const { dispose } = await install(proxyAll())
     try {
       await expect((await fetch(proxyTarget)).text()).resolves.toBe('VIA-PROXY')
       expect(proxied).toEqual([`GET ${proxyTarget}`])
@@ -82,22 +106,38 @@ describe('installGlobalProxy', () => {
   })
 
   it('connects directly when the bypass list covers the target', async () => {
-    const dispose = await installGlobalProxy(proxyAll('127.0.0.1'))
+    const { dispose } = await install(env({ HTTP_PROXY: proxyUrl, NO_PROXY: 'origin.test' }))
     try {
-      await expect((await fetch(originUrl)).text()).resolves.toBe('DIRECT')
+      await expect(fetch(proxyTarget, { signal: AbortSignal.timeout(1500) })).rejects.toThrow()
       expect(proxied).toEqual([])
     } finally {
       await dispose()
     }
   })
 
+  it('reports a value it cannot use and installs the rest', async () => {
+    const { dispose, reported } = await install(env({ HTTP_PROXY: proxyUrl, HTTPS_PROXY: 'socks5://127.0.0.1:1080' }))
+    try {
+      // A variable exported for another tool must not stop the agent from starting, and the user
+      // has to learn that this scheme stays direct rather than discover it from a failing request.
+      // The message names the variable, never its value: a proxy URL may carry `user:password`.
+      expect(reported).toHaveLength(1)
+      expect(reported[0]).toContain('HTTPS_PROXY')
+      expect(reported[0]).toContain('SOCKS')
+      expect(reported[0]).not.toContain('1080')
+      await expect((await fetch(proxyTarget)).text()).resolves.toBe('VIA-PROXY')
+    } finally {
+      await dispose()
+    }
+  })
+
   it('publishes the policy through the proxy environment in both casings', async () => {
-    const dispose = await installGlobalProxy(proxyAll('example.com'))
+    const { dispose } = await install(proxyAll('example.com'))
     try {
       expect(process.env.http_proxy).toBe(proxyUrl)
       expect(process.env.HTTP_PROXY).toBe(proxyUrl)
-      expect(process.env.no_proxy).toBe('example.com')
-      expect(process.env.NO_PROXY).toBe('example.com')
+      expect(process.env.no_proxy).toContain('example.com')
+      expect(process.env.NO_PROXY).toContain('example.com')
     } finally {
       await dispose()
     }
@@ -105,7 +145,9 @@ describe('installGlobalProxy', () => {
 
   it('removes an environment name the policy leaves unset', async () => {
     process.env.HTTPS_PROXY = 'http://stale.example'
-    const dispose = await installGlobalProxy({ httpProxy: proxyUrl, noProxy: '', source: 'env' })
+    // The user named no HTTPS proxy, so the policy derives one from HTTP — the name is rewritten,
+    // never left carrying a value from an earlier process.
+    const { dispose } = await install(env({ HTTP_PROXY: proxyUrl, HTTPS_PROXY: 'socks5://127.0.0.1:1080' }))
     try {
       expect(process.env.HTTPS_PROXY).toBeUndefined()
     } finally {
@@ -115,41 +157,39 @@ describe('installGlobalProxy', () => {
     }
   })
 
-  it('restores the dispatcher, the policy, and the environment on disposal', async () => {
+  it('restores the dispatcher, the route, and the environment on disposal', async () => {
     const before = getGlobalDispatcher()
     const beforeEnv = process.env.HTTP_PROXY
-    const beforePolicy = currentProxyPolicy()
-    const dispose = await installGlobalProxy(proxyAll())
+    const { dispose } = await install(proxyAll())
     expect(getGlobalDispatcher()).not.toBe(before)
-    expect(currentProxyPolicy()).not.toBe(beforePolicy)
+    expect(proxyRouteFor(new URL(proxyTarget)).proxied).toBe(true)
     await dispose()
     expect(getGlobalDispatcher()).toBe(before)
-    expect(currentProxyPolicy()).toBe(beforePolicy)
+    expect(proxyRouteFor(new URL(proxyTarget)).proxied).toBe(false)
     expect(process.env.HTTP_PROXY).toBe(beforeEnv)
     await expect((await fetch(originUrl)).text()).resolves.toBe('DIRECT')
   })
 
-  it('installs no dispatcher and touches no environment for a direct policy', async () => {
+  it('installs no dispatcher and touches no environment when the user exported none', async () => {
     const before = getGlobalDispatcher()
     process.env.HTTP_PROXY = 'http://untouched.example'
-    const dispose = await installGlobalProxy(DIRECT_POLICY)
+    const { dispose, reported } = await install(env({}))
     try {
       expect(getGlobalDispatcher()).toBe(before)
       expect(process.env.HTTP_PROXY).toBe('http://untouched.example')
-      expect(currentProxyPolicy()).toBe(DIRECT_POLICY)
+      expect(reported).toEqual([])
+      expect(proxyRouteFor(new URL(proxyTarget))).toEqual({ proxied: false })
     } finally {
       await dispose()
       delete process.env.HTTP_PROXY
     }
-    // With nothing installed the accessor still answers, so a caller never has to spell the direct
-    // case itself — that spelling is what let two reads disagree about one request.
-    expect(currentProxyPolicy()).toBe(DIRECT_POLICY)
   })
+
   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' })
+    const { dispose } = await install(env({ HTTP_PROXY: proxyUrl, HTTPS_PROXY: 'socks5://127.0.0.1:1080' }))
     try {
       // The direct path here fails on a DNS miss whose latency is the machine's resolver to decide;
       // the deadline bounds it. Either rejection proves the same thing — no CONNECT reached the
@@ -165,193 +205,163 @@ describe('installGlobalProxy', () => {
   })
 })
 
-describe('createDispatcher', () => {
-  it('tunnels through the proxy when the policy covers the URL', async () => {
-    const dispose = await installGlobalProxy(proxyAll())
-    const dispatcher = await createDispatcher(new URL(proxyTarget))
-    try {
-      const undici = await import('undici')
-      const response = await undici.fetch(proxyTarget, { dispatcher })
-      await expect(response.text()).resolves.toBe('VIA-PROXY')
-    } finally {
-      await dispatcher.close()
-      await dispose()
-    }
+describe('proxyRouteFor', () => {
+  it('carries the dispatcher already routing, so a branch and its request agree', async () => {
+    const { dispose } = await install(proxyAll())
+    const route = proxyRouteFor(new URL(proxyTarget))
+    expect(route).toMatchObject({ proxied: true, proxy: proxyUrl })
+    if (!route.proxied) throw new Error('unreachable: asserted proxied above')
+    // One transport, not a copy: a caller that branched on this route sends its request through the
+    // very agent the branch described, so no second read can put the two on different routes.
+    expect(route.dispatcher).toBe(getGlobalDispatcher())
+    const undici = await import('undici')
+    // Unmounting the plugin under an in-flight request is what a hot reload does. The shared
+    // dispatcher is closed, not destroyed, so the hop that already left finishes.
+    const inFlight = undici.fetch(proxyTarget, { dispatcher: route.dispatcher })
+    await dispose()
+    await expect((await inFlight).text()).resolves.toBe('VIA-PROXY')
+    expect(proxied).toEqual([`GET ${proxyTarget}`])
   })
 
-  it('connects directly when the policy bypasses the URL', async () => {
-    const dispose = await installGlobalProxy(proxyAll('127.0.0.1'))
-    const dispatcher = await createDispatcher(new URL(originUrl))
+  it('is direct for a bypassed URL, and direct with nothing installed', async () => {
+    const { dispose } = await install(proxyAll('origin.test'))
     try {
-      const undici = await import('undici')
-      const response = await undici.fetch(originUrl, { dispatcher })
-      await expect(response.text()).resolves.toBe('DIRECT')
-      expect(proxied).toEqual([])
+      expect(proxyRouteFor(new URL(proxyTarget))).toEqual({ proxied: false })
     } finally {
-      await dispatcher.close()
       await dispose()
     }
+    expect(proxyRouteFor(new URL(proxyTarget))).toEqual({ proxied: false })
   })
 
-  it('connects directly when no policy is installed', async () => {
-    const dispatcher = await createDispatcher(new URL(originUrl))
-    try {
-      const undici = await import('undici')
-      await expect((await undici.fetch(originUrl, { dispatcher })).text()).resolves.toBe('DIRECT')
-    } finally {
-      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(proxyTarget), {}, branched)
+  it('is direct for a loopback URL under a policy that proxies everything', async () => {
+    const { dispose } = await install(proxyAll())
     try {
-      const undici = await import('undici')
-      await expect((await undici.fetch(proxyTarget, { dispatcher })).text()).resolves.toBe('VIA-PROXY')
+      expect(proxyRouteFor(new URL(originUrl))).toEqual({ proxied: false })
     } finally {
-      await dispatcher.close()
+      await dispose()
     }
   })
 })
 
-describe('childProxyEnv', () => {
+describe('proxyEnvironmentForChild', () => {
   it('is empty when no policy is installed', () => {
-    expect(childProxyEnv()).toEqual({})
+    expect(proxyEnvironmentForChild()).toEqual({})
   })
 
-  it('is empty under a direct policy, so a child sees no flag it cannot use', async () => {
-    const dispose = await installGlobalProxy(DIRECT_POLICY)
+  it('is empty when the user exported none, so a child sees no flag it cannot use', async () => {
+    const { dispose } = await install(env({}))
     try {
-      expect(childProxyEnv()).toEqual({})
+      expect(proxyEnvironmentForChild()).toEqual({})
     } finally {
       await dispose()
     }
   })
 
   it('hands a child the values the user exported, not this process\'s normalization', async () => {
-    // Start from a known environment: a CI runner or developer machine may export its own proxy,
-    // which would otherwise appear as the "user's" value and decide this assertion.
-    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)
-    // A user who set only HTTP_PROXY, plus a SOCKS proxy this package refuses but `curl` uses.
-    process.env.HTTP_PROXY = proxyUrl
-    process.env.https_proxy = 'socks5://127.0.0.1:1080'
-    const dispose = await installGlobalProxy(proxyAll('example.com'))
-    try {
-      const child = childProxyEnv()
-      // The published policy derived an HTTPS proxy for this process; the child must not see it.
-      // Asserted over both casings rather than one: Windows folds the pair into a single variable,
-      // so which spelling carries the value is the platform's to decide — that it is the user's
-      // value and never the derived one is not.
-      const https = [child.https_proxy, child.HTTPS_PROXY]
-      expect(https).toContain('socks5://127.0.0.1:1080')
-      expect(https).not.toContain(proxyUrl)
-      expect(child.HTTP_PROXY).toBe(proxyUrl)
-      // 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()
-      for (const [name, value] of Object.entries(saved)) {
-        if (value === undefined) Reflect.deleteProperty(process.env, name)
-        else process.env[name] = value
+    await withCleanProxyEnv(async () => {
+      // A user who set only HTTP_PROXY, plus a SOCKS proxy this package refuses but `curl` uses.
+      process.env.HTTP_PROXY = proxyUrl
+      process.env.https_proxy = 'socks5://127.0.0.1:1080'
+      const { dispose } = await install(env({ HTTP_PROXY: proxyUrl, https_proxy: 'socks5://127.0.0.1:1080', NO_PROXY: 'example.com' }))
+      try {
+        const child = proxyEnvironmentForChild()
+        // The published policy derived an HTTPS proxy for this process; the child must not see it.
+        // Asserted over both casings rather than one: Windows folds the pair into a single variable,
+        // so which spelling carries the value is the platform's to decide — that it is the user's
+        // value and never the derived one is not.
+        const https = [child.https_proxy, child.HTTPS_PROXY]
+        expect(https).toContain('socks5://127.0.0.1:1080')
+        expect(https).not.toContain(proxyUrl)
+        expect(child.HTTP_PROXY).toBe(proxyUrl)
+        // The bypass list is the resolved one: it only adds entries to what the user wrote, and
+        // without the loopback ones the child sends its own localhost traffic to a proxy that
+        // cannot route it.
+        expect(child.no_proxy).toBe('example.com,localhost,127.0.0.1,::1,[::1]')
+        expect(child.NO_PROXY).toBe('example.com,localhost,127.0.0.1,::1,[::1]')
+        expect(child.NODE_USE_ENV_PROXY).toBe('1')
+      } finally {
+        await dispose()
       }
-    }
+    })
   })
+
   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
+    await withCleanProxyEnv(async () => {
+      // 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 install(env({ ALL_PROXY: proxyUrl }))
+      try {
+        const child = proxyEnvironmentForChild()
+        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()
       }
-    }
+    })
   })
 
   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)
+    await withCleanProxyEnv(async () => {
+      // The user exported one name, in one casing.
+      process.env.HTTP_PROXY = proxyUrl
+      // The launcher installs first; mounting the plugin installs a second policy over it.
+      const outer = await install(env({ HTTP_PROXY: proxyUrl, HTTPS_PROXY: proxyUrl, NO_PROXY: 'example.com' }))
       try {
-        const child = childProxyEnv()
-        // The user named no HTTPS proxy, so this scheme carries whichever policy is active. Reading
-        // the outer install's published environment as the user's would pin it to the outer proxy
-        // instead — the one discriminator that does not depend on how a platform cases names.
-        expect(child.https_proxy).toBe(nestedUrl)
-        expect(child.HTTPS_PROXY).toBe(nestedUrl)
+        const inner = await install(env({ HTTP_PROXY: nestedUrl, HTTPS_PROXY: nestedUrl }))
+        try {
+          const child = proxyEnvironmentForChild()
+          // The user named no HTTPS proxy, so this scheme carries whichever policy is active. Reading
+          // the outer install's published environment as the user's would pin it to the outer proxy
+          // instead — the one discriminator that does not depend on how a platform cases names.
+          expect(child.https_proxy).toBe(nestedUrl)
+          expect(child.HTTPS_PROXY).toBe(nestedUrl)
+        } finally {
+          await inner.dispose()
+        }
+        // Unmounting the inner install must leave the outer one still able to describe that
+        // environment; clearing the record instead makes this an empty object, so every later child
+        // inherits the normalized values from `process.env` untouched.
+        expect(proxyEnvironmentForChild().HTTP_PROXY).toBe(proxyUrl)
+        expect(proxyEnvironmentForChild().https_proxy).toBe(proxyUrl)
       } finally {
-        await disposeInner()
-      }
-      // Unmounting the inner install must leave the outer one still able to describe that
-      // environment; clearing the record instead makes this an empty object, so every later child
-      // inherits the normalized values from `process.env` untouched.
-      expect(childProxyEnv().HTTP_PROXY).toBe(proxyUrl)
-      expect(childProxyEnv().https_proxy).toBe(proxyUrl)
-    } finally {
-      await disposeOuter()
-      for (const [name, value] of Object.entries(saved)) {
-        if (value === undefined) Reflect.deleteProperty(process.env, name)
-        else process.env[name] = value
+        await outer.dispose()
       }
-    }
+    })
   })
 })
 
-describe('installGlobalProxy over an existing installation', () => {
-  it('stops proxying when a direct policy is installed over a proxied one', async () => {
-    const outer = await installGlobalProxy(proxyAll())
+describe('installing over an existing installation', () => {
+  it('stops proxying when the mounted policy proxies nothing', async () => {
+    const outer = await install(proxyAll())
     try {
       await expect((await fetch(proxyTarget)).text()).resolves.toBe('VIA-PROXY')
-      const off = await installGlobalProxy(DIRECT_POLICY)
+      const off = await install(env({}))
       try {
         // `mode: 'off'` must actually stop proxying, not merely report a direct policy while the
         // launcher's agent keeps tunnelling. A direct hop needs a host that answers, so this one
         // reaches the real origin rather than the name only the proxy can resolve.
         await expect((await fetch(originUrl)).text()).resolves.toBe('DIRECT')
-        expect(currentProxyPolicy()).toBe(DIRECT_POLICY)
+        expect(proxyRouteFor(new URL(proxyTarget))).toEqual({ proxied: false })
       } finally {
-        await off()
+        await off.dispose()
       }
       // Disposing the direct policy restores the proxy the launcher installed.
       await expect((await fetch(proxyTarget)).text()).resolves.toBe('VIA-PROXY')
+      expect(proxyRouteFor(new URL(proxyTarget)).proxied).toBe(true)
     } finally {
-      await outer()
+      await outer.dispose()
     }
   })
 })
 
-describe('applyPolicyEnv restoration', () => {
+describe('the published environment', () => {
   it('restores every name from one snapshot taken before any write', async () => {
     process.env.http_proxy = 'http://before.example'
     process.env.HTTP_PROXY = 'http://before.example'
-    const dispose = await installGlobalProxy(proxyAll())
+    const { dispose } = await install(proxyAll())
     expect(process.env.HTTP_PROXY).toBe(proxyUrl)
     await dispose()
     // Reading the uppercase spelling after writing the lowercase one must not restore the value
@@ -363,77 +373,10 @@ 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) => {
-      http.get(target, { agent }, (response) => {
-        let body = ''
-        response.on('data', (chunk: Buffer) => { body += chunk.toString() })
-        response.on('end', () => { resolve(body) })
-      }).on('error', (error: NodeJS.ErrnoException) => { resolve(`ERR ${error.code ?? ''}`) })
-    })
-  }
-
-  it('routes a node:http request through the proxy', async () => {
-    const dispose = await installGlobalProxy(proxyAll())
-    const agent = await createNodeHttpAgent('http:')
-    try {
-      // 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()
-    }
-  })
-
-  it('connects directly when no policy is installed', async () => {
-    const agent = await createNodeHttpAgent('http:', { keepAlive: false })
-    try {
-      await expect(get(originUrl, agent)).resolves.toBe('DIRECT')
-    } finally {
-      agent.destroy()
-    }
-  })
-
-  it('selects the TLS agent for an https target', async () => {
-    const agent = await createNodeHttpAgent('https:')
-    try {
-      expect(agent).toBeInstanceOf(https.Agent)
-    } finally {
-      agent.destroy()
-    }
-  })
-})
-
-describe('proxyUrlFor', () => {
-  it('names the proxy an SDK with its own transport must use', async () => {
-    const dispose = await installGlobalProxy(proxyAll())
-    try {
-      expect(proxyUrlFor(new URL('https://api.example.com/v1'))).toBe(proxyUrl)
-    } finally {
-      await dispose()
-    }
-  })
-
-  it('names none for a bypassed host, and none at all without a policy', async () => {
-    const dispose = await installGlobalProxy(proxyAll('api.example.com'))
-    try {
-      expect(proxyUrlFor(new URL('https://api.example.com/v1'))).toBeUndefined()
-    } finally {
-      await dispose()
-    }
-    expect(proxyUrlFor(new URL('https://api.example.com/v1'))).toBeUndefined()
+describe('clearedProxyEnv', () => {
+  it('names every proxy variable for removal, so a replay reaches its own fixture server', () => {
+    const cleared = clearedProxyEnv()
+    expect(Object.keys(cleared).sort()).toEqual([...PROXY_ENV_NAMES].sort())
+    expect(Object.values(cleared).every(value => value === undefined)).toBe(true)
   })
 })

+ 13 - 8
packages/util/http-proxy/tests/matcher-parity.spec.ts

@@ -1,7 +1,8 @@
 import { createServer, type Server } from 'node:http'
 import type { AddressInfo } from 'node:net'
 import { afterAll, beforeAll, describe, expect, it } from 'vitest'
-import { installGlobalProxy, proxyForUrl, type ProxyPolicy } from '../src/index.ts'
+import { installProxyFromEnvironment } from '../src/index.ts'
+import { proxyForUrl, resolveProxyPolicy } from '../src/policy.ts'
 
 /**
  * `proxyForUrl` answers where a URL goes; these cases check that answer against where a real `fetch`
@@ -10,9 +11,9 @@ import { installGlobalProxy, proxyForUrl, type ProxyPolicy } from '../src/index.
  * 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.
+ * The remaining second matcher is Node's, in a spawned child: it reads the published `NO_PROXY` and
+ * 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 },
@@ -46,22 +47,26 @@ afterAll(async () => {
   await new Promise<void>((resolve) => { proxy.close(() => { resolve() }) })
 })
 
-function policy(noProxy: string): ProxyPolicy {
-  return { httpProxy: proxyUrl, httpsProxy: proxyUrl, noProxy, source: 'env' }
+/** The launch environment of a user who exported one proxy for both schemes plus one bypass list. */
+function proxyEnv(noProxy: string): { get(name: string): { value: string } | undefined } {
+  const values: Record<string, string> = { HTTP_PROXY: proxyUrl, HTTPS_PROXY: proxyUrl, NO_PROXY: noProxy }
+  return { get: name => (name in values ? { value: values[name] as string } : undefined) }
 }
 
 describe('bypass matcher parity', () => {
   it.each(CASES)('agrees on $noProxy for $path', async ({ noProxy, path, bypassed }) => {
     seen = []
     const url = new URL(`http://probe.invalid${path}`)
-    const dispose = await installGlobalProxy(policy(noProxy))
+    const env = proxyEnv(noProxy)
+    const { policy } = resolveProxyPolicy(env)
+    const dispose = await installProxyFromEnvironment(env, () => undefined)
     try {
       // 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 })
+      expect({ ours: proxyForUrl(policy, url) !== undefined, agent: agentProxied })
         .toEqual({ ours: !bypassed, agent: !bypassed })
     } finally {
       await dispose()

+ 39 - 57
packages/web/web-fetch-http/src/network.ts

@@ -9,8 +9,8 @@
 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, type ProxyPolicy } from '@deepseek-ai/dsh-http-proxy'
+import type { Dispatcher, Response } from 'undici'
+
 import ipaddr from 'ipaddr.js'
 import { WebError } from '@deepseek-ai/dsh-web'
 
@@ -174,93 +174,75 @@ export function isNonPublicIpLiteral(hostname: string): boolean {
 }
 
 /**
- * Fetch through an Undici agent whose lookup callback returns only the already
- * validated address set. The URL hostname remains intact for HTTP Host and TLS SNI.
+ * Fetch through an agent whose lookup callback returns only the already validated address set. The
+ * URL hostname remains intact for HTTP Host and TLS SNI.
+ *
+ * The agent is this request's own because the address set is: pinning is how this package refuses a
+ * DNS answer that changes between validation and connection, and it may not apply process-wide —
+ * an operator-configured MCP server or model endpoint on loopback is a supported destination, and
+ * only the URLs this tool fetches are the model's to choose.
  *
- * @param url - validated HTTP(S) URL.
+ * @param url - validated HTTP(S) URL the policy does not route through a proxy.
  * @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.
+ * @returns a response plus the disposer its consumer must call.
  */
 export async function requestPinned(
   url: URL,
   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)
+  // 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 resolves it here.
+  const { Agent, fetch } = await import('undici')
+  // Reached only where `proxyRouteFor` reported no proxy for this URL, and the pinned lookup this
+  // agent carries is per-request state the process-wide dispatcher cannot hold.
+  // proxy-exempt: pinning one request's validated addresses, on a URL the policy routes directly.
+  const dispatcher = new Agent({ autoSelectFamily: true, connect: { lookup: createPinnedLookup(addresses) } })
+  try {
+    // proxy-exempt: the agent above, whose lifetime is this one request.
+    const response = await fetch(url, { method: 'GET', redirect: 'manual', headers, signal, dispatcher })
+    return { response, close: async () => { await dispatcher.close() } }
+  } catch (error: unknown) {
+    await dispatcher.close()
+    throw error
+  }
 }
 
 /**
- * Fetch through the active proxy, letting it resolve the origin.
+ * Fetch through the dispatcher the proxy policy already installed, letting the proxy resolve the
+ * origin.
  *
  * No address set is pinned because none exists to pin: the proxy performs the lookup, and a
  * connection pinned to a locally resolved address would reach the origin directly and defeat the
- * proxy. Configuring a proxy therefore delegates destination selection to it; the URL-level policy
- * in `policy.ts` still applies to every hop.
- *
- * @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, {}, policy)
-}
-
-/**
- * Issue one request on a policy-aware dispatcher the caller then owns.
- *
- * The dispatcher comes from `dsh-http-proxy` rather than a bare `new Agent`, which would bypass the
- * global dispatcher and with it the proxy — the defect this package had before proxy support existed.
+ * proxy. The dispatcher is the process-wide one, so hops share its connection pool and no caller
+ * closes it.
  *
- * @param url - validated HTTP(S) URL.
+ * @param dispatcher - the route's dispatcher, from `proxyRouteFor`.
+ * @param url - validated HTTP(S) URL the policy routes through a proxy.
  * @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.
+ * @returns a response plus a disposer that releases nothing, so both paths close alike.
  */
-async function requestWith(
+export async function requestVia(
+  dispatcher: Dispatcher,
   url: URL,
   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, 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 })
-    return { response, close: async () => { await dispatcher.close() } }
-  } catch (error: unknown) {
-    await dispatcher.close()
-    throw error
-  }
+  // proxy-exempt: the dispatcher is the installed policy's own, handed over by `proxyRouteFor`.
+  const response = await fetch(url, { method: 'GET', redirect: 'manual', headers, signal, dispatcher })
+  return { response, close: () => Promise.resolve() }
 }
 
 /** Production network operations kept as an object so provider tests can replace resolution only. */
 export const publicHttpNetwork = {
   resolve: resolvePublicAddresses,
   request: requestPinned,
-  requestProxied,
+  requestVia,
 }
 
 type LookupCallback = (

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

@@ -10,7 +10,7 @@ import { WebError } from '@deepseek-ai/dsh-web'
 import type { WebFetchBody, WebFetchProvider, WebFetchRequest, WebFetchResult } from '@deepseek-ai/dsh-web'
 import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
 import type { Response } from 'undici'
-import { currentProxyPolicy, proxyForUrl } from '@deepseek-ai/dsh-http-proxy'
+import { proxyRouteFor } from '@deepseek-ai/dsh-http-proxy'
 import { isNonPublicIpLiteral, publicHttpNetwork } from './network.ts'
 import type { PublicAddress } from './network.ts'
 import { classifyContentType, decoderForCharset, isSameOrigin, parseCharset, validateFetchUrl } from './policy.ts'
@@ -125,19 +125,18 @@ export class HttpFetchProvider implements WebFetchProvider {
       // bypass the proxy. A hop the policy bypasses — every loopback and every `NO_PROXY` entry —
       // still takes the resolved-and-pinned path unchanged.
       //
-      // 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.
+      // One route decides both the branch and the dispatcher, so a mount or disposal between two
+      // reads cannot return a direct, unpinned agent for a URL this branch cleared as proxied.
       //
       // An IP literal the address checks would refuse never takes it. The proxy would resolve
       // nothing — the address is already stated — so the shortcut would spend the checks for
       // nothing and let a proxy on this machine reach the very service they keep out of reach.
-      const policy = currentProxyPolicy()
-      if (proxyForUrl(policy, url) !== undefined && !isNonPublicIpLiteral(url.hostname)) {
-        return await publicHttpNetwork.requestProxied(url, headers, signal, policy)
+      const route = proxyRouteFor(url)
+      if (route.proxied && !isNonPublicIpLiteral(url.hostname)) {
+        return await publicHttpNetwork.requestVia(route.dispatcher, url, headers, signal)
       }
       const addresses = await this.resolveAddresses(url.hostname, signal)
-      return await publicHttpNetwork.request(url, addresses, headers, signal, policy)
+      return await publicHttpNetwork.request(url, addresses, headers, signal)
     } catch (error: unknown) {
       if (error instanceof WebError) throw error
       throw translateAbortOrNetwork(error, signal)

+ 16 - 10
packages/web/web-fetch-http/tests/proxy.spec.ts

@@ -1,7 +1,7 @@
 import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
 import type { AddressInfo } from 'node:net'
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { installGlobalProxy, type ProxyPolicy } from '@deepseek-ai/dsh-http-proxy'
+import { installProxyFromEnvironment } from '@deepseek-ai/dsh-http-proxy'
 import { HttpFetchProvider } from '@deepseek-ai/dsh-web-fetch-http'
 import type { HttpFetchLimits } from '@deepseek-ai/dsh-web-fetch-http'
 import { isNonPublicIpLiteral, publicHttpNetwork } from '../src/network.ts'
@@ -62,15 +62,19 @@ afterEach(async () => {
   ])
 })
 
-/** A policy proxying everything, since a resolved policy always bypasses the loopback used here. */
-function policy(noProxy = ''): ProxyPolicy {
-  return { httpProxy: proxyUrl, httpsProxy: proxyUrl, noProxy, source: 'env' }
+/**
+ * Install the policy of a user who exported one proxy for both schemes; the fixture disposes it
+ * after every case.
+ */
+async function installProxy(): Promise<() => Promise<void>> {
+  const env = { get: (name: string) => (name === 'HTTP_PROXY' || name === 'HTTPS_PROXY' ? { value: proxyUrl } : undefined) }
+  return await installProxyFromEnvironment(env, () => undefined)
 }
 
 describe('fetching through a proxy', () => {
   it('tunnels the request and never resolves a public address for it', async () => {
     const resolve = vi.spyOn(publicHttpNetwork, 'resolve')
-    disposeProxy = await installGlobalProxy(policy())
+    disposeProxy = await installProxy()
 
     const result = await new HttpFetchProvider(limits).fetch({ url: proxyTarget })
 
@@ -81,10 +85,12 @@ describe('fetching through a proxy', () => {
     expect(resolve).not.toHaveBeenCalled()
   })
 
-  it('keeps resolving and pinning a hop the bypass list covers', async () => {
+  it('keeps resolving and pinning a hop the policy does not proxy', async () => {
     const resolve = vi.spyOn(publicHttpNetwork, 'resolve')
       .mockResolvedValue([{ address: '127.0.0.1', family: 4 }])
-    disposeProxy = await installGlobalProxy(policy('127.0.0.1'))
+    // No bypass entry needed: a resolved policy never routes loopback through a proxy, which is
+    // exactly the case this asserts still resolves and pins.
+    disposeProxy = await installProxy()
 
     const result = await new HttpFetchProvider(limits).fetch({ url: originUrl })
 
@@ -107,7 +113,7 @@ describe('fetching through a proxy', () => {
     'refuses %s instead of letting the proxy reach it for us',
     async (host) => {
       const resolve = vi.spyOn(publicHttpNetwork, 'resolve')
-      disposeProxy = await installGlobalProxy(policy())
+      disposeProxy = await installProxy()
 
       // The proxied path exists because a proxy resolves the origin; a literal needs no resolution,
       // so taking it would spend the address checks for nothing and hand a proxy on this machine
@@ -137,14 +143,14 @@ describe('fetching through a proxy', () => {
       response.writeHead(302, { location: 'http://elsewhere.example/next' })
       response.end()
     })
-    disposeProxy = await installGlobalProxy(policy())
+    disposeProxy = await installProxy()
 
     await expect(new HttpFetchProvider(limits).fetch({ url: proxyTarget }))
       .rejects.toThrow(expect.objectContaining({ code: 'WEB_REDIRECT_BLOCKED' }))
   })
 
   it('still refuses a URL the transport policy rejects before any hop', async () => {
-    disposeProxy = await installGlobalProxy(policy())
+    disposeProxy = await installProxy()
 
     await expect(new HttpFetchProvider(limits).fetch({ url: 'ftp://example.com/x' }))
       .rejects.toThrow(expect.objectContaining({ code: 'WEB_INVALID_URL' }))

+ 5 - 4
packages/web/web-search-deepseek/tests/egress.spec.ts

@@ -1,7 +1,7 @@
 import { createServer, type Server } from 'node:http'
 import type { AddressInfo } from 'node:net'
 import { afterAll, beforeAll, describe, expect, it } from 'vitest'
-import { installGlobalProxy, type ProxyPolicy } from '@deepseek-ai/dsh-http-proxy'
+import { installProxyFromEnvironment } from '@deepseek-ai/dsh-http-proxy'
 
 let seen: string[] = []
 let proxy: Server
@@ -21,12 +21,13 @@ beforeAll(async () => {
 })
 afterAll(async () => { await new Promise<void>((r) => { proxy.close(() => { r() }) }) })
 
-function policy(): ProxyPolicy {
-  return { httpProxy: proxyUrl, httpsProxy: proxyUrl, noProxy: '', source: 'env' }
+/** The launch environment of a user who exported one proxy for both schemes. */
+function proxyEnv(): { get(name: string): { value: string } | undefined } {
+  return { get: name => (name === 'HTTP_PROXY' || name === 'HTTPS_PROXY' ? { value: proxyUrl } : undefined) }
 }
 async function observe(run: () => Promise<unknown>): Promise<string[]> {
   seen = []
-  const dispose = await installGlobalProxy(policy())
+  const dispose = await installProxyFromEnvironment(proxyEnv(), () => undefined)
   try { await run().catch(() => undefined) } finally { await dispose() }
   return seen
 }

+ 5 - 4
packages/web/web-search-exa/tests/egress.spec.ts

@@ -1,7 +1,7 @@
 import { createServer, type Server } from 'node:http'
 import type { AddressInfo } from 'node:net'
 import { afterAll, beforeAll, describe, expect, it } from 'vitest'
-import { installGlobalProxy, type ProxyPolicy } from '@deepseek-ai/dsh-http-proxy'
+import { installProxyFromEnvironment } from '@deepseek-ai/dsh-http-proxy'
 
 let seen: string[] = []
 let proxy: Server
@@ -21,12 +21,13 @@ beforeAll(async () => {
 })
 afterAll(async () => { await new Promise<void>((r) => { proxy.close(() => { r() }) }) })
 
-function policy(): ProxyPolicy {
-  return { httpProxy: proxyUrl, httpsProxy: proxyUrl, noProxy: '', source: 'env' }
+/** The launch environment of a user who exported one proxy for both schemes. */
+function proxyEnv(): { get(name: string): { value: string } | undefined } {
+  return { get: name => (name === 'HTTP_PROXY' || name === 'HTTPS_PROXY' ? { value: proxyUrl } : undefined) }
 }
 async function observe(run: () => Promise<unknown>): Promise<string[]> {
   seen = []
-  const dispose = await installGlobalProxy(policy())
+  const dispose = await installProxyFromEnvironment(proxyEnv(), () => undefined)
   try { await run().catch(() => undefined) } finally { await dispose() }
   return seen
 }

+ 5 - 4
packages/web/web-search-perplexity/tests/egress.spec.ts

@@ -1,7 +1,7 @@
 import { createServer, type Server } from 'node:http'
 import type { AddressInfo } from 'node:net'
 import { afterAll, beforeAll, describe, expect, it } from 'vitest'
-import { installGlobalProxy, type ProxyPolicy } from '@deepseek-ai/dsh-http-proxy'
+import { installProxyFromEnvironment } from '@deepseek-ai/dsh-http-proxy'
 
 let seen: string[] = []
 let proxy: Server
@@ -21,12 +21,13 @@ beforeAll(async () => {
 })
 afterAll(async () => { await new Promise<void>((r) => { proxy.close(() => { r() }) }) })
 
-function policy(): ProxyPolicy {
-  return { httpProxy: proxyUrl, httpsProxy: proxyUrl, noProxy: '', source: 'env' }
+/** The launch environment of a user who exported one proxy for both schemes. */
+function proxyEnv(): { get(name: string): { value: string } | undefined } {
+  return { get: name => (name === 'HTTP_PROXY' || name === 'HTTPS_PROXY' ? { value: proxyUrl } : undefined) }
 }
 async function observe(run: () => Promise<unknown>): Promise<string[]> {
   seen = []
-  const dispose = await installGlobalProxy(policy())
+  const dispose = await installProxyFromEnvironment(proxyEnv(), () => undefined)
   try { await run().catch(() => undefined) } finally { await dispose() }
   return seen
 }

+ 9 - 9
packages/workflow/workflow-worker-thread/tests/egress.spec.ts

@@ -1,23 +1,23 @@
 import { describe, expect, it } from 'vitest'
-import { PROXY_ENV_NAMES, installGlobalProxy, type ProxyPolicy } from '@deepseek-ai/dsh-http-proxy'
+import { clearedProxyEnv, installProxyFromEnvironment } from '@deepseek-ai/dsh-http-proxy'
 import { workerSpawnEnv } from '../src/host.ts'
 
-/** A policy carrying credentials, the shape that must never reach model-authored code. */
-const CREDENTIALED: ProxyPolicy = {
-  httpProxy: 'http://alice:s3cret@proxy.example:8080',
-  httpsProxy: 'http://alice:s3cret@proxy.example:8080',
-  noProxy: '',
-  source: 'env',
+/** A proxy URL carrying credentials, the shape that must never reach model-authored code. */
+const CREDENTIALED_PROXY = 'http://alice:s3cret@proxy.example:8080'
+
+/** The launch environment of a user whose proxy needs a password. */
+const CREDENTIALED = {
+  get: (name: string) => (name === 'HTTP_PROXY' || name === 'HTTPS_PROXY' ? { value: CREDENTIALED_PROXY } : undefined),
 }
 
 describe('workflow worker egress', () => {
   it('hands the worker no proxy configuration, credentialed or not', async () => {
-    const dispose = await installGlobalProxy(CREDENTIALED)
+    const dispose = await installProxyFromEnvironment(CREDENTIALED, () => undefined)
     try {
       const env = workerSpawnEnv()
       // The worker executes the model-authored script body, so a proxy URL that may carry
       // `user:password` must not be readable from its environment.
-      for (const name of PROXY_ENV_NAMES) expect(env).not.toHaveProperty(name)
+      for (const name of Object.keys(clearedProxyEnv())) expect(env).not.toHaveProperty(name)
       expect(env).not.toHaveProperty('NODE_USE_ENV_PROXY')
       expect(JSON.stringify(env)).not.toContain('s3cret')
     } finally {

+ 2 - 17
pnpm-lock.yaml

@@ -7236,10 +7236,10 @@ importers:
       '@opentelemetry/api-logs':
         specifier: ^0.220.0
         version: 0.220.0
-      '@opentelemetry/exporter-logs-otlp-http':
+      '@opentelemetry/otlp-exporter-base':
         specifier: ^0.220.0
         version: 0.220.0(@opentelemetry/api@1.9.1)
-      '@opentelemetry/otlp-exporter-base':
+      '@opentelemetry/otlp-transformer':
         specifier: ^0.220.0
         version: 0.220.0(@opentelemetry/api@1.9.1)
       '@opentelemetry/resources':
@@ -11822,12 +11822,6 @@ packages:
     peerDependencies:
       '@opentelemetry/api': '>=1.0.0 <1.10.0'
 
-  '@opentelemetry/exporter-logs-otlp-http@0.220.0':
-    resolution: {integrity: sha512-8186thl+pTw64iz/qEEen5oJZoZ/gO73XruChdaGlYdWOdBIQ42r+vHLf6a7vIDqTD4b8ZOoMlyxptanECaI9A==}
-    engines: {node: ^18.19.0 || >=20.6.0}
-    peerDependencies:
-      '@opentelemetry/api': ^1.3.0
-
   '@opentelemetry/otlp-exporter-base@0.220.0':
     resolution: {integrity: sha512-CXYo8UD5Mn9YbgebO2EL4wejtA+gxLmLiu6HCk2KH2BR7XhFN6/6p1UlCb23DYCjeYkndevLHuejCCN1yx4+OQ==}
     engines: {node: ^18.19.0 || >=20.6.0}
@@ -17427,15 +17421,6 @@ snapshots:
       '@opentelemetry/api': 1.9.1
       '@opentelemetry/semantic-conventions': 1.43.0
 
-  '@opentelemetry/exporter-logs-otlp-http@0.220.0(@opentelemetry/api@1.9.1)':
-    dependencies:
-      '@opentelemetry/api': 1.9.1
-      '@opentelemetry/api-logs': 0.220.0
-      '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1)
-      '@opentelemetry/otlp-exporter-base': 0.220.0(@opentelemetry/api@1.9.1)
-      '@opentelemetry/otlp-transformer': 0.220.0(@opentelemetry/api@1.9.1)
-      '@opentelemetry/sdk-logs': 0.220.0(@opentelemetry/api@1.9.1)
-
   '@opentelemetry/otlp-exporter-base@0.220.0(@opentelemetry/api@1.9.1)':
     dependencies:
       '@opentelemetry/api': 1.9.1

+ 1 - 1
scripts/verify-no-bare-dispatcher.spec.ts

@@ -36,7 +36,7 @@ describe('bare dispatcher check', () => {
   })
 
   it('accepts the sanctioned factory', () => {
-    expect(reasons('      const dispatcher = await createDispatcher(url, options)')).toEqual([])
+    expect(reasons('      const route = proxyRouteFor(url)')).toEqual([])
   })
 
   it('rejects the shorthand form a line-wise regex misses', () => {

+ 5 - 2
scripts/verify-no-bare-dispatcher.ts

@@ -7,7 +7,10 @@
  * — the exact defect `web-fetch-http` carried before proxy support existed, where its DNS-pinning
  * agent silently bypassed every proxy.
  *
- * `createDispatcher()` from that package is the sanctioned way to get agent options AND the policy.
+ * `proxyRouteFor(url)` from that package is the sanctioned way to ask where one request goes and to
+ * get the transport that answer assumed. A call site that genuinely owns its transport — because it
+ * carries per-request state the process-wide dispatcher cannot, as `web-fetch-http`'s address
+ * pinning does — says so with the marker below.
  *
  * 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
@@ -215,7 +218,7 @@ function main(): void {
     console.error(`  ${relative('.', violation.file)}:${String(violation.line)} ${violation.what}`)
     console.error(`    ${violation.text}`)
   }
-  console.error('\nUse `createDispatcher(url, options)` from @deepseek-ai/dsh-http-proxy, or annotate the line')
+  console.error('\nUse `proxyRouteFor(url)` from @deepseek-ai/dsh-http-proxy, or annotate the line')
   console.error(`with a \`${ALLOW_MARKER} <reason>\` comment when the request must genuinely ignore the proxy.`)
   process.exit(1)
 }