Browse Source

fix(http-proxy): withhold NODE_USE_ENV_PROXY when the child receives a refused proxy value

`proxyEnvironmentForChild()` hands a child the proxy values the user
exported, including one this package refused — a SOCKS URL kept because
`curl` reads it — and sets `NODE_USE_ENV_PROXY=1` so a child Node honors
them. Node parses `HTTP_PROXY` and `HTTPS_PROXY` under that flag before
running the program and exits on any scheme other than `http:` or
`https:`. So a user with a usable `HTTP_PROXY` and `HTTPS_PROXY=socks4://…`
lost every Node child — stdio MCP servers, subagent CLIs, `npm` in the
bash tool — before its first line, while this process had reported only
that the scheme stayed direct. Measured on Node 24.17: `socks4://`,
`ftp://`, and a malformed value all exit 1; `socks5://` is accepted there
and only there.

The flag is now withheld whenever a value under the names Node parses is
one `isSupportedProxyUrl` refuses. Such a child connects directly, which
is what this process already said about that scheme, and `curl` still
reads the value it was kept for. Node does not read `ALL_PROXY`, so a
refused value there alone changes nothing.

The socks5 case in `install.spec.ts` now asserts the flag absent; the
ALL_PROXY fill case asserts it present; a new case spawns a real child
Node under the overlay for each refused shape and asserts it starts.
Yichen Jiang 3 weeks ago
parent
commit
93bba8ef67

+ 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: ff81530764006419afdfd6ce9e75ce9eebe62f94
-2026-08-27-outbound-proxy-policy.zh.md: f289013ec7606c4d2c5cc4487b429000598b403b
+2026-08-27-outbound-proxy-policy.md: 67927a9d1404e0b14c6e420cc5bea462be5c87ad
+2026-08-27-outbound-proxy-policy.zh.md: cf0b203a061522450a2a1b0c337a060b0c387684

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

@@ -44,6 +44,8 @@ The URL-level policy is untouched: `http(s)` only, no embedded credentials, the
 
 **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.
 
+The child keeps the user's own values, and that is what once broke it. Node parses `HTTP_PROXY` and `HTTPS_PROXY` under `NODE_USE_ENV_PROXY` before running the program and exits on any scheme other than `http:` or `https:`; a `socks4://` kept for `curl` therefore ended every Node child — MCP servers, subagent CLIs, `npm` — before its first line, while this process had reported only that the scheme stayed direct. Measured on Node 24.17: `socks4://`, `ftp://`, and a malformed value all exit 1; `socks5://` happens to be accepted there. The flag is now withheld whenever a value the child receives is one this package refused, so such a child connects directly and `curl` still reads the value it was kept for. Handing the child the resolved value instead would have kept Node proxied at the price of silently rewriting what the user set for another tool.
+
 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. E2B is handed `route.proxy` from `proxyRouteFor`, the same call `web-fetch-http` makes. Telemetry is deliberately left direct, and that exclusion is the more interesting half.

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

@@ -44,6 +44,8 @@ URL 层策略未受影响:仅 `http(s)`、禁止内嵌凭据、长度上限与
 
 **派生的子进程通过环境获得策略;执行模型代码的 worker 什么也不获得。** `proxyEnvironmentForChild()` 并入 `scrubbedParentEnv()`——每个 spawner 本就共享的那一个函数。workflow worker **不**接收它:它执行的是模型编写的脚本体,而代理 URL 可能携带 `user:password`。这与 code runtime 保持的隔离相同,也是 `docs/defensive-patterns.md` 的要求,因此 workflow 自身的请求直连。
 
+子进程拿到的是用户自己的值,而这恰恰曾把它弄坏。Node 在 `NODE_USE_ENV_PROXY` 下会在运行程序之前先解析 `HTTP_PROXY` 与 `HTTPS_PROXY`,遇到 `http:`/`https:` 之外的协议直接退出;于是一个为 `curl` 保留的 `socks4://` 会让每个 Node 子进程——MCP server、subagent CLI、`npm`——在第一行之前就终结,而本进程此前只报告过该协议保持直连。在 Node 24.17 上实测:`socks4://`、`ftp://` 与畸形值均以 1 退出;`socks5://` 恰好在该版本被接受。现在只要子进程收到的某个值是本包拒绝过的,就扣下该标志,这样的子进程直连,`curl` 仍读到为它保留的值。若改为把解析后的值交给子进程,Node 固然能继续走代理,代价却是悄悄改写用户为另一工具设置的值。
+
 这接受了一处已记录的接缝。此类上下文按 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。两者都实测为直连。E2B 接收 `proxyRouteFor` 给出的 `route.proxy`,与 `web-fetch-http` 调的是同一个函数。遥测则被有意保留为直连,而这个排除项才是更值得说的一半。

