Jelajahi Sumber

fix(net): address review — containment, opt-out, and syntax-aware discovery

The workflow worker no longer receives proxy configuration: it executes the
model-authored script body, and a proxy URL may carry credentials. A child
process now inherits the values the user exported rather than this process's
normalization, so a SOCKS proxy set for curl survives and no HTTPS_PROXY is
invented. `mode: 'off'` installs a direct dispatcher instead of recording a
policy the global dispatcher ignores, and the environment snapshot is taken
before any write so Windows restores the user's values.

E2B picks its proxy from the control-plane URL the SDK will really call, the
OTLP agent honors `exporter.keepAlive`, and a scheme whose own value was
refused stays direct instead of borrowing another scheme's proxy.

verify-no-bare-dispatcher parses the TypeScript AST as scripts/AGENTS.md
requires; it immediately found the `{ dispatcher }` shorthand the regex missed.
Yichen Jiang 1 bulan lalu
induk
melakukan
e6dbf85f6c
27 mengubah file dengan 668 tambahan dan 228 penghapusan
  1. 2 2
      .agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.i18n.yaml
  2. 4 4
      .agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.md
  3. 4 4
      .agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.zh.md
  4. 1 0
      packages/e2b/e2b/package.json
  5. 23 3
      packages/e2b/e2b/src/index.ts
  6. 23 0
      packages/e2b/e2b/tests/egress.spec.ts
  7. 3 0
      packages/e2b/e2b/tsconfig.json
  8. 2 2
      packages/net/http-proxy/README.i18n.yaml
  9. 6 5
      packages/net/http-proxy/README.md
  10. 6 5
      packages/net/http-proxy/README.zh.md
  11. 56 25
      packages/net/http-proxy/src/install.ts
  12. 51 22
      packages/net/http-proxy/src/policy.ts
  13. 46 15
      packages/net/http-proxy/tests/install.spec.ts
  14. 63 0
      packages/net/http-proxy/tests/matcher-parity.spec.ts
  15. 23 0
      packages/net/http-proxy/tests/policy.spec.ts
  16. 4 2
      packages/session/session-telemetry-otel/src/index.ts
  17. 57 3
      packages/session/session-telemetry-otel/tests/egress.spec.ts
  18. 2 1
      packages/subprocess/subprocess/package.json
  19. 8 2
      packages/subprocess/subprocess/src/index.ts
  20. 86 38
      packages/subprocess/subprocess/tests/egress.spec.ts
  21. 3 0
      packages/subprocess/subprocess/tsconfig.json
  22. 1 0
      packages/web/web-fetch-http/src/network.ts
  23. 6 9
      packages/workflow/workflow-worker-thread/src/host.ts
  24. 25 58
      packages/workflow/workflow-worker-thread/tests/egress.spec.ts
  25. 6 0
      pnpm-lock.yaml
  26. 51 9
      scripts/verify-no-bare-dispatcher.spec.ts
  27. 106 19
      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: 9d0a1545e68652f2f2453e89fbf6321385b6c804
-2026-08-27-outbound-proxy-policy.zh.md: fe4f596c6839b7b1c275abf042b90d08b42643c1
+2026-08-27-outbound-proxy-policy.md: 0213ab056bb3c4eb417788b739daca0adc5be669
+2026-08-27-outbound-proxy-policy.zh.md: 312b9197acfb47edb51eb4413e15c57b492cdce6

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

@@ -26,13 +26,13 @@ This keeps `proxyForUrl()` and the dispatcher answering from one set of values.
 
 **Resolution supplies what neither Node nor undici does.** `ALL_PROXY` backs both schemes; a blank value counts as unset, because undici's `??` chain lets an empty lowercase name shadow a populated uppercase one; loopback is always bypassed, since the Web UI, the Connection transport, and every local test server would otherwise route through the proxy and loop. The bypass list carries `::1` *and* `[::1]`: undici's own matcher reads a bare `::1` as host `:` port `1` and never exempts it.
 
-**Rejection is loud or quiet by where the value came from.** A SOCKS URL, an unparseable string, or an unsupported scheme *from the environment* is reported on stderr and skipped — that variable may have been exported for other tools, and a typo in it must not stop the agent from starting. The same value through the plugin's `Config` throws at load, because that is the harness's own configuration surface, where `AGENTS.md` requires misconfiguration to fail loud.
+**Rejection is loud or quiet by where the value came from, and never reroutes the refused scheme.** A slot the user filled and this package refused keeps that scheme direct rather than falling through to `ALL_PROXY` or the HTTP proxy, so the diagnostic and the route agree. A SOCKS URL, an unparseable string, or an unsupported scheme *from the environment* is reported on stderr and skipped — that variable may have been exported for other tools, and a typo in it must not stop the agent from starting. The same value through the plugin's `Config` throws at load, because that is the harness's own configuration surface, where `AGENTS.md` requires misconfiguration to fail loud.
 
 **Through a proxy, `web_fetch` stops resolving and pinning.** The provider validates a public address set and pins the connection to it. Through a proxy there is nothing to pin — the proxy performs the origin's DNS — and a pinned direct connection would bypass the proxy entirely. So a proxied hop skips resolution, and configuring a proxy is a statement that the proxy is trusted with destination selection. A hop the policy bypasses, which includes every loopback and every `NO_PROXY` entry, takes the resolved-and-pinned path unchanged. Kimi Code and Claude Code reached this same conclusion independently.
 
 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 separate Node execution context gets the policy through its environment.** `childProxyEnv()` returns the resolved names plus `NODE_USE_ENV_PROXY=1`, merged into `scrubbedParentEnv()` — one function every spawner already shares — and into `workerSpawnEnv()`. A worker thread has its own `globalThis` and does not inherit the global dispatcher, and its environment is built explicitly rather than inherited, so it needs the names as well as the flag. Measured: the flag does take effect for a `Worker` given an explicit `env`.
+**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.
 
 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+.
 
@@ -40,7 +40,7 @@ This accepts a documented seam. Such a context matches bypass entries by Node's
 
 **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` rejects `new Agent(...)` and an explicit `dispatcher:` 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. `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.
 
 ## Alternatives considered
 
@@ -78,6 +78,6 @@ The suite is hermetic against the developer's own environment: `plugin.spec.ts`
 
 `verify-no-bare-dispatcher.spec.ts` proves the gate rejects the exact shape this package was introduced to fix, accepts `createDispatcher`, accepts an annotated exemption, and passes on the current tree.
 
-The egress suite also carries the negative case for telemetry: without the agent this change supplies, the exporter reaches no proxy at all. That assertion is what keeps the fix from being quietly reverted by an SDK upgrade that restores the default agent.
+The egress suite carries the negative case for telemetry — restoring the SDK's own default agent reaches no proxy — so an upgrade cannot quietly un-proxy it. Its positive case branches on the runtime, because the exporter's agent needs Node 22.21+ or 24.5+. A parity suite pins the two bypass matchers (`proxyForUrl` and the installed `EnvHttpProxyAgent`) against each other over the documented `NO_PROXY` vocabulary.
 
 No recorded-session snapshot changes: nothing here alters a model-visible input or product-user-visible transcript output.

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

@@ -26,13 +26,13 @@ Node 内置的 `fetch` 会忽略 `HTTP_PROXY` 与 `HTTPS_PROXY`。开发者运
 
 **解析补上 Node 与 undici 都不提供的部分。** `ALL_PROXY` 为两种协议兜底;空值视为未设置,因为 undici 的 `??` 链会让空的小写名遮住有值的大写名;loopback 始终绕过,否则 Web UI、Connection 传输以及每一个本地测试服务器都会经由代理并形成回环。绕过列表同时携带 `::1` **与** `[::1]`:undici 自带的匹配器会把裸写的 `::1` 读成主机 `:` 端口 `1`,从而永不豁免它。
 
-**拒绝是响还是静,取决于值从哪来。** 来自**环境**的 SOCKS URL、无法解析的字符串或不受支持的协议,会在 stderr 上报告并跳过——该变量可能是为其他工具导出的,它的笔误不应阻止 agent 启动。同样的值若经由插件的 `Config` 传入,则在加载期抛出,因为那是 Harness 自己的配置面,`AGENTS.md` 要求配置错误必须响。
+**拒绝是响还是静取决于值从哪来,且绝不为被拒协议改道。** 用户填写而被本包拒绝的槽位,会让该协议保持直连,而不是继续回退到 `ALL_PROXY` 或 HTTP 代理,从而让诊断与实际路由一致。来自**环境**的 SOCKS URL、无法解析的字符串或不受支持的协议,会在 stderr 上报告并跳过——该变量可能是为其他工具导出的,它的笔误不应阻止 agent 启动。同样的值若经由插件的 `Config` 传入,则在加载期抛出,因为那是 Harness 自己的配置面,`AGENTS.md` 要求配置错误必须响。
 
 **经由代理时,`web_fetch` 不再解析与固定地址。** 该提供方会校验一组公网地址并把连接固定到其上。经由代理时没有可固定的对象——origin 的 DNS 由代理执行——而固定后的直连会彻底绕开代理。因此代理转发的一跳跳过解析,配置代理即表示信任该代理进行目的地选择。被策略绕过的一跳,包括每一个 loopback 与每一条 `NO_PROXY` 条目,仍走原有的解析并固定路径。Kimi Code 与 Claude Code 各自独立得出了同一结论。
 
 URL 层策略未受影响:仅 `http(s)`、禁止内嵌凭据、长度上限与跨域重定向拒绝在每一跳上依然生效。
 
