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.
This commit is contained in:
Armin Ronacher
2026-05-13 14:44:00 +02:00
Unverified
parent e0b5d27af2
commit 3d8fcd612a
8 changed files with 867 additions and 248 deletions
-219
View File
@@ -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": {
-2
View File
@@ -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": [
+7 -20
View File
@@ -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
@@ -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<WebSocketConstructor | null> {
// 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<string, unknown>) {
let _opts: Record<string, unknown> = {};
+416
View File
@@ -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<net.Socket> {
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<ProxyConnectResponse> {
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<string, string> = {
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<tls.TLSSocket> {
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),
};
}
+122
View File
@@ -0,0 +1,122 @@
/*
* Adapted from proxy-from-env.
*
* The MIT License
*
* Copyright (C) 2016-2018 Rob Wu <rob@robwu.nl>
*
* 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<string, number> = {
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_<scheme>_proxy, <scheme>_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);
}
+244
View File
@@ -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<http.Server | https.Server> = [];
const sockets = new Set<net.Socket>();
async function listen(server: http.Server | https.Server): Promise<number> {
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<void> {
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<string> {
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();
});
});
+76
View File
@@ -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<string, string | undefined>();
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<string, string>): 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");
});
});