+ 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: da887195d3c76257f01b824f64cf41372b3717ed
-network-proxy.zh.md: 97d637fc3efcbc23ceacb5ab95e9191bdadec5a7
+network-proxy.md: d53d48688490c741f0ed7f950c5ff39db02dc1e9
+network-proxy.zh.md: 1eee1e67abb700e15f3b2cea1b22bf036c694302

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

@@ -57,7 +57,7 @@ export NODE_EXTRA_CA_CERTS=/path/to/corporate-ca.pem
 
 Node reads that variable only at process start, so export it before running `dsh`.
 
-**Tools DSH runs for you follow the same proxy.** Commands in the bash tool, `git`, `gh`, and MCP servers started as child processes all inherit these variables. A child that is itself a Node program honors them only on Node 22.21 or later; an older Node connects directly.
+**Tools DSH runs for you follow the same proxy.** Commands in the bash tool, `git`, `gh`, and MCP servers started as child processes all inherit these variables. A child that is itself a Node program honors them only on Node 22.21 or later; an older Node connects directly. If one of your proxy variables holds a value DSH rejected — a SOCKS URL, say — Node-based tools also connect directly rather than fail to start, while `curl` and `git` still read that value.
 
 **A password in the proxy URL reaches those tools too.** `HTTPS_PROXY=http://alice:s3cret@proxy.example:8080` is a normal environment variable, so every command DSH runs — including the ones the model writes — can read it, and a command that prints its environment puts the password in output that is kept. This is how the variable already behaves for everything else in your shell. If that matters, give the proxy a credential-free entry point, or authenticate it some other way than in the URL.
 

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

@@ -57,7 +57,7 @@ export NODE_EXTRA_CA_CERTS=/path/to/corporate-ca.pem
 
 Node 只在进程启动时读取该变量,所以要在运行 `dsh` 之前导出。
 
-**DSH 替你运行的工具遵循同一个代理。** bash 工具里的命令、`git`、`gh`,以及作为子进程启动的 MCP 服务器都会继承这些变量。子进程若本身是 Node 程序,则需 Node 22.21 或更高版本才会遵循;更旧的 Node 会直连。
+**DSH 替你运行的工具遵循同一个代理。** bash 工具里的命令、`git`、`gh`,以及作为子进程启动的 MCP 服务器都会继承这些变量。子进程若本身是 Node 程序,则需 Node 22.21 或更高版本才会遵循;更旧的 Node 会直连。如果你的某个代理变量是 DSH 拒绝的值——比如 SOCKS URL——基于 Node 的工具同样直连而不是起不来,`curl` 与 `git` 则仍会读取那个值。
 
 **代理 URL 里的密码同样会到达这些工具。** `HTTPS_PROXY=http://alice:s3cret@proxy.example:8080` 就是一个普通环境变量,因此 DSH 运行的每一条命令——包括模型编写的那些——都能读到它,而打印环境的命令会把密码写进被保留的输出。这与该变量在你 shell 里对其他一切程序的行为一致。若这一点重要,请为代理提供一个无需凭据的入口,或改用 URL 之外的方式认证。
 

+ 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: 1a2098a7ba6e9a43df54e06079dd2c37899ed3a6
-README.zh.md: 6589fce3c7a618eb17f3507437ab45550a760a8a
+README.md: 023d8a2bad23647072fd249b862f4fe3ca865139
+README.zh.md: 6fcdf6b97dc6eb4f413b704d522b665c83ea70ca

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

@@ -66,7 +66,7 @@ A proxy value the package cannot use — a SOCKS or PAC URL, an unparseable stri
 
 **One resolution, one matcher.** `proxyForUrl()` and the installed dispatcher must never disagree about a URL, or `dsh-web-fetch-http` would pin a connection the dispatcher meant to tunnel. The dispatcher is therefore an `Agent` whose per-origin `factory` calls `proxyForUrl()` itself, so there is no second parser to drift from the first. undici's `EnvHttpProxyAgent` cannot serve here: with no `HTTPS_PROXY` present it reuses the HTTP proxy for `https:`, which would tunnel a scheme this package keeps direct after refusing the URL the user named for it.
 