-**独立的 Node 执行上下文通过环境获得策略。** `childProxyEnv()` 返回解析出的变量名加上 `NODE_USE_ENV_PROXY=1`,并入 `scrubbedParentEnv()`(每个 spawner 本就共享的一个函数)与 `workerSpawnEnv()`。worker 线程拥有独立的 `globalThis`,不继承全局 dispatcher,而且它的环境是显式构造而非继承的,因此除标志外还需要那些变量名。已实测:对给定显式 `env` 的 `Worker`,该标志确实生效。
+**派生的子进程通过环境获得策略;执行模型代码的 worker 什么也不获得。** `childProxyEnv()` 并入 `scrubbedParentEnv()`——每个 spawner 本就共享的那一个函数。workflow worker **不**接收它:它执行的是模型编写的脚本体,而代理 URL 可能携带 `user:password`。这与 code runtime 保持的containment 相同,也是 `docs/defensive-patterns.md` 的要求,因此 workflow 自身的请求直连。
 
 这接受了一处已记录的接缝。此类上下文按 Node 自己的规则匹配绕过条目,其分隔符与 IPv4 区间支持与本包不同,且该标志仅存在于 Node 22.21+ 与 24+。
 
@@ -40,7 +40,7 @@ URL 层策略未受影响:仅 `http(s)`、禁止内嵌凭据、长度上限与
 
 **每个出网点都配一份出网测试,因为读代码不够。** 各所属包中的 `egress.spec.ts` 驱动该点的真实代码路径,目标是无法解析的 `.invalid` 主机,穿过一个假代理,并断言代理确实收到了请求。九份测试覆盖搜索后端、pi-ai 发现、走 HTTP 的 MCP、遥测、E2B、派生的子 Node 与 worker 线程。下面那条门禁看不进依赖内部;这些能,它们把「某个 SDK 换了传输」从静默回归变成失败的测试。
 
-**用门禁防止该缺陷复现。** `verify-no-bare-dispatcher` 在所属包之外拒绝 `new 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` 选项。`createDispatcher(url, options)` 是受支持的替代;确实必须忽略代理的行用 `proxy-exempt:` 注释说明。这条规则之所以存在,是因为 `web-fetch-http` 里原本那行 `new Agent` 在写下时完全合理——那时根本还没有代理这回事,也没有任何机制会拦下它。
 
 ## Alternatives considered
 
@@ -78,6 +78,6 @@ userland undici 能触及 Node 内置的 `fetch`,依赖于两者都会写入 l
 
 `verify-no-bare-dispatcher.spec.ts` 证明该门禁能拒掉本包所要修复的那种写法、接受 `createDispatcher`、接受带注释的豁免,并在当前代码树上通过。
 
-出网测试还为遥测保留了负向用例:不带本次提供的 agent 时,导出器完全触及不到代理。该断言可以防止某次 SDK 升级恢复默认 agent 后把修复悄悄回退掉。
+出网测试为遥测保留了负向用例——恢复 SDK 自带的默认 agent 就触及不到代理——因此升级无法悄悄把它变回直连。其正向用例按运行时分支,因为导出器的 agent 需要 Node 22.21+ 或 24.5+。另有一组一致性测试,用文档所述的 `NO_PROXY` 词汇把两套绕过匹配器(`proxyForUrl` 与已安装的 `EnvHttpProxyAgent`)相互固定。
 
 无录制会话快照变更:本次改动不影响任何模型可见输入或产品用户可见的 transcript 输出。

+ 1 - 0
packages/e2b/e2b/package.json

@@ -48,6 +48,7 @@
     "@deepseek-ai/dsh-fs-e2b": "workspace:^",
     "@deepseek-ai/dsh-http-proxy": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
+    "@deepseek-ai/dsh-launch-environment": "workspace:^",
     "@deepseek-ai/dsh-loader-smoke": "workspace:^",
     "@deepseek-ai/dsh-lsp": "workspace:^",
     "@deepseek-ai/dsh-lsp-stdio": "workspace:^",

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

