import * as net from "node:net"; import * as tls from "node:tls"; import * as AIError from "../error"; import type { FetchImpl } from "../types"; /** * Checks if a host is local or cloud metadata, which should always bypass the proxy * (e.g. localhost, 127/8, ::1, 169.254.169.254, metadata.google.internal). */ export function isLocalOrMetadataHost(host: string): boolean { const lowerHost = host.toLowerCase(); // Hostnames: localhost and the cloud metadata service. if (lowerHost === "localhost" || lowerHost.endsWith(".localhost") || lowerHost === "metadata.google.internal") { return true; } // Strip IPv6 brackets before numeric checks. const ip = lowerHost.replace(/^\[|\]$/g, ""); // IPv4 loopback (127/8), unspecified (0/8), RFC1918 private (10/8, 172.16/12, // 192.168/16) and link-local (169.254/16 — covers IMDS 169.254.169.254 and // ECS credentials 169.254.170.2). None are reachable through a remote egress // proxy, and credential/metadata probes must never leak to one. const v4 = ip.match(/^(\d{1,3})\.(\d{1,3})\.\d{1,3}\.\d{1,3}$/); if (v4) { const a = Number(v4[1]); const b = Number(v4[2]); if (a === 127 || a === 10 || a === 0) return true; if (a === 169 && b === 254) return true; if (a === 192 && b === 168) return true; if (a === 172 && b >= 16 && b <= 31) return true; return false; } // IPv6 loopback (::1), unspecified (::), link-local (fe80::/10) and // unique-local (fc00::/7 — covers EC2 IPv6 IMDS fd00:ec2::254). if (ip === "::1" || ip === "::") return true; if (/^fe[89ab][0-9a-f]:/.test(ip)) return true; if (/^f[cd][0-9a-f]{2}:/.test(ip)) return true; return false; } /** * Check if the url should bypass the proxy due to hard-coded localhost/metadata checks * or custom NO_PROXY/no_proxy environment variables rules. */ export function shouldBypassProxy(urlObj: URL): boolean { if (isLocalOrMetadataHost(urlObj.hostname)) { return true; } const noProxyVal = Bun.env.NO_PROXY || Bun.env.no_proxy; if (!noProxyVal) { return false; } const rules = noProxyVal .split(/[,\s]+/) .map(r => r.trim()) .filter(Boolean); const targetHost = urlObj.hostname.toLowerCase(); const targetPort = urlObj.port || (urlObj.protocol === "https:" ? "443" : "80"); for (const rule of rules) { if (rule === "*") { return true; } let ruleHost = rule.toLowerCase(); let rulePort: string | undefined; if (ruleHost.includes("]:")) { const lastColon = ruleHost.lastIndexOf(":"); rulePort = ruleHost.slice(lastColon + 1); ruleHost = ruleHost.slice(0, lastColon); } else if (!ruleHost.includes("]") && ruleHost.includes(":")) { const lastColon = ruleHost.lastIndexOf(":"); rulePort = ruleHost.slice(lastColon + 1); ruleHost = ruleHost.slice(0, lastColon); } // Strip IPv6 brackets ruleHost = ruleHost.replace(/^\[|\]$/g, ""); if (rulePort && rulePort !== targetPort) { continue; } // Match host part if (ruleHost.startsWith(".")) { const suffix = ruleHost; const cleanRule = ruleHost.slice(1); if (targetHost === cleanRule || targetHost.endsWith(suffix)) { return true; } } else { if (targetHost === ruleHost || targetHost.endsWith(`.${ruleHost}`)) { return true; } } } return false; } const proxyCache = new Map(); /** Test seam: clears the provider proxy cache. */ export function __resetProxyCache(): void { proxyCache.clear(); } /** * Normalizes provider id (e.g. github-copilot -> PI_PROXY_GITHUB_COPILOT) and looks it up. * If not found, falls back to PI_PROXY. Results are memoized because env values are static * for the lifetime of the process and this function is called for every outgoing request. */ export function getProxyForProvider(provider: string): string | undefined { if (proxyCache.has(provider)) { return proxyCache.get(provider); } const normalized = provider.toUpperCase().replace(/[^A-Z0-9]/g, "_"); const envKey = `PI_PROXY_${normalized}`; const value = Bun.env[envKey] || Bun.env.PI_PROXY; proxyCache.set(provider, value); return value; } /** * Wraps a fetch implementation to inject proxy options for non-local hosts. */ export function wrapFetchForProxy(fetchImpl: FetchImpl, provider: string): FetchImpl { const proxyUrl = getProxyForProvider(provider); if (!proxyUrl) { return fetchImpl; } const wrapped = async (input: string | URL | Request, init?: RequestInit): Promise => { const urlStr = input instanceof Request ? input.url : input.toString(); let urlObj: URL; try { urlObj = new URL(urlStr); } catch { // Fallback to calling fetch unmodified if URL is unparseable return fetchImpl(input, init); } if (shouldBypassProxy(urlObj)) { return fetchImpl(input, init); } const mergedInit = { ...(init ?? {}), proxy: proxyUrl }; return fetchImpl(input, mergedInit); }; if (fetchImpl.preconnect) { wrapped.preconnect = fetchImpl.preconnect; } return wrapped; } export interface ConnectProxiedSocketOptions { /** Caller cancellation for the proxy TCP/TLS handshake and CONNECT tunnel. */ signal?: AbortSignal; /** Maximum wall-clock time to establish the final TLS tunnel. Disabled when absent or non-positive. */ timeoutMs?: number; } /** * Tunnel a socket connection through an HTTP CONNECT proxy. * This is used specifically to wrap Node's `http2.connect(baseUrl, { createConnection })` for Cursor. */ export async function connectProxiedSocket( proxyUrlStr: string, targetUrlStr: string, options?: ConnectProxiedSocketOptions, ): Promise { if (options?.signal?.aborted) { throw new AIError.AbortError("Proxy tunnel aborted"); } const proxyUrl = new URL(proxyUrlStr); const targetUrl = new URL(targetUrlStr); const useProxySsl = proxyUrl.protocol === "https:"; const proxyPort = proxyUrl.port ? parseInt(proxyUrl.port, 10) : useProxySsl ? 443 : 80; const proxyHost = proxyUrl.hostname; const targetPort = targetUrl.port ? parseInt(targetUrl.port, 10) : 443; const targetHost = targetUrl.hostname; const { promise, resolve, reject } = Promise.withResolvers(); const readyEvent = useProxySsl ? "secureConnect" : "connect"; let rawSocket: net.Socket | undefined; let tunnelSocket: tls.TLSSocket | undefined; let timeout: NodeJS.Timeout | undefined; let responseData = ""; let settled = false; const cleanup = (): void => { if (timeout) { clearTimeout(timeout); timeout = undefined; } options?.signal?.removeEventListener("abort", onAbort); rawSocket?.off("error", onRawError); rawSocket?.off(readyEvent, onProxyReady); rawSocket?.off("data", onProxyData); tunnelSocket?.off("secureConnect", onTunnelReady); tunnelSocket?.off("error", onTunnelError); }; const destroyInProgress = (): void => { tunnelSocket?.destroy(); rawSocket?.destroy(); }; const rejectOnce = (error: Error): void => { if (settled) return; settled = true; cleanup(); destroyInProgress(); reject(error); }; const resolveOnce = (socket: tls.TLSSocket): void => { if (settled) return; settled = true; cleanup(); resolve(socket); }; const onAbort = (): void => rejectOnce(new AIError.AbortError("Proxy tunnel aborted")); const onRawError = (error: Error): void => rejectOnce(error); const onTunnelError = (error: Error): void => rejectOnce(error); const onTunnelReady = (): void => { if (!tunnelSocket) return; resolveOnce(tunnelSocket); }; const onProxyData = (chunk: Buffer): void => { if (!rawSocket) return; responseData += chunk.toString("binary"); if (!responseData.includes("\r\n\r\n")) return; rawSocket.off("data", onProxyData); rawSocket.off("error", onRawError); const firstLine = responseData.split("\r\n")[0]; if (!firstLine.includes(" 200 ")) { rejectOnce(new AIError.ValidationError(`Proxy tunnel failed: ${firstLine}`)); return; } tunnelSocket = tls.connect({ socket: rawSocket, servername: targetHost, ALPNProtocols: ["h2"], }); tunnelSocket.once("secureConnect", onTunnelReady); tunnelSocket.once("error", onTunnelError); }; const onProxyReady = (): void => { if (!rawSocket) return; let connectReq = `CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\n` + `Host: ${targetHost}:${targetPort}\r\n`; if (proxyUrl.username || proxyUrl.password) { const creds = Buffer.from( `${decodeURIComponent(proxyUrl.username)}:${decodeURIComponent(proxyUrl.password)}`, ).toString("base64"); connectReq += `Proxy-Authorization: Basic ${creds}\r\n`; } connectReq += "\r\n"; rawSocket.write(connectReq); rawSocket.on("data", onProxyData); }; options?.signal?.addEventListener("abort", onAbort, { once: true }); if (options?.timeoutMs !== undefined && Number.isFinite(options.timeoutMs) && options.timeoutMs > 0) { const timeoutMs = Math.trunc(options.timeoutMs); timeout = setTimeout(() => { rejectOnce(new AIError.StreamTimeoutError(`Proxy tunnel timed out after ${timeoutMs}ms`)); }, timeoutMs); timeout.unref?.(); } rawSocket = useProxySsl ? tls.connect({ host: proxyHost, port: proxyPort, }) : net.connect({ host: proxyHost, port: proxyPort, }); rawSocket.once("error", onRawError); rawSocket.once(readyEvent, onProxyReady); return promise; }