-**A child inherits the user's own values, and the resolved policy for what they left unset.** A scheme the user named in either casing reaches a child exactly as they wrote it, so a SOCKS proxy `curl` uses is never replaced by an HTTP one named for another scheme. A scheme they named in neither casing carries the resolved value instead, because otherwise the child's routing diverges from its parent's: Node's `NODE_USE_ENV_PROXY` does not read `ALL_PROXY`. The bypass list is always the resolved one — it only ever adds the loopback entries, so nothing the user wrote is lost. The cost of one routing answer for parent and child alike is that `curl` also sees the `https:` proxy this package derives from the HTTP one.
+**A child inherits the user's own values, and the resolved policy for what they left unset.** A scheme the user named in either casing reaches a child exactly as they wrote it, so a SOCKS proxy `curl` uses is never replaced by an HTTP one named for another scheme. A scheme they named in neither casing carries the resolved value instead, because otherwise the child's routing diverges from its parent's: Node's `NODE_USE_ENV_PROXY` does not read `ALL_PROXY`. The bypass list is always the resolved one — it only ever adds the loopback entries, so nothing the user wrote is lost. The cost of one routing answer for parent and child alike is that `curl` also sees the `https:` proxy this package derives from the HTTP one. One exception protects the child itself: when a value it receives is one this package refused — a SOCKS URL kept for `curl` — the `NODE_USE_ENV_PROXY` flag is withheld, because Node parses `HTTP_PROXY` and `HTTPS_PROXY` under that flag before running the program and exits on such a value. A child Node then connects directly, as this process already reported for that scheme, instead of failing to start.
 
 ### Source map
 
@@ -108,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 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 spawned child honors the policy only on a new enough runtime, and only when every value it inherits is one Node accepts** — 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 user whose environment also names a SOCKS or otherwise refused proxy leaves every child Node direct: the flag is withheld so the child can start at all. 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.
 - **Telemetry is direct by design** — the OTLP exporter posts through `node:http`, which no global dispatcher reaches. Routing it would need either an `http.Agent` whose `proxyEnv` option post-dates the lowest supported Node, or the SDK's `fetch` transport, which has no compression while the shipped profile enables gzip. Telemetry is the one channel whose loss costs the user nothing, so it stays where it was; `DSH_TELEMETRY_MODE=DISABLED` turns it off.
 - **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`.

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

@@ -66,7 +66,7 @@ loopback 始终被绕过——`localhost`、整个 `127.0.0.0/8` 段、`::1`、`
 
 **一次解析,一个匹配器。** `proxyForUrl()` 与已安装的 dispatcher 绝不能对同一个 URL 给出不同答案,否则 `dsh-web-fetch-http` 会把 dispatcher 本打算隧道转发的连接固定到某个地址上。因此该 dispatcher 是一个 `Agent`,其按 origin 调用的 `factory` 自身调用 `proxyForUrl()`,不存在可能与第一个解析器产生漂移的第二个解析器。undici 的 `EnvHttpProxyAgent` 在此无法胜任:没有 `HTTPS_PROXY` 时它让 `https:` 复用 HTTP 代理,于是本包在拒绝用户为该 scheme 指定的 URL 后本应保持直连的 scheme 仍会被隧道转发。
 