@@ -70,6 +70,25 @@ declare module '@deepseek-ai/cordis' {
 /** The SDK's own default control-plane domain; `E2B_DOMAIN` overrides it there and here alike. */
 const E2B_DEFAULT_DOMAIN = 'e2b.app'
 
+/** The debug control plane the SDK substitutes, on loopback and plain HTTP. */
+const E2B_DEBUG_API_URL = 'http://localhost:3000'
+
+/**
+ * The control-plane URL the SDK will actually call, derived the way the SDK derives it: an explicit
+ * `E2B_API_URL` first, then the debug substitute, then the domain default. Choosing a proxy for
+ * anything else would pick the wrong scheme's proxy, ignore a bypass entry naming the real host, and
+ * — for the loopback debug plane — hand a proxy the control-plane traffic and its API key.
+ *
+ * @param env - the process environment to read; overridable so tests need no ambient state.
+ * @returns the absolute control-plane URL.
+ */
+export function e2bApiUrl(env: NodeJS.ProcessEnv = process.env): string {
+  const explicit = env.E2B_API_URL
+  if (explicit !== undefined && explicit !== '') return explicit
+  if ((env.E2B_DEBUG ?? 'false').toLowerCase() === 'true') return E2B_DEBUG_API_URL
+  return `https://api.${env.E2B_DOMAIN ?? E2B_DEFAULT_DOMAIN}`
+}
+
 /**
  * Creates one lazily consumable E2B SDK handle and deletes the sandbox at
  * timeout or disposal. Creation begins at plugin construction; adapters await
@@ -154,9 +173,10 @@ export class E2BRuntime extends Service {
 
   private async open(): Promise<Sandbox> {
     // The SDK builds its own undici dispatcher, so the global one never reaches it; it takes a proxy
-    // URL instead and reads no environment of its own. Its control-plane origin follows `E2B_DOMAIN`
-    // exactly as the SDK derives it, so a bypass entry naming that host is honored.
-    const proxy = proxyUrlFor(new URL(`https://api.${process.env.E2B_DOMAIN ?? E2B_DEFAULT_DOMAIN}`))
+    // 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 sandbox = await Sandbox.create({
       apiKey: this.config.apiKey,
       timeoutMs: this.config.timeoutMs,

+ 23 - 0
packages/e2b/e2b/tests/egress.spec.ts

@@ -44,3 +44,26 @@ describe('e2b egress', () => {
     expect(observed.join('|')).toContain('api.e2b.app:443')
   })
 })
+
+describe('e2b control-plane URL', () => {
+  it('follows the SDK precedence so the proxy decision matches the real target', async () => {
+    const { e2bApiUrl } = await import('../src/index.ts')
+    expect(e2bApiUrl({})).toBe('https://api.e2b.app')
+    expect(e2bApiUrl({ E2B_DOMAIN: 'e2b.dev' })).toBe('https://api.e2b.dev')
+    expect(e2bApiUrl({ E2B_DEBUG: 'TRUE' })).toBe('http://localhost:3000')
+    expect(e2bApiUrl({ E2B_API_URL: 'https://api.internal.example', E2B_DEBUG: 'true' }))
+      .toBe('https://api.internal.example')
+  })
+
+  it('keeps the loopback debug plane direct instead of sending its API key to a proxy', async () => {
+    const { e2bApiUrl } = await import('../src/index.ts')
+    const { proxyForUrl, resolveProxyPolicy } = 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(
+      createLaunchEnvironmentSnapshot([{ source: 'process', values: { HTTP_PROXY: proxyUrl } }]),
+    )
+    expect(proxyForUrl(resolved, new URL(e2bApiUrl({ E2B_DEBUG: 'true' })))).toBeUndefined()
+    expect(proxyForUrl(resolved, new URL(e2bApiUrl({})))).toBe(proxyUrl)
+  })
+})

+ 3 - 0
packages/e2b/e2b/tsconfig.json

@@ -25,6 +25,9 @@
     },
     {
       "path": "../../net/http-proxy"
+    },
+    {
+      "path": "../../util/launch-environment"
     }
   ]
 }

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

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

+ 6 - 5
packages/net/http-proxy/README.md

@@ -36,7 +36,7 @@ Plain `fetch()` is proxied, and so is any SDK that reaches `globalThis.fetch` 
 | 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 worker thread, or a spawn whose environment you build yourself | merge `childProxyEnv()` into its environment |
+| A spawn whose environment you build yourself | apply `childProxyEnv()` to it (`undefined` means remove) |
 
 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.
 
@@ -61,7 +61,7 @@ A proxy value the package cannot use — a SOCKS or PAC URL, an unparseable stri
 
 **One resolution, two readers.** `proxyForUrl()` and the installed dispatcher must never disagree about a URL, or `dsh-web-fetch-http` would pin a connection the dispatcher meant to tunnel. Installation therefore publishes the resolved policy into the proxy environment variables and constructs `EnvHttpProxyAgent` with no options, so the agent reads back exactly what was resolved rather than re-parsing the raw environment under slightly different rules.
 
-**Publishing the policy is also how children inherit it.** The same write normalizes what every spawned process sees: the `ALL_PROXY` fallback becomes a concrete `HTTP_PROXY`, and the bypass list arrives with loopback already merged.
+**A child inherits what the user exported, not what this process resolved.** The published values exist for undici; `childProxyEnv()` restores each name to its original before a child is spawned. Normalizing them into a child would hand `curl` an `HTTPS_PROXY` invented from the HTTP one, or replace a SOCKS proxy the user set for `curl` with an HTTP proxy they never named for that scheme.
 
 ### Source map
 
@@ -101,10 +101,11 @@ No direct invalidation: the package contributes no request tokens and never muta
 
 These limits define when the package is a poor fit. They are current package constraints.
 
-- **No SOCKS, PAC, or operating-system proxy detection** — only `http(s)://` proxy URLs from the environment or configuration. 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 skipped rather than silently ignored.
+- **No SOCKS, PAC, or operating-system proxy detection** — only `http(s)://` proxy URLs from the environment or configuration. 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 matches bypass entries by Node's rules, not these** — a child process or worker thread honors the policy through Node's own `NODE_USE_ENV_PROXY` support, whose `NO_PROXY` parsing differs in separators and IPv4-range handling, and which exists only on Node 22.21+ and 24+. An older runtime keeps that context direct.
-- **The `code-runtime` worker is deliberately excluded** — model-authored programs run with no ambient environment at all, and a proxy URL may carry credentials.
+- **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 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`.
 
 <a id="dev-note"></a>
 ### Dev Note

+ 6 - 5
packages/net/http-proxy/README.zh.md

@@ -36,7 +36,7 @@ Node 内置的 `fetch` 会忽略 `HTTP_PROXY` 与 `HTTPS_PROXY`,因此在代
 | 需要自定义 agent 选项的调用(连接池、超时、DNS 查询) | `createDispatcher(url, options)` |
 | 接受 `node:http` agent 的 SDK | `createNodeHttpAgent(protocol, options)` |
 | 接受自有代理 URL 的 SDK | `proxyUrlFor(url)` |
-| worker 线程,或由你自己构造环境的派生进程 | 把 `childProxyEnv()` 并入其环境 |
+| 由你自己构造环境的派生进程 | 把 `childProxyEnv()` 应用到它上面(`undefined` 表示删除) |
 
 构造 `new Agent(...)` 再作为 `dispatcher` 传入会覆盖全局 dispatcher,从而静默绕开代理。`verify-no-bare-dispatcher` 会在本包之外拒绝该写法;确实必须忽略代理的行用 `proxy-exempt:` 注释说明理由。
 
@@ -61,7 +61,7 @@ loopback 始终被绕过。否则 Harness 自己的 Web UI、Connection 传输
 
 **一次解析,两个读者。** `proxyForUrl()` 与已安装的 dispatcher 绝不能对同一个 URL 给出不同答案,否则 `dsh-web-fetch-http` 会把 dispatcher 本打算隧道转发的连接固定到某个地址上。因此安装时会把解析出的策略发布到代理环境变量中,并以无选项方式构造 `EnvHttpProxyAgent`,让该 agent 读回的正是解析结果,而不是按略有差异的规则重新解析原始环境。
 
-**发布策略同时也是子进程继承的途径。** 同一次写入还规范化了每个派生进程看到的内容:`ALL_PROXY` 兜底落为具体的 `HTTP_PROXY`,绕过列表也已并入 loopback。
+**子进程继承的是用户导出的值,而非本进程解析出的值。** 写回环境只服务 undici;派生子进程前,`childProxyEnv()` 会把每个变量名还原为原值。把规范化结果塞给子进程,会让 `curl` 拿到一个由 HTTP 代理凭空推出的 `HTTPS_PROXY`,或让用户为 `curl` 设置的 SOCKS 代理被替换成他们从未为该协议指定过的 HTTP 代理。
 
 ### 源码地图
 
@@ -101,10 +101,11 @@ loopback 始终被绕过。否则 Harness 自己的 Web UI、Connection 传输
 
 这些限制界定了本包不适用的场景,属于当前的包级约束。
 
-- **不支持 SOCKS、PAC 或操作系统代理探测**——只接受来自环境或配置的 `http(s)://` 代理 URL。不会读取 macOS 或 Windows 的系统代理设置,因此仅在代理软件里拨了开关的用户仍须导出环境变量;SOCKS URL 会被报告并跳过,而不是静默忽略。
+- **不支持 SOCKS、PAC 或操作系统代理探测**——只接受来自环境或配置的 `http(s)://` 代理 URL。不会读取 macOS 或 Windows 的系统代理设置,因此仅在代理软件里拨了开关的用户仍须导出环境变量;SOCKS URL 会被报告,且该协议保持直连,不会借用另一协议的代理。
 - **不支持自定义证书颁发机构**——做 TLS 拦截的企业代理需要在启动前为进程设置 `NODE_EXTRA_CA_CERTS`,本包既不设置也不校验它。
-- **独立的 Node 上下文按 Node 自己的规则匹配绕过条目,而非本包的规则**——子进程或 worker 线程通过 Node 自带的 `NODE_USE_ENV_PROXY` 支持来遵循策略,而它的 `NO_PROXY` 解析在分隔符与 IPv4 区间处理上与此处不同,且仅存在于 Node 22.21+ 与 24+。更旧的运行时会让该上下文保持直连。
-- **`code-runtime` worker 被刻意排除在外**——模型编写的程序运行时完全没有环境变量,而代理 URL 可能携带凭据。
+- **独立的 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 区间处理与本包不同。
+- **执行模型编写代码的 worker 完全不获得代理**——`code-runtime` worker 与 `workflow` worker 都不接收代理配置,它们自身的请求直连。代理 URL 可能携带 `user:password`,而两者运行的都是模型写的脚本。
+- **防回归门禁只看源码,看不到依赖内部**——`verify-no-bare-dispatcher` 解析 `packages/*/*/src` 与 `apps/*/src`;测试、脚本以及第三方 SDK 的内部都在其之外。这正是每个出网点还各配一份 `egress.spec.ts` 的原因。
 
 <a id="dev-note"></a>
 ### 开发备注

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

@@ -15,6 +15,14 @@ import { DIRECT_POLICY, POLICY_ENV_NAMES, proxyForUrl, type ProxyPolicy } from '
 /** The active policy, or `undefined` until one is installed. Process-wide, like the dispatcher it tracks. */
 let active: ProxyPolicy | undefined
 
+/**
+ * The proxy environment as it stood before the active policy was published, or `undefined` when none
+ * is installed. A spawned child receives these, not the published ones: normalizing what this process
+ * resolved into a child's environment would replace a value the user set for another tool — a SOCKS
+ * proxy this package refuses but `curl` uses, or an `HTTPS_PROXY` the user never wrote at all.
+ */
+let inheritedProxyEnv: Readonly<Record<string, string | undefined>> | undefined
+
 /**
  * The policy governing this process's outbound requests.
  *
@@ -34,11 +42,17 @@ export function currentProxyPolicy(): ProxyPolicy | undefined {
  * @returns a function restoring every name this call changed.
  */
 function applyPolicyEnv(policy: ProxyPolicy): () => void {
+  // Snapshot EVERY name before writing any of them. Windows folds environment names case-insensitively,
+  // so reading the uppercase spelling after writing the lowercase one would read back the value just
+  // written and restore the policy instead of the user's environment.
   const previous = new Map<string, string | undefined>()
+  for (const names of Object.values(POLICY_ENV_NAMES)) {
+    for (const name of names) previous.set(name, process.env[name])
+  }
+  inheritedProxyEnv = Object.fromEntries(previous)
   for (const [field, names] of Object.entries(POLICY_ENV_NAMES)) {
     const value = policy[field as keyof typeof POLICY_ENV_NAMES]
     for (const name of names) {
-      previous.set(name, process.env[name])
       if (value === undefined || value === '') Reflect.deleteProperty(process.env, name)
       else process.env[name] = value
     }
@@ -48,6 +62,7 @@ function applyPolicyEnv(policy: ProxyPolicy): () => void {
       if (value === undefined) Reflect.deleteProperty(process.env, name)
       else process.env[name] = value
     }
+    inheritedProxyEnv = undefined
   }
 }
 
@@ -67,10 +82,26 @@ function applyPolicyEnv(policy: ProxyPolicy): () => void {
 export 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
+    // alone would leave the previous agent as the global dispatcher, so a plain `fetch()` would keep
+    // tunnelling while `proxyForUrl()` reported a direct connection — and `mode: 'off'` would be a
+    // silent no-op. With nothing installed there is nothing to displace.
+    if (previousPolicy === undefined) {
+      active = policy
+      return () => {
+        active = previousPolicy
+        return Promise.resolve()
+      }
+    }
+    const undici = await import('undici')
+    const previous = undici.getGlobalDispatcher()
+    const direct = new undici.Agent()
+    undici.setGlobalDispatcher(direct)
     active = policy
-    return () => {
+    return async () => {
+      undici.setGlobalDispatcher(previous)
       active = previousPolicy
-      return Promise.resolve()
+      await direct.close()
     }
   }
   const restoreEnv = applyPolicyEnv(policy)
@@ -98,7 +129,9 @@ export async function installGlobalProxy(policy: ProxyPolicy): Promise<() => Pro
  * `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.
+ * @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.
  * @returns a dispatcher the caller owns and must close once the response body is consumed.
  */
 export async function createDispatcher(url: URL, options: Agent.Options = {}): Promise<Dispatcher> {
@@ -147,29 +180,27 @@ export function proxyUrlFor(url: URL): string | undefined {
 }
 
 /**
- * The proxy environment a separate Node execution context needs: the resolved policy plus the flag
- * that makes Node's built-in HTTP clients honor it.
+ * The proxy environment a spawned child needs.
+ *
+ * A child inherits the parent environment, which this process rewrote to its own resolved policy so
+ * undici reads back exactly what was resolved. That normalization must not reach the child: it would
+ * hand `curl` an `HTTPS_PROXY` this package invented from the HTTP one, or replace a SOCKS proxy the
+ * user set for `curl` with an HTTP proxy they never named for that scheme. The result therefore
+ * restores each name to the value the user exported — `undefined` for a name they never set — and
+ * adds only the flag that makes a child Node honor them.
  *
- * This covers both shapes DSH spawns. A child process inherits the parent environment, so the proxy
- * names merely restate what {@link installGlobalProxy} already published and the flag is what it
- * gains. A worker thread is given an explicit, near-empty environment instead, so it needs the names
- * as well — and worker threads do not inherit the global dispatcher, which is why they are handled
- * here rather than left to the parent's installation.
+ * The flag reaches only Node 22.21+ and 24+; an older runtime keeps that child direct. Such a child
+ * also matches bypass entries with Node's own `NO_PROXY` rules, which differ from this package's in
+ * their separators and IPv4-range support. Non-Node children (curl, git, pnpm) ignore the flag and
+ * read the variables themselves.
  *
- * The flag reaches only Node 22.21+ and 24+; an older runtime keeps that context 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. Non-Node children (curl, git, pnpm) ignore
- * the flag and read the variables themselves.
+ * A worker thread is deliberately NOT served here — see the workflow engine, which runs
+ * model-authored scripts and must not receive a proxy URL that may carry credentials.
  *
- * @returns names to merge into the child or worker environment, or an empty object when no proxy is active.
+ * @returns names to apply to the child environment, where `undefined` means remove, or an empty
+ *   object when no proxy is active.
  */
-export function childProxyEnv(): Record<string, string> {
-  if (active === undefined || active.source === 'none') return {}
-  const env: Record<string, string> = { NODE_USE_ENV_PROXY: '1' }
-  for (const [field, names] of Object.entries(POLICY_ENV_NAMES)) {
-    const value = active[field as keyof typeof POLICY_ENV_NAMES]
-    if (value === undefined || value === '') continue
-    for (const name of names) env[name] = value
-  }
-  return env
+export function childProxyEnv(): Readonly<Record<string, string | undefined>> {
+  if (active === undefined || active.source === 'none' || inheritedProxyEnv === undefined) return {}
+  return { ...inheritedProxyEnv, NODE_USE_ENV_PROXY: '1' }
 }

+ 51 - 22
packages/net/http-proxy/src/policy.ts

@@ -126,18 +126,31 @@ function readEnv(
   return undefined
 }
 
+/**
+ * What one environment or configuration slot supplied. A rejected slot is distinct from an absent
+ * one: the user named a proxy for that scheme, so falling back to another scheme's proxy would route
+ * the request somewhere they never asked for while the diagnostic said it stayed direct.
+ */
+type ProxyCandidate =
+  | { readonly kind: 'accepted'; readonly value: string }
+  | { readonly kind: 'rejected' }
+  | { readonly kind: 'absent' }
+
+/** A slot nobody filled. */
+const ABSENT: ProxyCandidate = { kind: 'absent' }
+
 /**
  * Validate one candidate proxy URL.
  *
  * @param candidate - the raw value and the origin to name in a diagnostic.
  * @param diagnostics - collector the rejection is appended to.
- * @returns the candidate when it is a usable `http(s):` proxy URL, otherwise `undefined`.
+ * @returns the candidate's usability, distinguishing a rejected slot from an empty one.
  */
 function acceptProxyUrl(
   candidate: { value: string; name: string } | undefined,
   diagnostics: ProxyDiagnostic[],
-): string | undefined {
-  if (candidate === undefined) return undefined
+): ProxyCandidate {
+  if (candidate === undefined) return ABSENT
   const parsed = URL.parse(candidate.value)
   if (parsed === null) {
     diagnostics.push({
@@ -145,25 +158,39 @@ function acceptProxyUrl(
       origin: candidate.name,
       message: `${candidate.name} is not a valid URL; connecting directly`,
     })
-    return undefined
+    return { kind: 'rejected' }
   }
   if (SOCKS_PROTOCOLS.has(parsed.protocol)) {
     diagnostics.push({
       kind: 'socks',
       origin: candidate.name,
-      message: `${candidate.name} names a SOCKS proxy, which is not supported; set an http:// or https:// proxy URL instead`,
+      message: `${candidate.name} names a SOCKS proxy, which is not supported; connecting directly for that scheme — set an http:// or https:// proxy URL instead`,
     })
