`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.
344 lines
15 KiB
TypeScript
344 lines
15 KiB
TypeScript
/**
|
|
* Proxy policy resolution: the pure, transport-free half of this package. It turns the launch
|
|
* environment into one {@link ProxyPolicy}, and answers which proxy
|
|
* (if any) a given URL goes through.
|
|
*
|
|
* Nothing here imports `undici`, so the module stays loadable in the browser-worker runtime that
|
|
* evaluates `dsh-web-fetch-http` without a Node transport.
|
|
* @module @deepseek-ai/dsh-http-proxy/policy
|
|
*/
|
|
|
|
/**
|
|
* The one thing resolution needs from an environment: a name in, the winning value out. The
|
|
* launcher's snapshot satisfies this structurally and is passed unchanged, so this module names no
|
|
* package to describe its input — and a test builds one from an object literal.
|
|
*/
|
|
export interface EnvLookup {
|
|
/**
|
|
* Resolve one variable name.
|
|
* @param name - the variable name.
|
|
* @returns the winning entry, or `undefined` when nothing supplies it.
|
|
*/
|
|
get(name: string): { readonly value: string } | undefined
|
|
}
|
|
|
|
/**
|
|
* Loopback entries merged into every policy's `noProxy`. A proxy that also serves the harness's own
|
|
* loopback traffic turns the Web UI, the Connection transport, and every local test server into a
|
|
* routing loop, so the bypass is not optional.
|
|
*
|
|
* `::1` and `[::1]` are both listed because the resolved string is also handed to undici, whose
|
|
* matcher reads a bare `::1` as host `:` port `1` and therefore never bypasses it.
|
|
*/
|
|
export const LOOPBACK_NO_PROXY: readonly string[] = ['localhost', '127.0.0.1', '::1', '[::1]']
|
|
|
|
/**
|
|
* The environment names each policy field owns, lowercase first — undici reads the lowercase name
|
|
* first, so both casings are always written or cleared together.
|
|
*/
|
|
export const POLICY_ENV_NAMES = {
|
|
httpProxy: ['http_proxy', 'HTTP_PROXY'],
|
|
httpsProxy: ['https_proxy', 'HTTPS_PROXY'],
|
|
noProxy: ['no_proxy', 'NO_PROXY'],
|
|
} as const
|
|
|
|
/**
|
|
* Every environment name that carries proxy configuration, including the `ALL_PROXY` fallback this
|
|
* package resolves but never writes back. A caller that must isolate a child from the machine's
|
|
* network policy clears exactly these.
|
|
*/
|
|
export const PROXY_ENV_NAMES: readonly string[] = [
|
|
...Object.values(POLICY_ENV_NAMES).flat(),
|
|
'all_proxy',
|
|
'ALL_PROXY',
|
|
]
|
|
|
|
/** Proxy URL schemes this package routes through. Everything else is reported, never silently dropped. */
|
|
const SUPPORTED_PROTOCOLS = new Set(['http:', 'https:'])
|
|
|
|
/** Schemes recognised well enough to name in a diagnostic instead of calling them malformed. */
|
|
const SOCKS_PROTOCOLS = new Set(['socks:', 'socks4:', 'socks4a:', 'socks5:', 'socks5h:'])
|
|
|
|
/**
|
|
* One resolved outbound proxy policy. Plain data with no methods: worker threads receive it through
|
|
* `workerData`'s structured clone, so both sides run the identical policy rather than each re-reading
|
|
* an environment they may not share.
|
|
*/
|
|
export interface ProxyPolicy {
|
|
/** Proxy for `http:` origins, or absent for a direct connection. Always a validated `http(s):` URL. */
|
|
readonly httpProxy?: string
|
|
/** Proxy for `https:` origins, or absent for a direct connection. Always a validated `http(s):` URL. */
|
|
readonly httpsProxy?: string
|
|
/** The bypass list, already merged with {@link LOOPBACK_NO_PROXY}. Empty when nothing is bypassed. */
|
|
readonly noProxy: string
|
|
/** Which layer supplied the winning proxy URL; `env` when either field came from the environment. */
|
|
readonly source: 'env' | 'none'
|
|
}
|
|
|
|
/** A policy that proxies nothing. Callers that have not installed a policy resolve URLs against this. */
|
|
export const DIRECT_POLICY: ProxyPolicy = { noProxy: '', source: 'none' }
|
|
|
|
/** Why one candidate proxy value was not used. Callers decide whether this warns or fails the load. */
|
|
export interface ProxyDiagnostic {
|
|
/** `socks` for a SOCKS or PAC URL this package cannot route; `invalid` for anything unparseable. */
|
|
readonly kind: 'socks' | 'invalid'
|
|
/** The environment variable that supplied the rejected value. */
|
|
readonly origin: string
|
|
/** Operator-facing sentence naming the rejection and the way forward. Carries no credential. */
|
|
readonly message: string
|
|
}
|
|
|
|
|
|
/** A resolved policy plus every candidate value that was rejected on the way to it. */
|
|
export interface ProxyResolution {
|
|
/** The policy to install. Never carries a rejected value. */
|
|
readonly policy: ProxyPolicy
|
|
/** Rejections, in the order the candidates were considered. Empty on a clean resolution. */
|
|
readonly diagnostics: readonly ProxyDiagnostic[]
|
|
}
|
|
|
|
/**
|
|
* Read one environment name in undici's precedence order — lowercase first, uppercase as the
|
|
* fallback — treating a blank value as unset. Blank matters: undici's own `??` chain lets an empty
|
|
* lowercase name shadow a populated uppercase one.
|
|
*
|
|
* @param env - the launch environment snapshot to read.
|
|
* @param lower - the lowercase variable name.
|
|
* @returns the trimmed value and the name that supplied it, or `undefined` when neither is set.
|
|
*/
|
|
function readEnv(
|
|
env: EnvLookup,
|
|
lower: string,
|
|
): { value: string; name: string } | undefined {
|
|
for (const name of [lower, lower.toUpperCase()]) {
|
|
const value = env.get(name)?.value.trim()
|
|
if (value !== undefined && value !== '') return { value, name }
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
/**
|
|
* What one environment variable 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's usability, distinguishing a rejected slot from an empty one.
|
|
*/
|
|
function acceptProxyUrl(
|
|
candidate: { value: string; name: string } | undefined,
|
|
diagnostics: ProxyDiagnostic[],
|
|
): ProxyCandidate {
|
|
if (candidate === undefined) return ABSENT
|
|
const parsed = URL.parse(candidate.value)
|
|
if (parsed === null) {
|
|
diagnostics.push({
|
|
kind: 'invalid',
|
|
origin: candidate.name,
|
|
message: `${candidate.name} is not a valid URL; connecting directly`,
|
|
})
|
|
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; connecting directly for that scheme — set an http:// or https:// proxy URL instead`,
|
|
})
|
|
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; connecting directly for that scheme — set an http:// or https:// proxy URL instead`,
|
|
})
|
|
return { kind: 'rejected' }
|
|
}
|
|
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.
|
|
*
|
|
* @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)
|
|
}
|
|
|
|
/**
|
|
* Merge {@link LOOPBACK_NO_PROXY} into a bypass list, preserving the caller's entries and order.
|
|
* A list of `*` already bypasses everything and is returned unchanged.
|
|
*
|
|
* @param noProxy - the bypass list as the environment supplied it.
|
|
* @returns the effective bypass list.
|
|
*/
|
|
function withLoopback(noProxy: string | undefined): string {
|
|
const entries = (noProxy ?? '').split(/[,\s]+/).map(entry => entry.trim()).filter(entry => entry !== '')
|
|
if (entries.includes('*')) return '*'
|
|
const present = new Set(entries.map(entry => entry.toLowerCase()))
|
|
return [...entries, ...LOOPBACK_NO_PROXY.filter(entry => !present.has(entry))].join(',')
|
|
}
|
|
|
|
/**
|
|
* Split one bypass entry into host and optional port.
|
|
*
|
|
* A bare IPv6 literal carries several colons and no port, so only a single-colon entry splits;
|
|
* a bracketed literal takes its port from after the bracket. Getting this wrong is how undici
|
|
* turns `::1` into host `:` port `1`.
|
|
*
|
|
* @param entry - one already-trimmed bypass entry.
|
|
* @returns the entry's host and, when it carries one, its port.
|
|
*/
|
|
function splitHostPort(entry: string): { host: string; port?: string } {
|
|
if (entry.startsWith('[')) {
|
|
const close = entry.indexOf(']')
|
|
if (close !== -1) {
|
|
const rest = entry.slice(close + 1)
|
|
const host = entry.slice(1, close)
|
|
return rest.startsWith(':') ? { host, port: rest.slice(1) } : { host }
|
|
}
|
|
}
|
|
const colon = entry.indexOf(':')
|
|
if (colon !== -1 && entry.indexOf(':', colon + 1) === -1) {
|
|
return { host: entry.slice(0, colon), port: entry.slice(colon + 1) }
|
|
}
|
|
return { host: entry }
|
|
}
|
|
|
|
/** One IPv4 octet, so a loopback match cannot accept `127.999.1.1`. */
|
|
const OCTET = '(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)'
|
|
|
|
/** The whole `127.0.0.0/8` block, not just its first address. */
|
|
const LOOPBACK_IPV4 = new RegExp(`^127\\.${OCTET}\\.${OCTET}\\.${OCTET}$`)
|
|
|
|
/**
|
|
* Whether a host names this machine.
|
|
*
|
|
* A proxy cannot meaningfully reach one: it would resolve the address in its own network, and a
|
|
* proxy running on this machine would reach a service that only listens on loopback. The bypass
|
|
* list carries {@link LOOPBACK_NO_PROXY} for the consumers that read an environment rather than a
|
|
* policy, but those are four literal entries — matching them alone leaves `127.0.0.2`, the whole
|
|
* rest of `127.0.0.0/8`, and the IPv4-mapped spelling routed through the proxy.
|
|
*
|
|
* @param hostname - a URL's hostname, bracketed or not.
|
|
* @returns true when the host is loopback or the unspecified address.
|
|
*/
|
|
export function isLoopbackHost(hostname: string): boolean {
|
|
const host = hostname.replace(/^\[|\]$/g, '').replace(/\.$/, '').toLowerCase()
|
|
if (host === 'localhost' || host.endsWith('.localhost')) return true
|
|
if (host === '::1' || host === '::' || host === '0.0.0.0') return true
|
|
// An IPv4-mapped IPv6 address may keep its dotted tail or, once a URL has normalized it, carry
|
|
// the same four bytes as two hex groups: `::ffff:127.0.0.1` and `::ffff:7f00:1` are one address.
|
|
const mappedHigh = /^::ffff:([0-9a-f]{1,4}):[0-9a-f]{1,4}$/.exec(host)?.[1]
|
|
if (mappedHigh !== undefined) return Number.parseInt(mappedHigh, 16) >>> 8 === 127
|
|
return LOOPBACK_IPV4.test(host.startsWith('::ffff:') ? host.slice('::ffff:'.length) : host)
|
|
}
|
|
|
|
/**
|
|
* Decide whether a bypass list exempts one URL. An entry names a host and matches it together with
|
|
* every subdomain under it — `example.com` also bypasses `api.example.com` — and a leading `.` or
|
|
* `*.` is accepted as the same thing; an entry may carry a `:port`, and `*` bypasses everything.
|
|
* CIDR notation is not matched —
|
|
* an operating system's bypass list often carries `10.0.0.0/8`, which must be rewritten as suffixes.
|
|
*
|
|
* @param noProxy - the effective bypass list.
|
|
* @param url - the request URL.
|
|
* @returns true when the URL must bypass the proxy.
|
|
*/
|
|
export function bypassesProxy(noProxy: string, url: URL): boolean {
|
|
// `URL.hostname` keeps the brackets around an IPv6 literal, while a bypass entry may be written
|
|
// either way, so both sides are unbracketed before they are compared.
|
|
const host = url.hostname.replace(/^\[|\]$/g, '').replace(/\.$/, '').toLowerCase()
|
|
const port = url.port !== '' ? url.port : url.protocol === 'https:' ? '443' : '80'
|
|
for (const raw of noProxy.split(/[,\s]+/)) {
|
|
const entry = raw.trim().toLowerCase()
|
|
if (entry === '') continue
|
|
if (entry === '*') return true
|
|
const split = splitHostPort(entry)
|
|
if (split.port !== undefined && split.port !== port) continue
|
|
const candidate = split.host.replace(/^\*?\./, '').replace(/\.$/, '')
|
|
if (candidate === '') continue
|
|
if (host === candidate || host.endsWith(`.${candidate}`)) return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Resolve the outbound proxy policy for this process.
|
|
*
|
|
* A scheme's own variable wins, then `ALL_PROXY`, then — for HTTPS only — the HTTP proxy, matching
|
|
* undici so this function and the installed dispatcher never disagree about one URL.
|
|
*
|
|
* @param env - the launch environment, whose own layering already prefers real variables over `.env` files.
|
|
* @returns the policy to install plus every rejected candidate.
|
|
*/
|
|
export function resolveProxyPolicy(env: EnvLookup): ProxyResolution {
|
|
const diagnostics: ProxyDiagnostic[] = []
|
|
const all = acceptProxyUrl(readEnv(env, 'all_proxy'), diagnostics)
|
|
const allValue = all.kind === 'accepted' ? all.value : undefined
|
|
const envHttp = acceptProxyUrl(readEnv(env, 'http_proxy'), diagnostics)
|
|
const envHttps = acceptProxyUrl(readEnv(env, 'https_proxy'), diagnostics)
|
|
const httpProxy = resolveScheme(envHttp, allValue)
|
|
// 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, httpProxy)
|
|
if (httpProxy === undefined && httpsProxy === undefined) return { policy: DIRECT_POLICY, diagnostics }
|
|
return {
|
|
policy: {
|
|
...httpProxy === undefined ? {} : { httpProxy },
|
|
...httpsProxy === undefined ? {} : { httpsProxy },
|
|
noProxy: withLoopback(readEnv(env, 'no_proxy')?.value),
|
|
source: 'env',
|
|
},
|
|
diagnostics,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve which proxy one URL goes through under a policy.
|
|
*
|
|
* This is the single answer both the installed dispatcher and `dsh-web-fetch-http` consult, so a URL
|
|
* can never be pinned to a resolved address by one and tunnelled by the other.
|
|
*
|
|
* @param policy - the active policy.
|
|
* @param url - the request URL.
|
|
* @returns the proxy URL to tunnel through, or `undefined` for a direct connection.
|
|
*/
|
|
export function proxyForUrl(policy: ProxyPolicy, url: URL): string | undefined {
|
|
const proxy = url.protocol === 'https:' ? policy.httpsProxy : url.protocol === 'http:' ? policy.httpProxy : undefined
|
|
if (proxy === undefined) return undefined
|
|
if (isLoopbackHost(url.hostname)) return undefined
|
|
return bypassesProxy(policy.noProxy, url) ? undefined : proxy
|
|
}
|