-**子进程继承用户自己的值,以及用户未设置部分的解析结果。** 用户以任一大小写指定过的 scheme,会以他们书写的形式原样传给子进程,因此用户为 `curl` 设置的 SOCKS 代理绝不会被替换成为其他 scheme 指定的 HTTP 代理。两种大小写都未指定的 scheme 则携带解析值,否则子进程的路由会与父进程分歧:Node 的 `NODE_USE_ENV_PROXY` 不读 `ALL_PROXY`。绕过列表始终采用解析结果——它只会追加 loopback 条目,用户写下的内容不会丢失。让父子进程只有一个路由答案的代价是:`curl` 也会看到本包由 HTTP 代理推导出的 `https:` 代理。
+**子进程继承用户自己的值,以及用户未设置部分的解析结果。** 用户以任一大小写指定过的 scheme,会以他们书写的形式原样传给子进程,因此用户为 `curl` 设置的 SOCKS 代理绝不会被替换成为其他 scheme 指定的 HTTP 代理。两种大小写都未指定的 scheme 则携带解析值,否则子进程的路由会与父进程分歧:Node 的 `NODE_USE_ENV_PROXY` 不读 `ALL_PROXY`。绕过列表始终采用解析结果——它只会追加 loopback 条目,用户写下的内容不会丢失。让父子进程只有一个路由答案的代价是:`curl` 也会看到本包由 HTTP 代理推导出的 `https:` 代理。有一处例外是为了保护子进程自身:当子进程收到的某个值是本包拒绝过的——比如为 `curl` 保留的 SOCKS URL——就不再设置 `NODE_USE_ENV_PROXY`,因为 Node 在该标志下会在运行程序之前先解析 `HTTP_PROXY` 与 `HTTPS_PROXY`,遇到这类值直接退出。此时子 Node 直连(本进程已为该协议如此报告),而不是根本起不来。
 
 ### 源码地图
 