-    return undefined
+    return { kind: 'rejected' }
   }
   if (!SUPPORTED_PROTOCOLS.has(parsed.protocol)) {
     diagnostics.push({
       kind: 'invalid',
       origin: candidate.name,
-      message: `${candidate.name} uses the unsupported ${parsed.protocol}// scheme; set an http:// or https:// proxy URL instead`,
+      message: `${candidate.name} uses the unsupported ${parsed.protocol}// scheme; connecting directly for that scheme — set an http:// or https:// proxy URL instead`,
     })
-    return undefined
+    return { kind: 'rejected' }
   }
-  return candidate.value
+  return { kind: 'accepted', value: candidate.value }
+}
+
+/**
+ * Resolve one scheme's proxy from its own slot, then the fallbacks — but only when the scheme's own
+ * slot was empty. A rejected slot keeps that scheme direct, so the diagnostic and the route agree.
+ *
+ * @param own - what the scheme's own name supplied.
+ * @param fallbacks - values to try in order when `own` is absent.
+ * @returns the proxy URL for that scheme, or `undefined` for a direct connection.
+ */
+function resolveScheme(own: ProxyCandidate, ...fallbacks: (string | undefined)[]): string | undefined {
+  if (own.kind === 'accepted') return own.value
+  if (own.kind === 'rejected') return undefined
+  return fallbacks.find(value => value !== undefined)
 }
 
 /**
@@ -252,32 +279,34 @@ export function resolveProxyPolicy(
   if (config.mode === 'off') return { policy: DIRECT_POLICY, diagnostics }
 
   const all = acceptProxyUrl(readEnv(env, 'all_proxy'), diagnostics)
-  const httpFromEnv = acceptProxyUrl(readEnv(env, 'http_proxy'), diagnostics) ?? all
-  const httpsFromEnv = acceptProxyUrl(readEnv(env, 'https_proxy'), diagnostics) ?? all
-
-  const httpFromConfig = acceptProxyUrl(
+  const allValue = all.kind === 'accepted' ? all.value : undefined
+  const configHttp = acceptProxyUrl(
     config.httpProxy === undefined ? undefined : { value: config.httpProxy, name: 'config.httpProxy' },
     diagnostics,
   )
-  const httpsFromConfig = acceptProxyUrl(
+  const configHttps = acceptProxyUrl(
     config.httpsProxy === undefined ? undefined : { value: config.httpsProxy, name: 'config.httpsProxy' },
     diagnostics,
   )
+  const configHttpValue = configHttp.kind === 'accepted' ? configHttp.value : undefined
+  const configHttpsValue = configHttps.kind === 'accepted' ? configHttps.value : undefined
 
-  const httpProxy = httpFromEnv ?? httpFromConfig
-  // HTTPS falls back to the HTTP proxy, so an undefined result here means no layer supplied any
-  // proxy at all — one check covers both schemes.
-  const httpsProxy = httpsFromEnv ?? httpsFromConfig ?? httpProxy
-  if (httpsProxy === undefined) return { policy: DIRECT_POLICY, diagnostics }
+  const envHttp = acceptProxyUrl(readEnv(env, 'http_proxy'), diagnostics)
+  const envHttps = acceptProxyUrl(readEnv(env, 'https_proxy'), diagnostics)
+  const httpProxy = resolveScheme(envHttp, allValue, configHttpValue)
+  // HTTPS falls back to the HTTP proxy last, matching undici — but never past a value the user named
+  // for HTTPS and this package refused.
+  const httpsProxy = resolveScheme(envHttps, allValue, configHttpsValue, httpProxy)
+  if (httpProxy === undefined && httpsProxy === undefined) return { policy: DIRECT_POLICY, diagnostics }
 
   const noProxy = withLoopback(readEnv(env, 'no_proxy')?.value ?? config.noProxy)
-  const source = httpFromEnv !== undefined || httpsFromEnv !== undefined ? 'env' : 'config'
+  const fromEnv = envHttp.kind === 'accepted' || envHttps.kind === 'accepted' || all.kind === 'accepted'
   return {
     policy: {
       ...httpProxy === undefined ? {} : { httpProxy },
-      httpsProxy,
+      ...httpsProxy === undefined ? {} : { httpsProxy },
       noProxy,
-      source,
+      source: fromEnv ? 'env' : 'config',
     },
     diagnostics,
   }

+ 46 - 15
packages/net/http-proxy/tests/install.spec.ts

@@ -184,34 +184,65 @@ describe('childProxyEnv', () => {
     }
   })
 
-  it('carries the resolved policy and the flag that makes a child Node honor it', async () => {
+  it('hands a child the values the user exported, not this process\'s normalization', 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 installGlobalProxy(proxyAll('example.com'))
     try {
-      expect(childProxyEnv()).toEqual({
-        NODE_USE_ENV_PROXY: '1',
-        http_proxy: proxyUrl,
-        HTTP_PROXY: proxyUrl,
-        https_proxy: proxyUrl,
-        HTTPS_PROXY: proxyUrl,
-        no_proxy: 'example.com',
-        NO_PROXY: 'example.com',
-      })
+      const child = childProxyEnv()
+      // The published policy invented an HTTPS proxy for this process; the child must not see it.
+      expect(child.https_proxy).toBe('socks5://127.0.0.1:1080')
+      expect(child.HTTPS_PROXY).toBeUndefined()
+      expect(child.HTTP_PROXY).toBe(proxyUrl)
+      expect(child.no_proxy).toBeUndefined()
+      expect(child.NODE_USE_ENV_PROXY).toBe('1')
     } finally {
       await dispose()
+      delete process.env.HTTP_PROXY
+      delete process.env.https_proxy
     }
   })
+})
 
-  it('omits a scheme the policy leaves direct, so a worker inherits no stale name', async () => {
-    const dispose = await installGlobalProxy({ httpProxy: proxyUrl, noProxy: '', source: 'env' })
+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())
     try {
-      expect(childProxyEnv()).not.toHaveProperty('HTTPS_PROXY')
-      expect(childProxyEnv()).not.toHaveProperty('NO_PROXY')
+      await expect((await fetch(originUrl)).text()).resolves.toBe('VIA-PROXY')
+      const off = await installGlobalProxy(DIRECT_POLICY)
+      try {
+        // `mode: 'off'` must actually stop proxying, not merely report a direct policy while the
+        // launcher's agent keeps tunnelling.
+        await expect((await fetch(originUrl)).text()).resolves.toBe('DIRECT')
+        expect(currentProxyPolicy()).toBe(DIRECT_POLICY)
+      } finally {
+        await off()
+      }
+      // Disposing the direct policy restores the proxy the launcher installed.
+      await expect((await fetch(originUrl)).text()).resolves.toBe('VIA-PROXY')
     } finally {
-      await dispose()
+      await outer()
     }
   })
 })
 
+describe('applyPolicyEnv restoration', () => {
+  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())
+    expect(process.env.HTTP_PROXY).toBe(proxyUrl)
+    await dispose()
+    // Reading the uppercase spelling after writing the lowercase one must not restore the value
+    // just written — the failure Windows's case-folded environment would produce.
+    expect(process.env.http_proxy).toBe('http://before.example')
+    expect(process.env.HTTP_PROXY).toBe('http://before.example')
+    delete process.env.http_proxy
+    delete process.env.HTTP_PROXY
+  })
+})
+
 describe('createNodeHttpAgent', () => {
   /** Drive a real `node:http` request, which the global dispatcher never reaches. */
   function get(target: string, agent: http.Agent): Promise<string> {

+ 63 - 0
packages/net/http-proxy/tests/matcher-parity.spec.ts

@@ -0,0 +1,63 @@
+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'
+
+/**
+ * `proxyForUrl` and the installed `EnvHttpProxyAgent` are two matchers over one bypass list. They are
+ * fed the same values, but their parsers are independent: a form they judge differently would route a
+ * plain `fetch` one way and `web_fetch` the other. These cases pin the forms in the documented
+ * vocabulary against the agent's real behavior.
+ */
+const CASES: readonly { readonly noProxy: string; readonly path: string; readonly bypassed: boolean }[] = [
+  { noProxy: '', path: '/plain', bypassed: false },
+  { noProxy: 'probe.invalid', path: '/exact', bypassed: true },
+  { noProxy: '.probe.invalid', path: '/dot-suffix', bypassed: true },
+  { noProxy: '*.probe.invalid', path: '/star-suffix', bypassed: true },
+  { noProxy: 'other.invalid', path: '/miss', bypassed: false },
+  { noProxy: '*', path: '/all', bypassed: true },
+  { noProxy: 'probe.invalid:80', path: '/with-default-port', bypassed: true },
+  { noProxy: 'probe.invalid:8443', path: '/wrong-port', bypassed: false },
+  { noProxy: 'a.invalid, probe.invalid', path: '/comma-list', bypassed: true },
+]
+
+let seen: string[] = []
+let proxy: Server
+let proxyUrl: string
+
+beforeAll(async () => {
+  proxy = createServer((request, response) => {
+    seen.push(request.url ?? '')
+    response.writeHead(200, { 'content-type': 'text/plain' })
+    response.end('VIA-PROXY')
+  })
+  const address = await new Promise<AddressInfo>((resolve) => {
+    proxy.listen(0, '127.0.0.1', () => { resolve(proxy.address() as AddressInfo) })
+  })
+  proxyUrl = `http://127.0.0.1:${String(address.port)}`
+})
+
+afterAll(async () => {
+  await new Promise<void>((resolve) => { proxy.close(() => { resolve() }) })
+})
+
+function policy(noProxy: string): ProxyPolicy {
+  return { httpProxy: proxyUrl, httpsProxy: proxyUrl, noProxy, source: 'env' }
+}
+
+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))
+    try {
+      // A bypassed target has no route here, so the fetch fails; a proxied one reaches the recorder.
+      await fetch(url).then(response => response.text()).catch(() => undefined)
+      const agentProxied = seen.length > 0
+      expect({ ours: proxyForUrl(policy(noProxy), url) !== undefined, agent: agentProxied })
+        .toEqual({ ours: !bypassed, agent: !bypassed })
+    } finally {
+      await dispose()
+    }
+  })
+})

