/** * Overlay HTTP proxy variables onto the source `dsh` Node process only. * Bootstrap proxy names cannot come from `.env` files; this overlay is the * checkout-local way to give OpenRouter (and other HTTPS fetches) an HTTP * proxy without changing the calling shell. * @module */ import { existsSync, readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' /** Repo-root file the source launcher reads when present; gitignored. */ export const SOURCE_DSH_HTTP_PROXY_FILE = '.dsh-http-proxy.env' /** Process variables Node, undici, and pi-ai consult for HTTP(S) proxies. */ export const SOURCE_DSH_PROXY_VAR_NAMES = [ 'HTTPS_PROXY', 'HTTP_PROXY', 'ALL_PROXY', 'https_proxy', 'http_proxy', 'all_proxy', ] as const const PROXY_VAR_NAME_SET = new Set(SOURCE_DSH_PROXY_VAR_NAMES) const LOCAL_NO_PROXY = 'localhost,127.0.0.1,::1' /** * Write one HTTP(S) proxy URL onto every name Node and pi-ai read, including * leftover inherited SOCKS values the overlay file did not mention. */ function installHttpProxy(env: NodeJS.ProcessEnv, url: string): void { for (const name of SOURCE_DSH_PROXY_VAR_NAMES) { env[name] = url } env.NODE_USE_ENV_PROXY = '1' if (!env.NO_PROXY && !env.no_proxy) { env.NO_PROXY = LOCAL_NO_PROXY env.no_proxy = LOCAL_NO_PROXY } } /** * First non-SOCKS proxy URL among the names Node consults. * @param env - environment after overlay assignments. */ function firstHttpProxyUrl(env: NodeJS.ProcessEnv): string | undefined { for (const name of SOURCE_DSH_PROXY_VAR_NAMES) { const value = env[name]?.trim() if (value !== undefined && value !== '' && !isSocksProxyUrl(value)) return value } return undefined } /** * Set on the child after this wrapper respawns so `fetch` sees the HTTP overlay * at process start. Node `--env-file` does not replace an inherited SOCKS * `HTTPS_PROXY`, and `NODE_USE_ENV_PROXY` assigned after start is ignored. */ export const SOURCE_DSH_PROXY_REEXEC_MARKER = 'DSH_SOURCE_HTTP_PROXY_APPLIED' /** @returns whether `value` is a SOCKS URL Node cannot use for fetch/pnpm. */ export function isSocksProxyUrl(value: string): boolean { return /^socks5h?:\/\//i.test(value.trim()) } /** * Parse a dotenv-like overlay file into assignments. * @param text - file contents. * @returns name → value; empty lines and `#` comments are skipped. */ export function parseSourceDshHttpProxyFile(text: string): Record { const out: Record = {} for (const raw of text.split(/\r?\n/)) { const line = raw.trim() if (line === '' || line.startsWith('#')) continue const body = line.startsWith('export ') ? line.slice('export '.length).trim() : line const eq = body.indexOf('=') if (eq <= 0) continue const name = body.slice(0, eq).trim() let value = body.slice(eq + 1).trim() if ( (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) ) { value = value.slice(1, -1) } if (name !== '') out[name] = value } return out } /** * Inputs for {@link applySourceDshHttpProxy}. Tests pass a fake env and file text. */ export interface ApplySourceDshHttpProxyInput { /** Mutable environment of this Node process (normally `process.env`). */ env: NodeJS.ProcessEnv /** Contents of `.dsh-http-proxy.env`; `null` when the file is absent. */ fileText: string | null /** Optional explicit HTTP overlay URL (`DSH_HTTP_PROXY`), which outranks the file. */ overlayUrl?: string | undefined } /** * Apply an HTTP proxy overlay onto `env` for this process only. * @param input - env, optional file, optional `DSH_HTTP_PROXY`. * @throws when the winning overlay is a SOCKS URL. */ export function applySourceDshHttpProxy(input: ApplySourceDshHttpProxyInput): void { const overlayUrl = input.overlayUrl?.trim() if (overlayUrl) { if (isSocksProxyUrl(overlayUrl)) { throw new Error( `DSH_HTTP_PROXY=${overlayUrl}. Node cannot fetch through SOCKS. Use an HTTP proxy (for example http://127.0.0.1:3067).`, ) } installHttpProxy(input.env, overlayUrl) return } if (input.fileText === null) return const assignments = parseSourceDshHttpProxyFile(input.fileText) for (const [name, value] of Object.entries(assignments)) { if (PROXY_VAR_NAME_SET.has(name) && isSocksProxyUrl(value)) { throw new Error( `${name}=${value}. Node cannot fetch through SOCKS. Use an HTTP proxy (for example http://127.0.0.1:3067).`, ) } input.env[name] = value } const httpUrl = firstHttpProxyUrl(input.env) if (httpUrl !== undefined) installHttpProxy(input.env, httpUrl) } /** * HTTP overlay URL now on `env`, if any. * @param env - environment after {@link applySourceDshHttpProxy}. */ export function sourceDshAppliedHttpProxyUrl(env: NodeJS.ProcessEnv): string | undefined { return firstHttpProxyUrl(env) } /** Absolute path of the gitignored overlay file beside this repository root. */ export function sourceDshHttpProxyFilePath(): string { return fileURLToPath(new URL(`../${SOURCE_DSH_HTTP_PROXY_FILE}`, import.meta.url)) } /** * Read the overlay file when it exists. * @param path - absolute path; defaults to the repo-root gitignored file. * @returns file text, or `null` when absent. */ export function readSourceDshHttpProxyFile(path = sourceDshHttpProxyFilePath()): string | null { if (!existsSync(path)) return null return readFileSync(path, 'utf8') } /** * Whether an overlay source is present (file or `DSH_HTTP_PROXY`). * @param overlayUrl - `DSH_HTTP_PROXY` value. * @param fileText - overlay file contents, or `null` when absent. */ export function sourceDshHttpProxyOverlayPresent(overlayUrl: string | undefined, fileText: string | null): boolean { return Boolean(overlayUrl?.trim()) || fileText !== null } /** * Whether this process must respawn so `fetch` binds the overlay at start. * @param env - the environment after {@link applySourceDshHttpProxy}. * @param overlayPresent - {@link sourceDshHttpProxyOverlayPresent}. */ export function shouldReexecSourceDshForFetchProxy(env: NodeJS.ProcessEnv, overlayPresent: boolean): boolean { return overlayPresent && env[SOURCE_DSH_PROXY_REEXEC_MARKER] !== '1' }