Files
deepseek-harness/packages/util/http-proxy/src/install.ts
T
Yichen Jiang 93bba8ef67 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.
2026-09-02 10:27:36 +08:00

316 lines
15 KiB
TypeScript

/**
* Proxy installation: the transport half of this package. It owns undici's global dispatcher and the
* process-wide record of which policy is active.
*
* `undici` is imported dynamically so the pure {@link ProxyPolicy} half stays loadable where no Node
* transport exists, matching how `dsh-web-fetch-http` defers its own transport import.
* @module @deepseek-ai/dsh-http-proxy/install
*/
import type { Dispatcher, Pool } from 'undici'
import {
isSupportedProxyUrl,
POLICY_ENV_NAMES,
PROXY_ENV_NAMES,
proxyForUrl,
resolveProxyPolicy,
type EnvLookup,
type ProxyPolicy,
} from './policy.ts'
/** The active policy, or `undefined` until one is installed. Process-wide, like the dispatcher it tracks. */
let active: ProxyPolicy | undefined
/**
* The proxy environment as the user exported it, or `undefined` when no policy is installed.
*
* Owned by the OUTERMOST install: one layered over the launcher's would otherwise record the outer
* policy's published values as if the user had written them, and
* hand every child a normalization the user never asked for.
*
* {@link proxyEnvironmentForChild} keeps a value the user set rather than the one this process resolved from
* it, so a SOCKS proxy `curl` can use is not replaced by an HTTP proxy named for another scheme.
*/
let inheritedProxyEnv: Readonly<Record<string, string | undefined>> | undefined
/** The dispatcher installed with {@link active}, so a route can hand back the one already routing. */
let installed: Dispatcher | undefined
/**
* How this process must send one request.
*
* A caller that branches on the answer needs the transport that answer assumed, or an install or
* disposal landing between the two would send the request somewhere the branch did not clear. The
* proxied arm therefore carries the dispatcher already routing by this policy: it is process-wide
* and long-lived, so a caller uses it and never closes it. Disposal closes that dispatcher rather
* than destroying it, so a request already dispatched when a policy is unmounted still finishes.
*/
export type ProxyRoute =
| { readonly proxied: true; readonly proxy: string; readonly dispatcher: Dispatcher }
| { readonly proxied: false }
/** A route that sends nothing through a proxy, shared because it carries no per-request state. */
const DIRECT_ROUTE: ProxyRoute = { proxied: false }
/**
* Decide how to send one request, and hand back the transport that decision assumed.
*
* @param url - the request URL.
* @returns the proxied route with its proxy URL and dispatcher, or the direct route.
*/
export function proxyRouteFor(url: URL): ProxyRoute {
const policy = active
const dispatcher = installed
if (policy === undefined || dispatcher === undefined) return DIRECT_ROUTE
const proxy = proxyForUrl(policy, url)
return proxy === undefined ? DIRECT_ROUTE : { proxied: true, proxy, dispatcher }
}
/**
* Publish a policy through the proxy environment variables, which is how the consumers that read an
* environment rather than a policy object — `node:http`'s `proxyEnv` and every spawned child — see
* the one resolved answer, including the `ALL_PROXY` fallback and the merged loopback bypass that
* neither derives on its own. The global dispatcher does not read these; it routes by the policy.
*
* @param policy - the policy to publish.
* @returns a function restoring every name this call changed.
*/
function applyPolicyEnv(policy: ProxyPolicy): () => void {
const previousInherited = inheritedProxyEnv
inheritedProxyEnv = previousInherited ?? snapshotProxyEnv()
const published: Record<string, string | undefined> = {}
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) published[name] = value
}
const restore = writeProxyEnv(published)
return () => {
restore()
inheritedProxyEnv = previousInherited
}
}
/**
* Read every proxy name this package publishes, as `process.env` holds it now.
*
* @returns one entry per name in {@link POLICY_ENV_NAMES}; `undefined` marks an absent name.
*/
function snapshotProxyEnv(): Record<string, string | undefined> {
const snapshot: Record<string, string | undefined> = {}
for (const names of Object.values(POLICY_ENV_NAMES)) {
for (const name of names) snapshot[name] = process.env[name]
}
return snapshot
}
/**
* Set every proxy name to the value `values` holds for it, removing a name whose value is `undefined`.
*
* @param values - the value each name in {@link POLICY_ENV_NAMES} should hold.
* @returns a function restoring every name to what it held before this call.
*/
function writeProxyEnv(values: Readonly<Record<string, string | undefined>>): () => 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 = snapshotProxyEnv()
for (const name of Object.keys(previous)) {
const value = values[name]
if (value === undefined) Reflect.deleteProperty(process.env, name)
else process.env[name] = value
}
return () => {
for (const [name, value] of Object.entries(previous)) {
if (value === undefined) Reflect.deleteProperty(process.env, name)
else process.env[name] = value
}
}
}
/**
* Build the global dispatcher for one policy.
*
* Routing runs through {@link proxyForUrl} per origin, so `fetch` and every caller that asks where a
* URL goes read the same answer from the same matcher. undici's `EnvHttpProxyAgent` cannot express
* this policy: with no `HTTPS_PROXY` present it reuses the HTTP proxy for `https:`, which would
* tunnel a scheme this package deliberately keeps direct after refusing the SOCKS or malformed URL
* the user named for it — the route and the diagnostic would then disagree.
*
* @param policy - the policy to route by; it must proxy at least one scheme.
* @returns the dispatcher to install, owning every per-origin agent its factory created.
*/
async function createPolicyDispatcher(policy: ProxyPolicy): Promise<Dispatcher> {
const { Agent, Pool, ProxyAgent } = await import('undici')
return new Agent({
factory(origin, options) {
// undici declares this parameter as `Object`, discarding the pool options it actually passes.
const passed = options as Pool.Options
const proxy = proxyForUrl(policy, new URL(origin.toString()))
if (proxy !== undefined) return new ProxyAgent({ ...passed, uri: proxy })
// What undici's own default factory builds for these options, which `factory` replaces
// wholesale. It reaches for a bare `Client` only at `connections: 1`, an option this
// dispatcher never carries: it is constructed with undici's defaults.
return new Pool(origin, passed)
},
})
}
/**
* Route this process's outbound HTTP through `policy`.
*
* Installing replaces undici's global dispatcher, which is what Node's built-in `fetch` resolves, so
* every caller that issues a plain `fetch()` is covered without knowing this package exists. A policy
* that proxies nothing installs a direct dispatcher and leaves the environment untouched.
*
* A worker thread has its own `globalThis` and so its own dispatcher; installing here does not
* reach it. No worker installs one today: both this repository ships — the workflow engine and the
* code runtime — evaluate model-authored scripts, which must not receive a proxy URL that may carry
* credentials. A worker that needs the policy has to be handed one explicitly and install it itself.
*
* @param policy - the resolved policy to install.
* @returns a disposer restoring the previous dispatcher, policy, and environment, then closing the agent.
*/
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 previousInstalled = installed
// The install underneath published its normalized policy into `process.env`, which is what a
// spawned child copies. With no policy active there is no normalization to stand behind, so the
// user's own values return for the window and the outer install's come back when it ends. An
// install underneath that proxied nothing published nothing, and there is nothing to put back.
const restoreEnv = inheritedProxyEnv === undefined ? undefined : writeProxyEnv(inheritedProxyEnv)
const undici = await import('undici')
const previous = undici.getGlobalDispatcher()
const direct = new undici.Agent()
undici.setGlobalDispatcher(direct)
active = policy
installed = undefined
return async () => {
undici.setGlobalDispatcher(previous)
active = previousPolicy
installed = previousInstalled
restoreEnv?.()
await direct.close()
}
}
const restoreEnv = applyPolicyEnv(policy)
const { getGlobalDispatcher, setGlobalDispatcher } = await import('undici')
const previousDispatcher = getGlobalDispatcher()
const previousInstalled = installed
const agent = await createPolicyDispatcher(policy)
setGlobalDispatcher(agent)
active = policy
installed = agent
return async () => {
setGlobalDispatcher(previousDispatcher)
active = previousPolicy
installed = previousInstalled
restoreEnv()
await agent.close()
}
}
/**
* The proxy environment a spawned child needs.
*
* A child inherits the parent environment, which this process rewrote to its own resolved policy.
* Handing that normalization straight through would replace values the user set for other tools, so
* each proxy name the user exported is restored to what they wrote: a SOCKS proxy `curl` uses is
* not swapped for the HTTP one this package fell back to for that scheme.
*
* A scheme the user named in neither casing carries the resolved value instead of being removed.
* Without that the child's routing silently diverges from its parent's: `NODE_USE_ENV_PROXY` does
* not read `ALL_PROXY`, so a child of a parent that resolved its proxy from that name would connect
* directly while the parent proxies.
*
* The bypass list is always the resolved one. It only ever adds the loopback entries to what
* the user wrote, so nothing is lost, and the child stops sending its own localhost traffic to a
* proxy that cannot route it.
*
* The flag reaches only Node 22.21+ and 24+; an older runtime keeps that child direct. Such a child
* also matches bypass entries with Node's own `NO_PROXY` rules, which differ from this package's in
* 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.
*
* @returns names to apply to the child environment, where `undefined` means remove, or an empty
* object when no proxy is active.
*/
export function proxyEnvironmentForChild(): Readonly<Record<string, string | undefined>> {
const policy = active
const inherited = inheritedProxyEnv
if (policy === undefined || policy.source === 'none' || inherited === undefined) return {}
const overlay: Record<string, string | undefined> = { NODE_USE_ENV_PROXY: '1' }
for (const [field, names] of Object.entries(POLICY_ENV_NAMES)) {
const resolved = policy[field as keyof typeof POLICY_ENV_NAMES]
// Naming a scheme in either casing claims that scheme: the child then gets exactly what the
// user wrote, in the casing they wrote it, rather than a value derived for this process.
const named = field !== 'noProxy' && names.some(name => inherited[name] !== undefined)
for (const name of names) overlay[name] = named ? inherited[name] : resolved
}
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
}
/**
* Resolve this process's proxy policy from `env` and install it.
*
* Resolution, reporting, and installation are one operation because no caller needs them apart: the
* launcher does all three in sequence before the first plugin mounts, and a policy resolved but not
* installed routes nothing.
*
* A value the environment supplies but this package cannot use is reported and skipped rather than
* thrown: the variable may have been exported for another tool, and a proxy the harness cannot use
* must not stop the agent from starting.
*
* @param env - the launch environment, whose own layering already prefers real variables over `.env` files.
* @param report - receives one message per rejected value, in the order the values were considered.
* @returns a disposer restoring the previous dispatcher, policy, and environment.
*/
export async function installProxyFromEnvironment(
env: EnvLookup,
report: (message: string) => void,
): Promise<() => Promise<void>> {
const { policy, diagnostics } = resolveProxyPolicy(env)
for (const diagnostic of diagnostics) report(diagnostic.message)
return await installGlobalProxy(policy)
}
/**
* The environment overlay that removes every proxy name from a spawned child.
*
* A harness that replays a recorded session must reach its own fixture server, not the proxy a
* developer or a CI runner exported; `undefined` is how a spawn removes a name it inherits.
*
* @returns one entry per proxy name, each `undefined`.
*/
export function clearedProxyEnv(): Record<string, undefined> {
return Object.fromEntries(PROXY_ENV_NAMES.map(name => [name, undefined]))
}