+ 23 - 0
packages/net/http-proxy/tests/policy.spec.ts

@@ -67,6 +67,22 @@ describe('resolveProxyPolicy', () => {
     expect(policy.noProxy).toBe('localhost,127.0.0.1,::1,[::1]')
   })
 
+  it('keeps a scheme direct when its own value was refused, rather than falling back', () => {
+    const { policy, diagnostics } = resolveProxyPolicy(env({ HTTPS_PROXY: 'socks5://127.0.0.1:1080', HTTP_PROXY: PROXY }))
+    expect(policy.httpProxy).toBe(PROXY)
+    // The diagnostic says HTTPS connects directly; the route must agree rather than borrowing the
+    // HTTP proxy the user never named for HTTPS.
+    expect(policy.httpsProxy).toBeUndefined()
+    expect(proxyForUrl(policy, new URL('https://example.com/'))).toBeUndefined()
+    expect(diagnostics[0]?.message).toMatch(/connecting directly for that scheme/)
+  })
+
+  it('keeps a scheme direct when its own value was malformed, past ALL_PROXY too', () => {
+    const { policy } = resolveProxyPolicy(env({ HTTPS_PROXY: 'not a url', ALL_PROXY: PROXY }))
+    expect(policy.httpProxy).toBe(PROXY)
+    expect(policy.httpsProxy).toBeUndefined()
+  })
+
   it('reports a SOCKS proxy instead of silently ignoring it', () => {
     const { policy, diagnostics } = resolveProxyPolicy(env({ HTTP_PROXY: 'socks5://127.0.0.1:7890' }))
     expect(policy).toEqual(DIRECT_POLICY)
@@ -95,6 +111,13 @@ describe('resolveProxyPolicy', () => {
     expect(policy.source).toBe('config')
   })
 
+  it('takes each scheme from its own configured field', () => {
+    const { policy } = resolveProxyPolicy(env({}), { httpProxy: PROXY, httpsProxy: OTHER })
+    expect(policy.httpProxy).toBe(PROXY)
+    expect(policy.httpsProxy).toBe(OTHER)
+    expect(policy.source).toBe('config')
+  })
+
   it('lets the environment outrank configuration', () => {
     const { policy } = resolveProxyPolicy(env({ HTTP_PROXY: PROXY }), { httpProxy: OTHER })
     expect(policy.httpProxy).toBe(PROXY)

+ 4 - 2
packages/session/session-telemetry-otel/src/index.ts

@@ -217,9 +217,11 @@ export class OpenTelemetrySessionBackend extends SessionTelemetryBackend {
           // 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.
+          // 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: true }),
+            httpAgentOptions: (protocol: string) =>
+              createNodeHttpAgent(protocol, { keepAlive: config.exporter?.keepAlive ?? true }),
             ...config.exporter,
           }),
         }),

+ 57 - 3
packages/session/session-telemetry-otel/tests/egress.spec.ts

@@ -51,12 +51,12 @@ afterAll(() => {
 })
 
 /** Mount the shipping backend against an unresolvable collector and let it try to export. */
-async function exportThroughBackend(): Promise<void> {
+async function exportThroughBackend(host: string, exporter: Record<string, unknown> = {}): Promise<void> {
   const ctx = new Context()
   await ctx.plugin(SessionStore)
   const fiber = await ctx.plugin(OpenTelemetrySessionBackend, {
     mode: SessionTelemetryMode.FULL,
-    exporter: { url: 'http://otel-probe.invalid/v1/logs' },
+    exporter: { url: `http://${host}/v1/logs`, ...exporter },
   })
   const session = ctx.sessions.create(SessionId('egress'), { meta: { cwd: '/tmp/e' } })
   session.append('turn/start', { turn: 1 })
@@ -64,8 +64,62 @@ async function exportThroughBackend(): Promise<void> {
   await fiber.dispose()
 }
 
+
+/**
+ * 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 () => {
-    expect((await observe(exportThroughBackend)).join('|')).toContain('otel-probe.invalid')
+    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('')
+  })
+
+  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 })
+      },
+    }))
+    // 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()
+    }
   })
 })

+ 2 - 1
packages/subprocess/subprocess/package.json

@@ -39,6 +39,7 @@
   "devDependencies": {
     "@deepseek-ai/cordis": "workspace:^",
     "@deepseek-ai/dsh-http-proxy": "workspace:^",
-    "@deepseek-ai/dsh-invariants": "workspace:^"
+    "@deepseek-ai/dsh-invariants": "workspace:^",
+    "@deepseek-ai/dsh-launch-environment": "workspace:^"
   }
 }

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

@@ -67,8 +67,14 @@ export function scrubbedParentEnv(): Record<string, string> {
     if (value !== undefined && !SENSITIVE_ENV_PATTERN.test(key) && !key.toUpperCase().startsWith(DSH_ENV_PREFIX)) env[key] = value
   }
   // A child Node ignores the inherited proxy variables unless the flag this adds is set, so an MCP
-  // stdio server or subagent CLI would connect directly while its parent proxies.
-  return { ...env, ...childProxyEnv() }
+  // 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())) {
+    if (value === undefined) Reflect.deleteProperty(env, name)
+    else env[name] = value
+  }
+  return env
 }
 
 declare module '@deepseek-ai/cordis' {

+ 86 - 38
packages/subprocess/subprocess/tests/egress.spec.ts

@@ -1,71 +1,119 @@
 import { createServer, type Server } from 'node:http'
+import { spawn } from 'node:child_process'
 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 { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'
+import {
+  PROXY_ENV_NAMES,
+  installGlobalProxy,
+  resolveProxyPolicy,
+} from '@deepseek-ai/dsh-http-proxy'
+import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
+import { scrubbedParentEnv } from '../src/index.ts'
+
+/**
+ * Whether this runtime honors `NODE_USE_ENV_PROXY`, which is how a child Node receives the policy.
+ * Added in Node 24.0 and backported to 22.21; the engines range admits 22.19 and 22.20, where a
+ * child stays direct.
+ */
+function supportsEnvProxy(): boolean {
+  const [major = 0, minor = 0] = process.versions.node.split('.').map(Number)
+  return major >= 24 || (major === 22 && minor >= 21)
+}
 
 let seen: string[] = []
 let proxy: Server
 let proxyUrl: string
+let saved: Record<string, string | undefined> = {}
 
 beforeAll(async () => {
   proxy = createServer((request, response) => {
-    seen.push(`REQ ${request.url ?? ''}`)
-    response.writeHead(502); response.end('fake-proxy')
+    seen.push(request.url ?? '')
+    response.writeHead(200)
+    response.end('VIA-PROXY')
   })
+  // Node's own proxy support may tunnel rather than send an absolute-form request; record either.
   proxy.on('connect', (request, socket) => {
-    seen.push(`CONNECT ${request.url ?? ''}`)
-    socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n'); socket.end()
+    seen.push(request.url ?? '')
+    socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n')
+    socket.end()
+  })
+  const address = await new Promise<AddressInfo>((resolve) => {
+    proxy.listen(0, '127.0.0.1', () => { resolve(proxy.address() as AddressInfo) })
   })
-  const a = await new Promise<AddressInfo>((r) => { proxy.listen(0, '127.0.0.1', () => { r(proxy.address() as AddressInfo) }) })
-  proxyUrl = `http://127.0.0.1:${String(a.port)}`
+  proxyUrl = `http://127.0.0.1:${String(address.port)}`
 })
-afterAll(async () => { await new Promise<void>((r) => { proxy.close(() => { r() }) }) })
 
-function policy(): ProxyPolicy {
-  return { httpProxy: proxyUrl, httpsProxy: proxyUrl, noProxy: '', source: 'env' }
-}
-async function observe(run: () => Promise<unknown>): Promise<string[]> {
+afterAll(async () => {
+  await new Promise<void>((resolve) => { proxy.close(() => { resolve() }) })
+})
+
+beforeEach(() => {
   seen = []
-  const dispose = await installGlobalProxy(policy())
-  try { await run().catch(() => undefined) } finally { await dispose() }
-  return seen
-}
-import { spawn } from 'node:child_process'
-import { scrubbedParentEnv } from '../src/index.ts'
+  saved = Object.fromEntries(PROXY_ENV_NAMES.map(name => [name, process.env[name]]))
+  for (const name of PROXY_ENV_NAMES) Reflect.deleteProperty(process.env, name)
+})
+
+afterEach(() => {
+  for (const [name, value] of Object.entries(saved)) {
+    if (value === undefined) Reflect.deleteProperty(process.env, name)
+    else process.env[name] = value
+  }
+})
 
 /** Run a child Node that fetches, using exactly the environment every harness spawner builds. */
 function childFetch(target: string, env: Record<string, string>): Promise<string> {
   return new Promise((resolve) => {
-    const child = spawn(process.execPath, ['-e', `fetch(${JSON.stringify(target)}).then(r=>r.text()).then(t=>console.log(t)).catch(e=>console.log('ERR'+String(e.cause?.code)))`],
-      { env, stdio: ['ignore', 'pipe', 'ignore'] })
+    const child = spawn(
+      process.execPath,
+      ['-e', `fetch(${JSON.stringify(target)}).then(r=>r.text()).then(t=>console.log(t)).catch(e=>console.log('ERR'+String(e.cause?.code)))`],
+      { env, stdio: ['ignore', 'pipe', 'ignore'] },
+    )
     let out = ''
-    child.stdout.on('data', (c: Buffer) => { out += c.toString() })
+    child.stdout.on('data', (chunk: Buffer) => { out += chunk.toString() })
     child.on('close', () => { resolve(out.trim()) })
   })
 }
 
-
-/**
- * Whether this runtime honors `NODE_USE_ENV_PROXY`, which is how a separate Node execution context
- * receives the policy. Added in Node 24.0 and backported to 22.21; the engines range admits 22.19
- * and 22.20, where such a context stays direct.
- */
-function supportsEnvProxy(): boolean {
-  const [major = 0, minor = 0] = process.versions.node.split('.').map(Number)
-  return major >= 24 || (major === 22 && minor >= 21)
-}
-
 describe('child process egress', () => {
-  it('a child Node honors the parent policy through scrubbedParentEnv', async () => {
+  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(
+      createLaunchEnvironmentSnapshot([{ source: 'process', values: { HTTP_PROXY: proxyUrl } }]),
+    )
+    const dispose = await installGlobalProxy(policy)
     let childEnv: Record<string, string> = {}
-    const observed = await observe(async () => {
+    try {
       childEnv = scrubbedParentEnv()
       await childFetch('http://child-probe.invalid/x', childEnv)
-    })
+    } finally {
+      await dispose()
+    }
     expect(childEnv.NODE_USE_ENV_PROXY).toBe('1')
+    expect(childEnv.HTTP_PROXY).toBe(proxyUrl)
     // The flag is what a child Node acts on; an older runtime ignores it and stays direct, which is
     // the documented seam rather than a defect.
-    if (supportsEnvProxy()) expect(observed.join('|')).toContain('child-probe.invalid')
-    else expect(observed).toEqual([])
+    if (supportsEnvProxy()) expect(seen.join('|')).toContain('child-probe.invalid')
+    else expect(seen).toEqual([])
+  })
+
+  it('does not invent a proxy name the user never exported', async () => {
+    process.env.HTTP_PROXY = proxyUrl
+    const { policy } = resolveProxyPolicy(
+      createLaunchEnvironmentSnapshot([{ source: 'process', values: { HTTP_PROXY: proxyUrl } }]),
+    )
+    const dispose = await installGlobalProxy(policy)
+    try {
+      // This process resolved an HTTPS proxy by falling back to the HTTP one; a child must not see
+      // a name the user never set, because `curl` performs no such fallback of its own.
+      expect(process.env.HTTPS_PROXY).toBe(proxyUrl)
+      expect(scrubbedParentEnv().HTTPS_PROXY).toBeUndefined()
+    } finally {
+      await dispose()
+    }
+  })
+
+  it('adds nothing when no proxy is active', () => {
+    expect(scrubbedParentEnv().NODE_USE_ENV_PROXY).toBeUndefined()
   })
 })

+ 3 - 0
packages/subprocess/subprocess/tsconfig.json

@@ -19,6 +19,9 @@
     },
     {
       "path": "../../net/http-proxy"
+    },
+    {
+      "path": "../../util/launch-environment"
     }
   ]
 }