@@ -108,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_USE_ENV_PROXY` 读取已发布的环境(22.21+、24+),而 engines 范围允许 22.19 与 22.20,在这两个版本上这样的子进程保持直连。子进程还会按 Node 自己的 `NO_PROXY` 规则匹配绕过条目,其分隔符与 IPv4 区间处理与本包不同。本进程内不依赖任何 Node 版本:每一次进程内请求都会落到全局 dispatcher。
+- **派生的子进程只在足够新的运行时上遵循策略,且仅当它继承的每个值都是 Node 接受的**——它通过 Node 的 `NODE_USE_ENV_PROXY` 读取已发布的环境(22.21+、24+),而 engines 范围允许 22.19 与 22.20,在这两个版本上这样的子进程保持直连。若用户环境里还有 SOCKS 或其他被拒的代理,所有子 Node 都保持直连:标志被扣下,子进程才起得来。子进程还会按 Node 自己的 `NO_PROXY` 规则匹配绕过条目,其分隔符与 IPv4 区间处理与本包不同。本进程内不依赖任何 Node 版本:每一次进程内请求都会落到全局 dispatcher。
 - **遥测按设计直连**——OTLP 导出器通过 `node:http` 投递,全局 dispatcher 触及不到。要让它走代理,要么依赖 `http.Agent` 的 `proxyEnv`,而该选项晚于本项目支持的最低 Node 版本;要么改用 SDK 的 `fetch` 传输,但它没有压缩能力,而随附配置启用了 gzip。遥测是唯一一条丢失了对用户毫无代价的通道,因此维持原状;`DSH_TELEMETRY_MODE=DISABLED` 可关闭它。
 - **执行模型编写代码的 worker 完全不获得代理**——`code-runtime` worker 与 `workflow` worker 都不接收代理配置,它们自身的请求直连。代理 URL 可能携带 `user:password`,而两者运行的都是模型写的脚本。
 - **防回归门禁只看源码,看不到依赖内部**——`verify-no-bare-dispatcher` 解析 `packages/*/*/src` 与 `apps/*/src`;测试、脚本以及第三方 SDK 的内部都在其之外。这正是每个出网点还各配一份 `egress.spec.ts` 的原因。

+ 11 - 0
packages/util/http-proxy/src/install.ts

@@ -9,6 +9,7 @@
 
 import type { Dispatcher, Pool } from 'undici'
 import {
+  isSupportedProxyUrl,
   POLICY_ENV_NAMES,
   PROXY_ENV_NAMES,
   proxyForUrl,
@@ -246,6 +247,12 @@ async function installGlobalProxy(policy: ProxyPolicy): Promise<() => Promise<vo
  * their separators and IPv4-range support. Non-Node children (curl, git, pnpm) ignore the flag and
  * read the variables themselves.
  *
+ * The flag is withheld when a proxy value the child receives is one this package refused. Node
+ * parses `HTTP_PROXY` and `HTTPS_PROXY` under that flag before running the program, and exits on a
+ * scheme other than `http:` or `https:` — so a SOCKS value kept for `curl` would stop every Node
+ * child from starting. Without the flag such a child connects directly, as this process already
+ * reported for that scheme, and `curl` still reads the value it was kept for.
+ *
  * 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.
  *
@@ -264,6 +271,10 @@ export function proxyEnvironmentForChild(): Readonly<Record<string, string | und
     const named = field !== 'noProxy' && names.some(name => inherited[name] !== undefined)
     for (const name of names) overlay[name] = named ? inherited[name] : resolved
   }
+  const parsedByNode = [...POLICY_ENV_NAMES.httpProxy, ...POLICY_ENV_NAMES.httpsProxy]
+  if (parsedByNode.some(name => overlay[name] !== undefined && !isSupportedProxyUrl(overlay[name]))) {
+    delete overlay.NODE_USE_ENV_PROXY
+  }
   return overlay
 }
 

+ 12 - 0
packages/util/http-proxy/src/policy.ts

@@ -170,6 +170,18 @@ function acceptProxyUrl(
   return { kind: 'accepted', value: candidate.value }
 }
 
+/**
+ * Whether a proxy URL is one this package accepts: parseable, with an `http:` or `https:` scheme.
+ * The same test {@link acceptProxyUrl} applies, without its diagnostics.
+ *
+ * @param value - the proxy URL as an environment variable holds it.
+ * @returns true when the URL would be accepted.
+ */
+export function isSupportedProxyUrl(value: string): boolean {
+  const parsed = URL.parse(value)
+  return parsed !== null && SUPPORTED_PROTOCOLS.has(parsed.protocol)
+}
+
 /**
  * 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.

+ 32 - 1
packages/util/http-proxy/tests/install.spec.ts

@@ -1,3 +1,4 @@
+import { spawnSync } from 'node:child_process'
 import { createServer, type Server } from 'node:http'
 import type { AddressInfo } from 'node:net'
 import { afterEach, beforeAll, afterAll, describe, expect, it } from 'vitest'
@@ -278,7 +279,9 @@ describe('proxyEnvironmentForChild', () => {
         // 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')
+        // The SOCKS value kept for `curl` is one Node would refuse at startup, so the flag that makes
+        // Node read it is withheld and a child Node connects directly rather than failing to start.
+        expect(child.NODE_USE_ENV_PROXY).toBeUndefined()
       } finally {
         await dispose()
       }
@@ -297,12 +300,40 @@ describe('proxyEnvironmentForChild', () => {
         expect(child.http_proxy).toBe(proxyUrl)
         expect(child.HTTPS_PROXY).toBe(proxyUrl)
         expect(child.https_proxy).toBe(proxyUrl)
+        expect(child.NODE_USE_ENV_PROXY).toBe('1')
       } finally {
         await dispose()
       }
     })
   })
 
+  it.each(['socks4://127.0.0.1:1080', 'ftp://p:1', 'not a url'])(
+    'withholds NODE_USE_ENV_PROXY when the child receives %s, so a child Node still starts',
+    async (refused) => {
+      await withCleanProxyEnv(async () => {
+        process.env.HTTP_PROXY = proxyUrl
+        process.env.HTTPS_PROXY = refused
+        const { dispose } = await install(env({ HTTP_PROXY: proxyUrl, HTTPS_PROXY: refused }))
+        try {
+          const child = proxyEnvironmentForChild()
+          // The value is still handed over — `curl` may read it — but Node, which parses these two
+          // names before running anything under the flag, must not be told to.
+          expect(child.HTTPS_PROXY).toBe(refused)
+          expect(child.HTTP_PROXY).toBe(proxyUrl)
+          expect(child).not.toHaveProperty('NODE_USE_ENV_PROXY')
+          // Proved on a real child rather than inferred: the same environment with the flag present
+          // exits before the program runs, on every Node this repository supports.
+          const childEnv: Record<string, string> = { PATH: process.env.PATH ?? '' }
+          for (const [name, value] of Object.entries(child)) if (value !== undefined) childEnv[name] = value
+          const run = spawnSync(process.execPath, ['-e', 'process.stdout.write("started")'], { env: childEnv, encoding: 'utf8' })
+          expect({ status: run.status, stdout: run.stdout }).toEqual({ status: 0, stdout: 'started' })
+        } finally {
+          await dispose()
+        }
+      })
+    },
+  )
+
   it('keeps the outermost install\'s record of what the user exported across a nested one', async () => {
     await withCleanProxyEnv(async () => {
       // The user exported one name, in one casing.