From 3d8fcd612a1d2995afdc6182f84588efce4f1808 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 13 May 2026 14:44:00 +0200 Subject: [PATCH] refactor(ai): replace proxy agent dependencies Vendor HTTP(S) proxy env resolution and agents for Bedrock/Codex. SOCKS and PAC proxy URLs now fail explicitly. --- package-lock.json | 219 --------- packages/ai/package.json | 2 - packages/ai/src/providers/amazon-bedrock.ts | 27 +- .../src/providers/openai-codex-responses.ts | 9 +- packages/ai/src/utils/node-proxy-agent.ts | 416 ++++++++++++++++++ packages/ai/src/utils/proxy-env.ts | 122 +++++ packages/ai/test/node-proxy-agent.test.ts | 244 ++++++++++ packages/ai/test/proxy-env.test.ts | 76 ++++ 8 files changed, 867 insertions(+), 248 deletions(-) create mode 100644 packages/ai/src/utils/node-proxy-agent.ts create mode 100644 packages/ai/src/utils/proxy-env.ts create mode 100644 packages/ai/test/node-proxy-agent.test.ts create mode 100644 packages/ai/test/proxy-env.test.ts diff --git a/package-lock.json b/package-lock.json index 1fba0423f..9e7401cb4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3637,10 +3637,6 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, - "node_modules/@tootallnate/quickjs-emscripten": { - "version": "0.23.0", - "license": "MIT" - }, "node_modules/@types/chai": { "version": "5.2.3", "dev": true, @@ -3975,16 +3971,6 @@ "node": ">=12" } }, - "node_modules/ast-types": { - "version": "0.13.4", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.1" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/balanced-match": { "version": "4.0.4", "license": "MIT", @@ -4010,13 +3996,6 @@ ], "license": "MIT" }, - "node_modules/basic-ftp": { - "version": "5.3.1", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/bignumber.js": { "version": "9.3.1", "license": "MIT", @@ -4369,18 +4348,6 @@ "node": ">=4.0.0" } }, - "node_modules/degenerator": { - "version": "5.0.1", - "license": "MIT", - "dependencies": { - "ast-types": "^0.13.4", - "escodegen": "^2.1.0", - "esprima": "^4.0.1" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/detect-libc": { "version": "2.1.2", "dev": true, @@ -4496,43 +4463,6 @@ "node": ">=6" } }, - "node_modules/escodegen": { - "version": "2.1.0", - "license": "BSD-2-Clause", - "dependencies": { - "esprima": "^4.0.1", - "estraverse": "^5.2.0", - "esutils": "^2.0.2" - }, - "bin": { - "escodegen": "bin/escodegen.js", - "esgenerate": "bin/esgenerate.js" - }, - "engines": { - "node": ">=6.0" - }, - "optionalDependencies": { - "source-map": "~0.6.1" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, "node_modules/estree-walker": { "version": "3.0.3", "dev": true, @@ -4541,13 +4471,6 @@ "@types/estree": "^1.0.0" } }, - "node_modules/esutils": { - "version": "2.0.3", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/execa": { "version": "1.0.0", "dev": true, @@ -4774,25 +4697,6 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, - "node_modules/get-uri": { - "version": "6.0.5", - "license": "MIT", - "dependencies": { - "basic-ftp": "^5.0.2", - "data-uri-to-buffer": "^6.0.2", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/get-uri/node_modules/data-uri-to-buffer": { - "version": "6.0.2", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, "node_modules/github-from-package": { "version": "0.0.0", "dev": true, @@ -4889,17 +4793,6 @@ "version": "0.0.9", "license": "MIT" }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/https-proxy-agent": { "version": "7.0.6", "license": "MIT", @@ -4972,13 +4865,6 @@ "node": ">= 0.10" } }, - "node_modules/ip-address": { - "version": "10.2.0", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, "node_modules/is-core-module": { "version": "2.16.2", "dev": true, @@ -5593,13 +5479,6 @@ "dev": true, "license": "MIT" }, - "node_modules/netmask": { - "version": "2.1.1", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/nice-try": { "version": "1.0.5", "dev": true, @@ -5722,34 +5601,6 @@ "version": "0.12.0", "license": "MIT" }, - "node_modules/pac-proxy-agent": { - "version": "7.2.0", - "license": "MIT", - "dependencies": { - "@tootallnate/quickjs-emscripten": "^0.23.0", - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "get-uri": "^6.0.1", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.6", - "pac-resolver": "^7.0.1", - "socks-proxy-agent": "^8.0.5" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/pac-resolver": { - "version": "7.0.1", - "license": "MIT", - "dependencies": { - "degenerator": "^5.0.0", - "netmask": "^2.0.2" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/pako": { "version": "1.0.11", "license": "(MIT AND Zlib)" @@ -5951,34 +5802,6 @@ "node": ">=12.0.0" } }, - "node_modules/proxy-agent": { - "version": "6.5.0", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "http-proxy-agent": "^7.0.1", - "https-proxy-agent": "^7.0.6", - "lru-cache": "^7.14.1", - "pac-proxy-agent": "^7.1.0", - "proxy-from-env": "^1.1.0", - "socks-proxy-agent": "^8.0.5" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/proxy-agent/node_modules/lru-cache": { - "version": "7.18.3", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "license": "MIT" - }, "node_modules/pump": { "version": "3.0.4", "dev": true, @@ -6321,46 +6144,6 @@ "simple-concat": "^1.0.0" } }, - "node_modules/smart-buffer": { - "version": "4.2.0", - "license": "MIT", - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks": { - "version": "2.8.8", - "license": "MIT", - "dependencies": { - "ip-address": "^10.1.1", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks-proxy-agent": { - "version": "8.0.5", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "socks": "^2.8.3" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/source-map": { - "version": "0.6.1", - "license": "BSD-3-Clause", - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/source-map-js": { "version": "1.2.1", "dev": true, @@ -7107,8 +6890,6 @@ "@mistralai/mistralai": "^2.2.0", "openai": "6.26.0", "partial-json": "^0.1.7", - "proxy-agent": "^6.5.0", - "proxy-from-env": "^1.1.0", "typebox": "^1.1.24" }, "bin": { diff --git a/packages/ai/package.json b/packages/ai/package.json index 9fce8e352..b996f16dd 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -75,8 +75,6 @@ "@mistralai/mistralai": "^2.2.0", "openai": "6.26.0", "partial-json": "^0.1.7", - "proxy-agent": "^6.5.0", - "proxy-from-env": "^1.1.0", "typebox": "^1.1.24" }, "keywords": [ diff --git a/packages/ai/src/providers/amazon-bedrock.ts b/packages/ai/src/providers/amazon-bedrock.ts index 1812d3c8d..730a41689 100644 --- a/packages/ai/src/providers/amazon-bedrock.ts +++ b/packages/ai/src/providers/amazon-bedrock.ts @@ -20,6 +20,7 @@ import { type ToolConfiguration, ToolResultStatus, } from "@aws-sdk/client-bedrock-runtime"; +import { NodeHttpHandler } from "@smithy/node-http-handler"; import type { DocumentType } from "@smithy/types"; import { calculateCost } from "../models.js"; import type { @@ -42,6 +43,8 @@ import type { } from "../types.js"; import { AssistantMessageEventStream } from "../utils/event-stream.js"; import { parseStreamingJson } from "../utils/json-parse.js"; +import { createEnvProxyAgents } from "../utils/node-proxy-agent.js"; +import { hasProxyEnvironment } from "../utils/proxy-env.js"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.js"; import { adjustMaxTokensForThinking, buildBaseOptions, clampReasoning } from "./simple-options.js"; import { transformMessages } from "./transform-messages.js"; @@ -156,30 +159,14 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOpt }; } - if ( - process.env.HTTP_PROXY || - process.env.HTTPS_PROXY || - process.env.NO_PROXY || - process.env.http_proxy || - process.env.https_proxy || - process.env.no_proxy - ) { - const nodeHttpHandler = await import("@smithy/node-http-handler"); - const proxyAgent = await import("proxy-agent"); - - const agent = new proxyAgent.ProxyAgent(); - + if (hasProxyEnvironment()) { // Bedrock runtime uses NodeHttp2Handler by default since v3.798.0, which is based // on `http2` module and has no support for http agent. - // Use NodeHttpHandler to support http agent. - config.requestHandler = new nodeHttpHandler.NodeHttpHandler({ - httpAgent: agent, - httpsAgent: agent, - }); + // Use NodeHttpHandler to support HTTP(S) proxy agents. + config.requestHandler = new NodeHttpHandler(createEnvProxyAgents()); } else if (process.env.AWS_BEDROCK_FORCE_HTTP1 === "1") { // Some custom endpoints require HTTP/1.1 instead of HTTP/2 - const nodeHttpHandler = await import("@smithy/node-http-handler"); - config.requestHandler = new nodeHttpHandler.NodeHttpHandler(); + config.requestHandler = new NodeHttpHandler(); } } else { // Non-Node environment (browser): fall back to us-east-1 since diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index 64542b2c7..d65914331 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -40,6 +40,7 @@ import { } from "../utils/diagnostics.js"; import { AssistantMessageEventStream } from "../utils/event-stream.js"; import { headersToRecord } from "../utils/headers.js"; +import { getProxyForUrl, hasProxyEnvironment } from "../utils/proxy-env.js"; import { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "./openai-responses-shared.js"; import { buildBaseOptions } from "./simple-options.js"; @@ -717,13 +718,7 @@ async function getWebSocketConstructor(): Promise { // bun doesn't respect http proxy envs, ref: https://github.com/oven-sh/bun/issues/15489 // TODO: remove this when bun supports proxy envs in websocket. - if ( - process?.versions?.bun && - (process.env.HTTP_PROXY || process.env.HTTPS_PROXY || process.env.http_proxy || process.env.https_proxy) - ) { - const m = await dynamicImport("proxy-from-env"); - const getProxyForUrl = (m as { getProxyForUrl: (url: string | object | URL) => string }).getProxyForUrl; - + if (process?.versions?.bun && hasProxyEnvironment()) { _cachedWebsocket = class extends WebSocket { constructor(url: string | URL, options?: string | string[] | Record) { let _opts: Record = {}; diff --git a/packages/ai/src/utils/node-proxy-agent.ts b/packages/ai/src/utils/node-proxy-agent.ts new file mode 100644 index 000000000..4d1c4b4de --- /dev/null +++ b/packages/ai/src/utils/node-proxy-agent.ts @@ -0,0 +1,416 @@ +import * as http from "node:http"; +import * as https from "node:https"; +import * as net from "node:net"; +import type { Duplex } from "node:stream"; +import * as tls from "node:tls"; +import { getProxyForUrl } from "./proxy-env.js"; + +type ProxyResolver = (url: string) => string; +type ConnectionCallback = (error: Error | null, socket: Duplex) => void; + +interface AgentWithAddRequest { + addRequest(req: http.ClientRequest, options: http.RequestOptions): void; +} + +type ProxyRequestOptions = http.RequestOptions & { + servername?: string; +}; + +export interface EnvHttpProxyAgentOptions extends http.AgentOptions { + getProxyForUrl?: ProxyResolver; +} + +export interface EnvHttpsProxyAgentOptions extends https.AgentOptions { + getProxyForUrl?: ProxyResolver; +} + +export interface EnvProxyAgentsOptions extends http.AgentOptions, https.AgentOptions { + getProxyForUrl?: ProxyResolver; +} + +export interface EnvProxyAgents { + httpAgent: http.Agent; + httpsAgent: https.Agent; +} + +export const UNSUPPORTED_PROXY_PROTOCOL_MESSAGE = + "Unsupported proxy protocol. SOCKS and PAC proxy URLs are no longer supported; use an HTTP or HTTPS proxy URL."; + +const MAX_CONNECT_RESPONSE_BYTES = 64 * 1024; + +function toPort(value: string | number | null | undefined, defaultPort: number): number { + if (typeof value === "number") return value; + if (typeof value === "string" && value.length > 0) return Number.parseInt(value, 10); + return defaultPort; +} + +function stripPort(host: string): string { + if (host.startsWith("[")) { + const end = host.indexOf("]"); + return end === -1 ? host : host.slice(1, end); + } + + const firstColon = host.indexOf(":"); + const lastColon = host.lastIndexOf(":"); + if (firstColon !== -1 && firstColon === lastColon) { + const maybePort = host.slice(lastColon + 1); + if (/^\d+$/.test(maybePort)) return host.slice(0, lastColon); + } + return host; +} + +function getHostname(options: ProxyRequestOptions): string { + const value = options.hostname || options.host || "localhost"; + return stripPort(String(value)); +} + +function formatHost(hostname: string): string { + return net.isIPv6(hostname) ? `[${hostname}]` : hostname; +} + +function buildTargetUrl(protocol: "http:" | "https:", options: ProxyRequestOptions): string { + const defaultPort = protocol === "https:" ? 443 : 80; + const hostname = getHostname(options); + const port = toPort(options.port, defaultPort); + const portSuffix = port === defaultPort ? "" : `:${port}`; + return `${protocol}//${formatHost(hostname)}${portSuffix}`; +} + +function callConnectionCallbackWithError(callback: ConnectionCallback, error: Error): void { + (callback as (error: Error) => void)(error); +} + +function addRequest(agent: http.Agent, req: http.ClientRequest, options: http.RequestOptions): void { + (http.Agent.prototype as unknown as AgentWithAddRequest).addRequest.call(agent, req, options); +} + +function parseProxyUrl(proxy: string): URL { + const proxyUrl = new URL(proxy); + if (proxyUrl.protocol !== "http:" && proxyUrl.protocol !== "https:") { + throw new Error(`${UNSUPPORTED_PROXY_PROTOCOL_MESSAGE} Got ${proxyUrl.protocol}`); + } + return proxyUrl; +} + +function getProxyAuthorization(proxyUrl: URL): string | undefined { + if (!proxyUrl.username && !proxyUrl.password) return undefined; + const auth = `${decodeURIComponent(proxyUrl.username)}:${decodeURIComponent(proxyUrl.password)}`; + return `Basic ${Buffer.from(auth).toString("base64")}`; +} + +function setProxyRequestHeaders(req: http.ClientRequest, proxyUrl: URL, keepAlive: boolean): void { + const authorization = getProxyAuthorization(proxyUrl); + if (authorization) { + req.setHeader("Proxy-Authorization", authorization); + } + if (!req.hasHeader("Proxy-Connection")) { + req.setHeader("Proxy-Connection", keepAlive ? "Keep-Alive" : "close"); + } +} + +function hasLeadingUrlScheme(value: string): boolean { + return /^[a-z][a-z\d+.-]*:\/\//i.test(value); +} + +function getHttpRequestUrl(req: http.ClientRequest, options: ProxyRequestOptions): string { + const hostHeader = req.getHeader("host"); + const host = Array.isArray(hostHeader) ? hostHeader[0] : hostHeader; + const fallbackHost = formatHost(getHostname(options)); + const baseHost = typeof host === "string" && host.length > 0 ? host : fallbackHost; + const url = hasLeadingUrlScheme(req.path) ? new URL(req.path) : new URL(req.path, `http://${baseHost}`); + const port = toPort(options.port, 80); + if (!url.port && port !== 80) { + url.port = String(port); + } + return url.toString(); +} + +function getProxyConnectionOptions(proxyUrl: URL): net.NetConnectOpts & tls.ConnectionOptions { + const host = proxyUrl.hostname.replace(/^\[|\]$/g, ""); + const port = proxyUrl.port ? Number.parseInt(proxyUrl.port, 10) : proxyUrl.protocol === "https:" ? 443 : 80; + return { + ALPNProtocols: ["http/1.1"], + host, + port, + ...(proxyUrl.protocol === "https:" && !net.isIP(host) ? { servername: host } : {}), + }; +} + +function openProxySocket(proxyUrl: URL): Promise { + return new Promise((resolve, reject) => { + const connectOptions = getProxyConnectionOptions(proxyUrl); + const socket: net.Socket = + proxyUrl.protocol === "https:" ? tls.connect(connectOptions) : net.connect(connectOptions); + + const cleanup = () => { + socket.removeListener("connect", onConnect); + socket.removeListener("secureConnect", onConnect); + socket.removeListener("error", onError); + }; + const onConnect = () => { + cleanup(); + resolve(socket); + }; + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + + socket.once(proxyUrl.protocol === "https:" ? "secureConnect" : "connect", onConnect); + socket.once("error", onError); + }); +} + +interface ProxyConnectResponse { + statusCode: number; + statusText: string; + headers: http.IncomingHttpHeaders; +} + +function parseProxyConnectResponse(socket: net.Socket): Promise { + return new Promise((resolve, reject) => { + const buffers: Buffer[] = []; + let buffersLength = 0; + + const cleanup = () => { + socket.removeListener("data", onData); + socket.removeListener("end", onEnd); + socket.removeListener("error", onError); + }; + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + const onEnd = () => { + cleanup(); + reject(new Error("Proxy connection ended before receiving CONNECT response")); + }; + const onData = (chunk: Buffer) => { + buffers.push(chunk); + buffersLength += chunk.length; + if (buffersLength > MAX_CONNECT_RESPONSE_BYTES) { + cleanup(); + reject(new Error("Proxy CONNECT response exceeded the maximum header size")); + return; + } + + const buffered = Buffer.concat(buffers, buffersLength); + const endOfHeaders = buffered.indexOf("\r\n\r\n"); + if (endOfHeaders === -1) return; + + cleanup(); + const extra = buffered.subarray(endOfHeaders + 4); + if (extra.length > 0) { + socket.unshift(extra); + } + + try { + resolve(parseProxyConnectHeaders(buffered.subarray(0, endOfHeaders).toString("ascii"))); + } catch (error) { + reject(error instanceof Error ? error : new Error(String(error))); + } + }; + + socket.on("data", onData); + socket.once("end", onEnd); + socket.once("error", onError); + }); +} + +function parseProxyConnectHeaders(headerText: string): ProxyConnectResponse { + const [statusLine, ...headerLines] = headerText.split("\r\n"); + const statusMatch = statusLine?.match(/^HTTP\/\d(?:\.\d)?\s+(\d{3})(?:\s+(.*))?$/); + if (!statusMatch) { + throw new Error("Invalid proxy CONNECT response status line"); + } + + const headers: http.IncomingHttpHeaders = {}; + for (const line of headerLines) { + if (!line) continue; + const colon = line.indexOf(":"); + if (colon === -1) throw new Error(`Invalid proxy CONNECT response header: ${line}`); + const key = line.slice(0, colon).toLowerCase(); + const value = line.slice(colon + 1).trimStart(); + const current = headers[key]; + if (typeof current === "string") { + headers[key] = [current, value]; + } else if (Array.isArray(current)) { + current.push(value); + } else { + headers[key] = value; + } + } + + return { + statusCode: Number.parseInt(statusMatch[1], 10), + statusText: statusMatch[2] || "", + headers, + }; +} + +function buildConnectPayload(targetHost: string, targetPort: number, proxyUrl: URL, keepAlive: boolean): string { + const host = formatHost(targetHost); + const headers: Record = { + Host: `${host}:${targetPort}`, + "Proxy-Connection": keepAlive ? "Keep-Alive" : "close", + }; + const authorization = getProxyAuthorization(proxyUrl); + if (authorization) { + headers["Proxy-Authorization"] = authorization; + } + + let payload = `CONNECT ${host}:${targetPort} HTTP/1.1\r\n`; + for (const [name, value] of Object.entries(headers)) { + payload += `${name}: ${value}\r\n`; + } + return `${payload}\r\n`; +} + +function buildTlsOptions(options: ProxyRequestOptions, socket: net.Socket): tls.ConnectionOptions { + const { + host: _host, + hostname: _hostname, + path: _path, + port: _port, + protocol: _protocol, + socketPath: _socketPath, + ...tlsOptions + } = options; + const targetHost = getHostname(options); + return { + ...tlsOptions, + socket, + ...(options.servername === undefined && !net.isIP(targetHost) ? { servername: targetHost } : {}), + }; +} + +async function connectHttpsThroughProxy( + proxyUrl: URL, + options: ProxyRequestOptions, + keepAlive: boolean, +): Promise { + const socket = await openProxySocket(proxyUrl); + let upgradedToTls = false; + + try { + const targetHost = getHostname(options); + const targetPort = toPort(options.port, 443); + const responsePromise = parseProxyConnectResponse(socket); + socket.write(buildConnectPayload(targetHost, targetPort, proxyUrl, keepAlive)); + const response = await responsePromise; + + if (response.statusCode !== 200) { + const statusText = response.statusText ? ` ${response.statusText}` : ""; + throw new Error(`Proxy CONNECT failed with status ${response.statusCode}${statusText}`); + } + + const tlsSocket = tls.connect(buildTlsOptions(options, socket)); + upgradedToTls = true; + return tlsSocket; + } catch (error) { + if (!upgradedToTls) { + socket.destroy(); + } + throw error; + } +} + +export class EnvHttpProxyAgent extends http.Agent { + private readonly keepAliveEnabled: boolean; + private readonly resolveProxy: ProxyResolver; + + constructor(options: EnvHttpProxyAgentOptions = {}) { + const { getProxyForUrl: resolver, ...agentOptions } = options; + super(agentOptions); + this.keepAliveEnabled = options.keepAlive ?? false; + this.resolveProxy = resolver || getProxyForUrl; + } + + addRequest(req: http.ClientRequest, options: ProxyRequestOptions): void { + let proxyUrl: URL | undefined; + try { + const proxy = this.resolveProxy(buildTargetUrl("http:", options)); + proxyUrl = proxy ? parseProxyUrl(proxy) : undefined; + } catch (error) { + const requestError = error instanceof Error ? error : new Error(String(error)); + queueMicrotask(() => { + req.emit("error", requestError); + req.destroy(); + }); + return; + } + + if (proxyUrl) { + req.path = getHttpRequestUrl(req, options); + setProxyRequestHeaders(req, proxyUrl, this.keepAliveEnabled); + } + + addRequest(this, req, options); + } + + override createConnection(options: ProxyRequestOptions, callback?: ConnectionCallback): Duplex | null | undefined { + try { + const proxy = this.resolveProxy(buildTargetUrl("http:", options)); + if (!proxy) { + return super.createConnection(options, callback); + } + + const proxyUrl = parseProxyUrl(proxy); + return proxyUrl.protocol === "https:" + ? tls.connect(getProxyConnectionOptions(proxyUrl)) + : net.connect(getProxyConnectionOptions(proxyUrl)); + } catch (error) { + if (callback) { + callConnectionCallbackWithError(callback, error instanceof Error ? error : new Error(String(error))); + return undefined; + } + throw error; + } + } +} + +export class EnvHttpsProxyAgent extends https.Agent { + private readonly keepAliveEnabled: boolean; + private readonly resolveProxy: ProxyResolver; + + constructor(options: EnvHttpsProxyAgentOptions = {}) { + const { getProxyForUrl: resolver, ...agentOptions } = options; + super(agentOptions); + this.keepAliveEnabled = options.keepAlive ?? false; + this.resolveProxy = resolver || getProxyForUrl; + } + + override createConnection(options: ProxyRequestOptions, callback?: ConnectionCallback): Duplex | null | undefined { + try { + const proxy = this.resolveProxy(buildTargetUrl("https:", options)); + if (!proxy) { + return super.createConnection(options, callback); + } + + const proxyUrl = parseProxyUrl(proxy); + if (!callback) { + throw new Error("A callback is required for HTTPS proxy connections"); + } + + connectHttpsThroughProxy(proxyUrl, options, this.keepAliveEnabled).then( + (socket) => callback(null, socket), + (error: unknown) => + callConnectionCallbackWithError(callback, error instanceof Error ? error : new Error(String(error))), + ); + return undefined; + } catch (error) { + if (callback) { + callConnectionCallbackWithError(callback, error instanceof Error ? error : new Error(String(error))); + return undefined; + } + throw error; + } + } +} + +export function createEnvProxyAgents(options: EnvProxyAgentsOptions = {}): EnvProxyAgents { + return { + httpAgent: new EnvHttpProxyAgent(options), + httpsAgent: new EnvHttpsProxyAgent(options), + }; +} diff --git a/packages/ai/src/utils/proxy-env.ts b/packages/ai/src/utils/proxy-env.ts new file mode 100644 index 000000000..a127f15b9 --- /dev/null +++ b/packages/ai/src/utils/proxy-env.ts @@ -0,0 +1,122 @@ +/* + * Adapted from proxy-from-env. + * + * The MIT License + * + * Copyright (C) 2016-2018 Rob Wu + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is furnished to do + * so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +const DEFAULT_PORTS: Record = { + ftp: 21, + gopher: 70, + http: 80, + https: 443, + ws: 80, + wss: 443, +}; + +const PROXY_ENV_KEYS = [ + "npm_config_http_proxy", + "http_proxy", + "npm_config_https_proxy", + "https_proxy", + "npm_config_ws_proxy", + "ws_proxy", + "npm_config_wss_proxy", + "wss_proxy", + "npm_config_proxy", + "all_proxy", +] as const; + +function getEnv(key: string): string { + if (typeof process === "undefined") return ""; + return process.env[key.toLowerCase()] || process.env[key.toUpperCase()] || ""; +} + +function getDefaultPort(protocol: string): number { + return DEFAULT_PORTS[protocol] || 0; +} + +function parseUrl(value: string | URL): URL | undefined { + try { + return typeof value === "string" ? new URL(value) : value; + } catch { + return undefined; + } +} + +function shouldProxy(hostname: string, port: number): boolean { + const noProxy = (getEnv("npm_config_no_proxy") || getEnv("no_proxy")).toLowerCase(); + if (!noProxy) return true; + if (noProxy === "*") return false; + + const lowerHostname = hostname.toLowerCase(); + return noProxy.split(/[,\s]/).every((entry) => { + if (!entry) return true; + + const match = entry.match(/^(.+):(\d+)$/); + let entryHostname = match ? match[1] : entry; + const entryPort = match ? Number.parseInt(match[2], 10) : 0; + if (entryPort && entryPort !== port) return true; + + if (!/^[.*]/.test(entryHostname)) { + return lowerHostname !== entryHostname; + } + + if (entryHostname.startsWith("*")) { + entryHostname = entryHostname.slice(1); + } + return !lowerHostname.endsWith(entryHostname); + }); +} + +/** + * Resolve the HTTP proxy URL for a target URL from standard proxy environment variables. + * + * Matches proxy-from-env precedence for the env vars Pi has historically supported: + * npm_config__proxy, _proxy, npm_config_proxy, all_proxy, and no_proxy. + */ +export function getProxyForUrl(value: string | URL): string { + const parsedUrl = parseUrl(value); + if (!parsedUrl?.protocol || !parsedUrl.host) return ""; + + const protocol = parsedUrl.protocol.slice(0, -1); + const hostname = parsedUrl.host.replace(/:\d*$/, "").toLowerCase(); + if (!hostname) return ""; + + const port = parsedUrl.port ? Number.parseInt(parsedUrl.port, 10) : getDefaultPort(protocol); + if (!shouldProxy(hostname, port)) return ""; + + let proxy = + getEnv(`npm_config_${protocol}_proxy`) || + getEnv(`${protocol}_proxy`) || + getEnv("npm_config_proxy") || + getEnv("all_proxy"); + if (proxy && !proxy.includes("://")) { + proxy = `${protocol}://${proxy}`; + } + return proxy; +} + +export function hasProxyEnvironment(): boolean { + if (typeof process === "undefined") return false; + return PROXY_ENV_KEYS.some((key) => getEnv(key).length > 0); +} diff --git a/packages/ai/test/node-proxy-agent.test.ts b/packages/ai/test/node-proxy-agent.test.ts new file mode 100644 index 000000000..f921ba02d --- /dev/null +++ b/packages/ai/test/node-proxy-agent.test.ts @@ -0,0 +1,244 @@ +import { Buffer } from "node:buffer"; +import { once } from "node:events"; +import * as http from "node:http"; +import * as https from "node:https"; +import type { AddressInfo } from "node:net"; +import * as net from "node:net"; +import { afterEach, describe, expect, it } from "vitest"; +import { EnvHttpProxyAgent, EnvHttpsProxyAgent } from "../src/utils/node-proxy-agent.js"; + +const TEST_KEY = `-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC6BQBGN7QX+PBv +gVIxRkVmk/1e1v+njs6TusQrPFfiGFqx4HqQ8aavyo24v5dHsLHcltxP4u8Ryt9/ +Fo5sBhpaVCTvbGkUyfv8HUz2m9gSEKHxyRA3d0W2JlSiFqPzv0JhapxLjRfOdxc5 +8FPx4MqJDQDnExcemTmvBCtyiWL/xmwh//cqm1j3H52ri+7W6olaGkil45xGKE0A +8IgMvG+VgzTNnmKSeYRGeWVAW4RdjVRQcntDCImUWDGAh1P3hxsoz9C5ehhvJbQD +vXM8c/XRMAaOQ/iGxpjqs/jxRZZpceZkYYMjOKpP1gJfEwVYxOWKKQ9eDSgJrf2N +iIOqYPzfAgMBAAECggEBAKRRNJvSKsieQq0MvDEAlN92zHKRUoWNdVczvINAq5Tx +1HOaCkqs24QfVa8jtptCLurfbD85A9tgrKoTn702auLtvy6rQet2PJvIuiqBIY1b +meH9rNCCEFdFZq9DdpIimZ150hQ+zw+3TRIGA/D+k4oarXhw+ZQy36yE72geig/K +kjgWoSX6ry78AOtlKnJGUJqgyzW/220ljzuJSW3Ry6aTDRUMkUi8k8z6o8Ku8YnM +PIW3eN9zh3qAsT/OGf2UFE1bnSj1tKXTqXPvt5h+9XiZ3rvQreyQW9Ns0kNJCHSZ +R775WeI3LpEhq6kFCcEGTozCLjgZ9eLI8zk7fLFF0KECgYEA3aVsqczZ3Py+3jtO +vbwCb5jJ+VmF4moC7FMvaXp6v+Cy35D5LKTnCvELJRChe83jzzH23m16nZQMT+cp +3yq+H1m6jgXhzbgDXwri/dngVpjLmoBp/P421q6Sx8OhYokRVhRwLlB9hqHnq0It +59uKUEyZ1/+xtrqpeYrzAUc1We8CgYEA1tn3auc3toKt+dE5Zxw9HtmCe9FQ/VUX +MbzYP0bXTl/JcwKulYPODhT8Q2ihspea88GCFqHt0MULPFD7hnaW0d9G9DBg3xH2 +dqKJnZ7vl8+PdN5mhumjhPaST5av6v40mP8EjOkerW5UzkbTorfRKFCesZlEhd0d +I01jvSdePBECgYBPuw0uu7D3TLgAS0dU+0fJCyZEm06NFuN8TaQ1hkiXs2XFBGqO +A7fU+Mawi537YiH7y5Zphupfuvz+1UH7tG1165ovMrB6hyI3Uzw6YuDPZeF/74ew +6WWirmPAln/8aSAiXfHIx02QW7dxpLnMuO21WwjQaXttJxKF1VT69bmcYwKBgQCb +UFYbYapUtYMu5KCqeS917ab1+wqhF7H8spdgpsVeUsA98+JhEzcR1vnFgQ3jHNVX +ALwZwFU6ZjcJE0HAolnEvbN9MrvUhhe1CyqQVyS8ib8arOtQ+/TJWbXK8xOYvMsp +DrErbBpRJUEJHQpAxsDcc+tEV5fBbWZy7q77PkpRAQKBgGd3cYOMIyB3EbFpZTEc +suv6urzMY+ezHWVdDTwzb2O9TaPUY8ovF0qjp0aD5u+zeUfZ8Sg3XVP0ceriLubw +bIBazJUHN2zO8W1iMSnQ1TqbHn02IR/ySMJbE1BH2KGEDKbSIcRPMNNVqJx+Civ+ +5r0i4ZclQDLWp6VaabdukwJ4 +-----END PRIVATE KEY-----`; + +const TEST_CERT = `-----BEGIN CERTIFICATE----- +MIICpDCCAYwCCQCAip8jY3MesjANBgkqhkiG9w0BAQsFADAUMRIwEAYDVQQDDAls +b2NhbGhvc3QwHhcNMjYwNTEzMTIwNTQwWhcNMzYwNTEwMTIwNTQwWjAUMRIwEAYD +VQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC6 +BQBGN7QX+PBvgVIxRkVmk/1e1v+njs6TusQrPFfiGFqx4HqQ8aavyo24v5dHsLHc +ltxP4u8Ryt9/Fo5sBhpaVCTvbGkUyfv8HUz2m9gSEKHxyRA3d0W2JlSiFqPzv0Jh +apxLjRfOdxc58FPx4MqJDQDnExcemTmvBCtyiWL/xmwh//cqm1j3H52ri+7W6ola +Gkil45xGKE0A8IgMvG+VgzTNnmKSeYRGeWVAW4RdjVRQcntDCImUWDGAh1P3hxso +z9C5ehhvJbQDvXM8c/XRMAaOQ/iGxpjqs/jxRZZpceZkYYMjOKpP1gJfEwVYxOWK +KQ9eDSgJrf2NiIOqYPzfAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAAtg3Gs68TVQ +EGPGCnjoR7CFET9/yyaxlehI4djJsAHK3CM82lFLcu64NzkmbirSLzFbU2eW/LcY +R/er/QML1L6t0cUOhIA9sM0nMakZ0gPHZwRAHTYySIUU3qfLPobSQbKLpCfErpzp +sfcIpR93fxHQUTx58D2GwwBCFhTzaH4dBdykArzuMXZ8Ywd+btD3HVPF9ARZIFBp +R0voLArplJfZYx9P73nbSqfN1pycE1iTlvsjbsrihuR/3hKOJfOEkIu242hasvVw +R+xvp78sGTgV8McCWqM7uuDg41Iiv9osEgVP81TxpcoNksWuJVduEl9gX/vXxglC +0YhuPJ0qWj8= +-----END CERTIFICATE-----`; + +const servers: Array = []; +const sockets = new Set(); + +async function listen(server: http.Server | https.Server): Promise { + server.on("connection", (socket) => { + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); + }); + server.listen(0, "127.0.0.1"); + servers.push(server); + await once(server, "listening"); + return (server.address() as AddressInfo).port; +} + +function closeServer(server: http.Server | https.Server): Promise { + return new Promise((resolve, reject) => { + server.close((error) => { + if (error) reject(error); + else resolve(); + }); + }); +} + +async function requestText( + client: typeof http | typeof https, + options: http.RequestOptions | https.RequestOptions, +): Promise { + return new Promise((resolve, reject) => { + const req = client.request(options, (response) => { + const chunks: Buffer[] = []; + response.on("data", (chunk: Buffer) => chunks.push(chunk)); + response.once("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + }); + req.once("error", reject); + req.end(); + }); +} + +afterEach(async () => { + for (const socket of sockets) { + socket.destroy(); + } + sockets.clear(); + await Promise.all(servers.splice(0).map((server) => closeServer(server))); +}); + +describe("node proxy agents", () => { + it("sends HTTP requests through an HTTP proxy", async () => { + const target = http.createServer((_request, response) => { + response.end("direct"); + }); + const targetPort = await listen(target); + + let proxiedUrl: string | undefined; + let proxyAuthorization: string | string[] | undefined; + const proxy = http.createServer((request, response) => { + proxiedUrl = request.url; + proxyAuthorization = request.headers["proxy-authorization"]; + response.end("proxied"); + }); + const proxyPort = await listen(proxy); + + const agent = new EnvHttpProxyAgent({ + getProxyForUrl: () => `http://user:pass@127.0.0.1:${proxyPort}`, + }); + + const text = await requestText(http, { + agent, + hostname: "127.0.0.1", + path: "/callback?next=https://example.com/after", + port: targetPort, + }); + agent.destroy(); + + expect(text).toBe("proxied"); + expect(proxiedUrl).toBe(`http://127.0.0.1:${targetPort}/callback?next=https://example.com/after`); + expect(proxyAuthorization).toBe(`Basic ${Buffer.from("user:pass").toString("base64")}`); + }); + + it("tunnels HTTPS requests through an HTTP proxy", async () => { + const target = https.createServer({ key: TEST_KEY, cert: TEST_CERT }, (_request, response) => { + response.end("secure target"); + }); + const targetPort = await listen(target); + + let connectUrl: string | undefined; + let proxyAuthorization: string | string[] | undefined; + const proxy = http.createServer(); + proxy.on("connect", (request, clientSocket, head) => { + connectUrl = request.url; + proxyAuthorization = request.headers["proxy-authorization"]; + + const serverSocket = net.connect(targetPort, "127.0.0.1", () => { + clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + if (head.length > 0) serverSocket.write(head); + serverSocket.pipe(clientSocket); + clientSocket.pipe(serverSocket); + }); + serverSocket.once("error", (error) => clientSocket.destroy(error)); + clientSocket.once("error", () => serverSocket.destroy()); + }); + const proxyPort = await listen(proxy); + + const agent = new EnvHttpsProxyAgent({ + getProxyForUrl: () => `http://user:pass@127.0.0.1:${proxyPort}`, + rejectUnauthorized: false, + }); + + const text = await requestText(https, { + agent, + hostname: "127.0.0.1", + path: "/secure", + port: targetPort, + }); + agent.destroy(); + + expect(text).toBe("secure target"); + expect(connectUrl).toBe(`127.0.0.1:${targetPort}`); + expect(proxyAuthorization).toBe(`Basic ${Buffer.from("user:pass").toString("base64")}`); + }); + + it("closes the proxy socket when CONNECT parsing fails", async () => { + const proxy = http.createServer(); + proxy.on("connect", (_request, clientSocket) => { + clientSocket.write("not-http\r\n\r\n"); + }); + const proxyPort = await listen(proxy); + + let destroyedProxySocket = false; + const originalDestroy = net.Socket.prototype.destroy; + net.Socket.prototype.destroy = function patchedDestroy(this: net.Socket, error?: Error): net.Socket { + if (this.remotePort === proxyPort) { + destroyedProxySocket = true; + } + return originalDestroy.call(this, error); + }; + + const agent = new EnvHttpsProxyAgent({ + getProxyForUrl: () => `http://127.0.0.1:${proxyPort}`, + rejectUnauthorized: false, + }); + + try { + await expect( + requestText(https, { + agent, + hostname: "127.0.0.1", + path: "/secure", + port: 443, + }), + ).rejects.toThrow(/Invalid proxy CONNECT response status line/); + expect(destroyedProxySocket).toBe(true); + } finally { + net.Socket.prototype.destroy = originalDestroy; + agent.destroy(); + } + }); + + it("rejects SOCKS and PAC proxy URLs explicitly", async () => { + const agent = new EnvHttpProxyAgent({ + getProxyForUrl: () => "socks5://127.0.0.1:1080", + }); + + await expect( + requestText(http, { + agent, + hostname: "127.0.0.1", + path: "/", + port: 9, + }), + ).rejects.toThrow(/SOCKS and PAC proxy URLs are no longer supported/); + agent.destroy(); + + const pacAgent = new EnvHttpProxyAgent({ + getProxyForUrl: () => "pac+http://127.0.0.1/proxy.pac", + }); + + await expect( + requestText(http, { + agent: pacAgent, + hostname: "127.0.0.1", + path: "/", + port: 9, + }), + ).rejects.toThrow(/SOCKS and PAC proxy URLs are no longer supported/); + pacAgent.destroy(); + }); +}); diff --git a/packages/ai/test/proxy-env.test.ts b/packages/ai/test/proxy-env.test.ts new file mode 100644 index 000000000..c1d8a2e35 --- /dev/null +++ b/packages/ai/test/proxy-env.test.ts @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { getProxyForUrl, hasProxyEnvironment } from "../src/utils/proxy-env.js"; + +const PROXY_ENV_KEYS = [ + "HTTP_PROXY", + "http_proxy", + "HTTPS_PROXY", + "https_proxy", + "ALL_PROXY", + "all_proxy", + "NO_PROXY", + "no_proxy", + "npm_config_http_proxy", + "npm_config_https_proxy", + "npm_config_proxy", + "npm_config_no_proxy", +]; + +const savedEnv = new Map(); + +function clearProxyEnv(): void { + for (const key of PROXY_ENV_KEYS) { + if (!savedEnv.has(key)) savedEnv.set(key, process.env[key]); + delete process.env[key]; + } +} + +function setProxyEnv(env: Record): void { + clearProxyEnv(); + for (const [key, value] of Object.entries(env)) { + process.env[key] = value; + } +} + +afterEach(() => { + for (const key of PROXY_ENV_KEYS) { + const value = savedEnv.get(key); + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + savedEnv.clear(); +}); + +describe("proxy env resolution", () => { + it("uses scheme-specific proxy variables", () => { + setProxyEnv({ HTTPS_PROXY: "http://proxy.example:8080" }); + + expect(getProxyForUrl("https://api.example/v1")).toBe("http://proxy.example:8080"); + expect(hasProxyEnvironment()).toBe(true); + }); + + it("adds the target scheme when a proxy URL has no scheme", () => { + setProxyEnv({ http_proxy: "proxy.example:8080" }); + + expect(getProxyForUrl("http://api.example/v1")).toBe("http://proxy.example:8080"); + }); + + it("honors NO_PROXY exact hosts, suffixes, wildcards, and ports", () => { + setProxyEnv({ + HTTPS_PROXY: "http://proxy.example:8080", + NO_PROXY: "api.example,.internal,*.corp,other.example:8443", + }); + + expect(getProxyForUrl("https://api.example/v1")).toBe(""); + expect(getProxyForUrl("https://service.internal/v1")).toBe(""); + expect(getProxyForUrl("https://build.corp/v1")).toBe(""); + expect(getProxyForUrl("https://other.example:443/v1")).toBe("http://proxy.example:8080"); + expect(getProxyForUrl("https://other.example:8443/v1")).toBe(""); + }); + + it("falls back to ALL_PROXY", () => { + setProxyEnv({ ALL_PROXY: "http://proxy.example:8080" }); + + expect(getProxyForUrl("https://api.example/v1")).toBe("http://proxy.example:8080"); + }); +});