+ 1 - 0
packages/web/web-fetch-http/src/network.ts

@@ -225,6 +225,7 @@ async function requestWith(
   const { fetch } = await import('undici')
   const dispatcher = await createDispatcher(url, options)
   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) {

+ 6 - 9
packages/workflow/workflow-worker-thread/src/host.ts

@@ -8,7 +8,6 @@
 
 import { tmpdir } from 'node:os'
 import { Worker } from 'node:worker_threads'
-import { childProxyEnv } from '@deepseek-ai/dsh-http-proxy'
 import type { WorkerOptions } from 'node:worker_threads'
 import { fileURLToPath } from 'node:url'
 import type { Context } from '@deepseek-ai/cordis'
@@ -31,9 +30,11 @@ interface ChildRecord {
 }
 
 /**
- * The scrubbed worker environment: no ambient credentials, no loader flags, plus the active proxy
- * policy — a worker thread does not inherit the host's global dispatcher, so this is the only way
- * its requests reach the same proxy the host uses.
+ * The scrubbed worker environment: no ambient credentials, no loader flags, and deliberately no
+ * proxy policy. A worker thread does not inherit the host's global dispatcher, so a workflow's own
+ * requests go direct — the alternative is handing the worker a proxy URL that may carry
+ * `user:password`, and this worker executes the model-authored script body. That is the same
+ * containment the code runtime keeps, and `docs/defensive-patterns.md` requires it.
  * Windows derives `os.tmpdir()` from `TMP`/`TEMP` and falls back to the
  * literal relative path `undefined\temp` when the environment is empty, so
  * tsx's transform cache would land in a cwd-relative `undefined/temp`
@@ -49,11 +50,7 @@ export function workerSpawnEnv(
   platform: NodeJS.Platform = process.platform,
   tsconfigPath?: string,
 ): NodeJS.ProcessEnv {
-  // A worker thread gets its own globalThis and therefore does NOT inherit the host's undici global
-  // dispatcher, so a workflow that fetches would connect directly while its host proxies. This
-  // near-empty environment is the only channel it has: the proxy names plus Node's own opt-in flag
-  // reach the worker's pre-execution setup, which runs per thread.
-  const env: NodeJS.ProcessEnv = { ...childProxyEnv() }
+  const env: NodeJS.ProcessEnv = {}
   if (platform === 'win32') {
     const tmp = tmpdir()
     env.TMP = tmp

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

@@ -1,64 +1,31 @@
-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'
-
-let seen: string[] = []
-let proxy: Server
-let proxyUrl: string
-
-beforeAll(async () => {
-  proxy = createServer((request, response) => {
-    seen.push(`REQ ${request.url ?? ''}`)
-    response.writeHead(502); response.end('fake-proxy')
-  })
-  proxy.on('connect', (request, socket) => {
-    seen.push(`CONNECT ${request.url ?? ''}`)
-    socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n'); socket.end()
-  })
-  const a = await new Promise<AddressInfo>((r) => { proxy.listen(0, '127.0.0.1', () => { r(proxy.address() as AddressInfo) }) })
-  proxyUrl = `http://127.0.0.1:${String(a.port)}`
-})
-afterAll(async () => { await new Promise<void>((r) => { proxy.close(() => { r() }) }) })
-
-function policy(): ProxyPolicy {
-  return { httpProxy: proxyUrl, httpsProxy: proxyUrl, noProxy: '', source: 'env' }
-}
-async function observe(run: () => Promise<unknown>): Promise<string[]> {
-  seen = []
-  const dispose = await installGlobalProxy(policy())
-  try { await run().catch(() => undefined) } finally { await dispose() }
-  return seen
-}
-import { Worker } from 'node:worker_threads'
-import { once } from 'node:events'
+import { describe, expect, it } from 'vitest'
+import { PROXY_ENV_NAMES, installGlobalProxy, type ProxyPolicy } from '@deepseek-ai/dsh-http-proxy'
 import { workerSpawnEnv } from '../src/host.ts'
 
-
-/**
- * Whether this runtime honors `NODE_USE_ENV_PROXY`, which is how a separate Node execution context
- * receives the policy. Added in Node 24.0 and backported to 22.21; the engines range admits 22.19
- * and 22.20, where such a context stays direct.
- */
-function supportsEnvProxy(): boolean {
-  const [major = 0, minor = 0] = process.versions.node.split('.').map(Number)
-  return major >= 24 || (major === 22 && minor >= 21)
+/** 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',
 }
 
-describe('worker thread egress', () => {
-  it('a worker honors the host policy through workerSpawnEnv', async () => {
-    const observed = await observe(async () => {
-      const worker = new Worker(
-        `import { parentPort, workerData } from 'node:worker_threads'
-         let out; try { out = await (await fetch(workerData.u)).text() } catch (e) { out = 'ERR' + String(e.cause?.code) }
-         parentPort.postMessage(out)`,
-        { eval: true, workerData: { u: 'http://worker-probe.invalid/x' }, env: workerSpawnEnv(), execArgv: [] },
-      )
-      await once(worker, 'message')
-      await worker.terminate()
-    })
-    // Same seam as a spawned child: the worker acts on the flag its environment carries.
-    if (supportsEnvProxy()) expect(observed.join('|')).toContain('worker-probe.invalid')
-    else expect(observed).toEqual([])
+describe('workflow worker egress', () => {
+  it('hands the worker no proxy configuration, credentialed or not', async () => {
+    const dispose = await installGlobalProxy(CREDENTIALED)
+    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)
+      expect(env).not.toHaveProperty('NODE_USE_ENV_PROXY')
+      expect(JSON.stringify(env)).not.toContain('s3cret')
+    } finally {
+      await dispose()
+    }
+  })
+
+  it('still carries the platform temp path the worker needs on Windows', () => {
+    expect(workerSpawnEnv('win32')).toHaveProperty('TMP')
   })
 })

+ 6 - 0
pnpm-lock.yaml

@@ -4592,6 +4592,9 @@ importers:
       '@deepseek-ai/dsh-invariants':
         specifier: workspace:^
         version: link:../../runtime-diagnostics/invariants
+      '@deepseek-ai/dsh-launch-environment':
+        specifier: workspace:^
+        version: link:../../util/launch-environment
       '@deepseek-ai/dsh-loader-smoke':
         specifier: workspace:^
         version: link:../../test-support/loader-smoke
@@ -9198,6 +9201,9 @@ importers:
       '@deepseek-ai/dsh-invariants':
         specifier: workspace:^
         version: link:../../runtime-diagnostics/invariants
+      '@deepseek-ai/dsh-launch-environment':
+        specifier: workspace:^
+        version: link:../../util/launch-environment
 
   packages/subprocess/subprocess-local:
     dependencies:

+ 51 - 9
scripts/verify-no-bare-dispatcher.spec.ts

@@ -10,23 +10,28 @@ function reasons(source: string, file = FILE): string[] {
 describe('bare dispatcher check', () => {
   it('rejects the shape that silently bypassed the proxy before this rule existed', () => {
     expect(reasons(`
+      import { Agent } from 'undici'
       const dispatcher = new Agent({ connect: { lookup } })
       const response = await fetch(url, { dispatcher })
-    `)).toEqual(['constructs an undici agent'])
+    `)).toEqual(['constructs an undici agent', 'passes an explicit \`dispatcher\`'])
   })
 
   it('rejects an explicit dispatcher option however the agent was obtained', () => {
     expect(reasons("      const response = await fetch(url, { method: 'GET', dispatcher: pooled })"))
-      .toEqual(['passes an explicit `dispatcher`'])
+      .toEqual(['passes an explicit \`dispatcher\`'])
   })
 
   it('rejects a namespaced construction', () => {
-    expect(reasons('      const agent = new undici.ProxyAgent(uri)')).toEqual(['constructs an undici agent'])
+    expect(reasons(`
+      import * as undici from 'undici'
+      const agent = new undici.ProxyAgent(uri)
+    `)).toEqual(['constructs an undici agent'])
   })
 
   it('reports the offending line number and text', () => {
-    expect(findDispatcherViolations(FILE, 'const a = 1\nconst b = new Agent({})')).toEqual([
-      { file: FILE, line: 2, what: 'constructs an undici agent', text: 'const b = new Agent({})' },
+    const source = "import { Agent } from 'undici'\nconst a = 1\nconst b = new Agent({})"
+    expect(findDispatcherViolations(FILE, source)).toEqual([
+      { file: FILE, line: 3, what: 'constructs an undici agent', text: 'const b = new Agent({})' },
     ])
   })
 
@@ -34,17 +39,54 @@ describe('bare dispatcher check', () => {
     expect(reasons('      const dispatcher = await createDispatcher(url, options)')).toEqual([])
   })
 
+  it('rejects the shorthand form a line-wise regex misses', () => {
+    expect(reasons(`
+      import { Agent } from 'undici'
+      const dispatcher = pool
+      const response = await fetch(url, { method: 'GET', dispatcher })
+    `)).toEqual(['passes an explicit \`dispatcher\`'])
+  })
+
+  it('rejects a quoted dispatcher key', () => {
+    expect(reasons("      await fetch(url, { 'dispatcher': pooled })"))
+      .toEqual(['passes an explicit \`dispatcher\`'])
+  })
+
+  it('rejects construction through an import alias', () => {
+    expect(reasons(`
+      import { Agent as CustomAgent } from 'undici'
+      const agent = new CustomAgent({})
+    `)).toEqual(['constructs an undici agent'])
+  })
+
+  it('accepts an unrelated class that happens to be named Agent', () => {
+    expect(reasons(`
+      import { Agent } from './our-own-agent.ts'
+      const agent = new Agent({})
+    `)).toEqual([])
+  })
+
+  it('accepts an exemption annotated on the line above, where a long line puts it', () => {
+    expect(reasons(`
+      import { Agent } from 'undici'
+      // proxy-exempt: the dispatcher already applied the active policy.
+      const response = await fetch(url, { method: 'GET', headers, dispatcher })
+    `)).toEqual([])
+  })
+
   it('accepts an annotated exemption', () => {
-    expect(reasons('      const agent = new Agent({}) // proxy-exempt: loopback transport for the local test server'))
-      .toEqual([])
+    expect(reasons(`
+      import { Agent } from 'undici'
+      const agent = new Agent({}) // proxy-exempt: loopback transport for the local test server
+    `)).toEqual([])
   })
 
   it('exempts the package that owns dispatcher construction', () => {
-    expect(reasons('const agent = new EnvHttpProxyAgent()', `${DISPATCHER_OWNER}src/install.ts`)).toEqual([])
+    expect(reasons("import { EnvHttpProxyAgent } from 'undici'\nconst agent = new EnvHttpProxyAgent()", `${DISPATCHER_OWNER}src/install.ts`)).toEqual([])
   })
 
   it('normalizes native separators before exempting the owning package', () => {
-    expect(reasons('const agent = new Agent({})', DISPATCHER_OWNER.replaceAll('/', '\\') + 'src\\install.ts')).toEqual([])
+    expect(reasons("import { Agent } from 'undici'\nconst agent = new Agent({})", DISPATCHER_OWNER.replaceAll('/', '\\') + 'src\\install.ts')).toEqual([])
   })
 
   it('passes on the current tree', () => {

+ 106 - 19
scripts/verify-no-bare-dispatcher.ts

@@ -8,67 +8,154 @@
  * agent silently bypassed every proxy.
  *
  * `createDispatcher()` from that package is the sanctioned way to get agent options AND the policy.
+ *
+ * Discovery is syntax-aware, as `scripts/AGENTS.md` requires: a line-wise regex misses the
+ * `{ dispatcher }` shorthand and a `new Alias(...)` whose import renamed `Agent`, and both bypass the
+ * proxy exactly as the spelled-out forms do.
  */
 
 import { globSync, readFileSync } from 'node:fs'
-import { resolve } from 'node:path'
+import { relative, resolve } from 'node:path'
+import ts from 'typescript'
 
 const root = resolve(import.meta.dirname, '..')
 
 /** The package that owns dispatcher construction; its own agents are the implementation. */
 export const DISPATCHER_OWNER = 'packages/net/http-proxy/'
 
-/** A line carrying this marker states why it is exempt and is left alone. */
+/**
+ * A comment carrying this marker states why the construction or option is exempt. It counts on the
+ * offending line or the line directly above it, because a syntax-aware match anchors on the property
+ * or `new` expression rather than the statement, and the explanation belongs above a long line.
+ */
 export const ALLOW_MARKER = 'proxy-exempt:'
 
-/** Constructing an undici agent, or naming a `dispatcher` option, outside the owning package. */
-const PATTERNS: readonly { readonly probe: RegExp; readonly what: string }[] = [
-  { probe: /\bnew\s+(?:undici\.)?(?:Agent|ProxyAgent|EnvHttpProxyAgent)\s*\(/, what: 'constructs an undici agent' },
-  { probe: /\bdispatcher\s*:/, what: 'passes an explicit `dispatcher`' },
-]
+/** Undici agent classes whose construction selects a transport, under any local name. */
+const AGENT_EXPORTS = new Set(['Agent', 'ProxyAgent', 'EnvHttpProxyAgent'])
+
+/** The module those classes must come from; a same-named class from elsewhere selects no transport. */
+const AGENT_MODULE = 'undici'
 
-/** One source line that would bypass the configured proxy. */
+/** The request option that overrides the global dispatcher, however it is written. */
+const DISPATCHER_PROPERTY = 'dispatcher'
+
+/** One source position that would bypass the configured proxy. */
 export interface DispatcherViolation {
   /** Repository-relative path, in POSIX separators. */
   readonly file: string
   /** One-based line number. */
   readonly line: number
-  /** Which rule the line broke. */
+  /** Which rule the position broke. */
   readonly what: string
-  /** The offending line, trimmed. */
+  /** The offending source text, trimmed. */
   readonly text: string
 }
 
 /**
- * Find every bare-dispatcher line in one source file.
+ * Local names bound to an undici agent class, including `import { Agent as X }` renames and a
+ * namespace import's own name so `undici.Agent` is recognised too.
+ *
+ * @param source - the parsed file.
+ * @returns agent identifiers and namespace identifiers bound in this file.
+ */
+function agentBindings(source: ts.SourceFile): { agents: Set<string>; namespaces: Set<string> } {
+  const agents = new Set<string>()
+  const namespaces = new Set<string>()
+  for (const statement of source.statements) {
+    if (!ts.isImportDeclaration(statement)) continue
+    if (!ts.isStringLiteral(statement.moduleSpecifier) || statement.moduleSpecifier.text !== AGENT_MODULE) continue
+    const bindings = statement.importClause?.namedBindings
+    if (bindings === undefined) continue
+    if (ts.isNamespaceImport(bindings)) {
+      namespaces.add(bindings.name.text)
+      continue
+    }
+    for (const element of bindings.elements) {
+      const imported = (element.propertyName ?? element.name).text
+      if (AGENT_EXPORTS.has(imported)) agents.add(element.name.text)
+    }
+  }
+  return { agents, namespaces }
+}
+
+/**
+ * Whether an expression names an undici agent class: a bound identifier, or a `<namespace>.Agent`
+ * property access.
+ *
+ * @param expression - the `new` expression's callee.
+ * @param bound - identifiers this file bound to an agent class or a namespace.
+ * @returns true when constructing it selects a transport.
+ */
+function namesAgent(expression: ts.Expression, bound: ReturnType<typeof agentBindings>): boolean {
+  if (ts.isIdentifier(expression)) return bound.agents.has(expression.text)
+  if (ts.isPropertyAccessExpression(expression) && ts.isIdentifier(expression.expression)) {
+    return bound.namespaces.has(expression.expression.text) && AGENT_EXPORTS.has(expression.name.text)
+  }
+  return false
+}
+
+/**
+ * Whether an object literal member supplies `dispatcher`, covering `dispatcher: x`, the `{ dispatcher }`
+ * shorthand, and `{ 'dispatcher': x }`.
+ *
+ * @param member - one object-literal element.
+ * @returns true when the member names the dispatcher option.
+ */
+function suppliesDispatcher(member: ts.ObjectLiteralElementLike): boolean {
+  if (ts.isShorthandPropertyAssignment(member)) return member.name.text === DISPATCHER_PROPERTY
+  if (!ts.isPropertyAssignment(member)) return false
+  const name = member.name
+  if (ts.isIdentifier(name) || ts.isStringLiteral(name)) return name.text === DISPATCHER_PROPERTY
+  return false
+}
+
+/**
+ * Find every bare-dispatcher position in one source file.
  *
  * @param file - repository-relative path, used to exempt the owning package and to report location.
  * @param sourceText - the file's contents.
- * @returns one violation per offending line, in file order.
+ * @returns one violation per offending position, in source order.
  */
 export function findDispatcherViolations(file: string, sourceText: string): DispatcherViolation[] {
   const posix = file.replaceAll('\\', '/')
   if (posix.startsWith(DISPATCHER_OWNER)) return []
+  const source = ts.createSourceFile(posix, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
+  const bound = agentBindings(source)
+  const lines = sourceText.split('\n')
   const violations: DispatcherViolation[] = []
-  sourceText.split('\n').forEach((text, index) => {
-    if (text.includes(ALLOW_MARKER)) return
-    for (const { probe, what } of PATTERNS) {
-      if (probe.test(text)) violations.push({ file: posix, line: index + 1, what, text: text.trim() })
+
+  const record = (node: ts.Node, what: string): void => {
+    const line = source.getLineAndCharacterOfPosition(node.getStart(source)).line
+    const exempt = [lines[line], lines[line - 1]].some(text => text?.includes(ALLOW_MARKER) === true)
+    if (exempt) return
+    violations.push({ file: posix, line: line + 1, what, text: (lines[line] ?? '').trim() })
+  }
+
+  const visit = (node: ts.Node): void => {
+    if (ts.isNewExpression(node) && namesAgent(node.expression, bound)) {
+      record(node, 'constructs an undici agent')
+    }
+    if (ts.isObjectLiteralExpression(node) && node.properties.some(suppliesDispatcher)) {
+      record(node.properties.find(suppliesDispatcher) as ts.Node, 'passes an explicit `dispatcher`')
     }
-  })
+    ts.forEachChild(node, visit)
+  }
+  ts.forEachChild(source, visit)
   return violations
 }
 
 /**
  * Scan every package and app source file in the repository.
  *
- * @returns every violation found, grouped by the order the files were scanned.
+ * @returns every violation found, in scan order.
+ * @throws when the corpus is empty, which would make the gate pass by scanning nothing.
  */
 export function scanRepository(): DispatcherViolation[] {
   const files = [
     ...globSync('packages/*/*/src/**/*.ts', { cwd: root }),
     ...globSync('apps/*/src/**/*.ts', { cwd: root }),
   ]
+  if (files.length === 0) throw new Error('verify-no-bare-dispatcher: scanned an empty corpus; the globs no longer match.')
   return files.flatMap(file => findDispatcherViolations(file, readFileSync(resolve(root, file), 'utf8')))
 }
 
@@ -80,7 +167,7 @@ function main(): void {
   }
   console.error('verify-no-bare-dispatcher: a dispatcher built outside @deepseek-ai/dsh-http-proxy bypasses the configured proxy.\n')
   for (const violation of violations) {
-    console.error(`  ${violation.file}:${String(violation.line)} ${violation.what}`)
+    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')