From e205888fa7a3d875c331ae4d5c172990f318902a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 28 May 2026 12:50:55 -0400 Subject: [PATCH] fix: honor ipv6 no_proxy entries --- src/llm/utils/node-http-proxy.test.ts | 42 +++++++++++++++++++++++++ src/llm/utils/node-http-proxy.ts | 45 +++++++++++++++++++++++---- 2 files changed, 81 insertions(+), 6 deletions(-) create mode 100644 src/llm/utils/node-http-proxy.test.ts diff --git a/src/llm/utils/node-http-proxy.test.ts b/src/llm/utils/node-http-proxy.test.ts new file mode 100644 index 00000000000..f806f227ac0 --- /dev/null +++ b/src/llm/utils/node-http-proxy.test.ts @@ -0,0 +1,42 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { resolveHttpProxyUrlForTarget } from "./node-http-proxy.js"; + +function clearProxyEnv(): void { + for (const key of [ + "http_proxy", + "HTTP_PROXY", + "https_proxy", + "HTTPS_PROXY", + "all_proxy", + "ALL_PROXY", + "no_proxy", + "NO_PROXY", + ]) { + vi.stubEnv(key, ""); + } +} + +describe("node HTTP proxy resolution", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("honors unbracketed IPv6 literals in NO_PROXY", () => { + clearProxyEnv(); + vi.stubEnv("HTTP_PROXY", "http://proxy.example:8080"); + vi.stubEnv("NO_PROXY", "::1"); + + expect(resolveHttpProxyUrlForTarget("http://[::1]:11434/v1")).toBeUndefined(); + }); + + it("honors bracketed IPv6 literals with matching NO_PROXY ports", () => { + clearProxyEnv(); + vi.stubEnv("HTTP_PROXY", "http://proxy.example:8080"); + vi.stubEnv("NO_PROXY", "[::1]:11434"); + + expect(resolveHttpProxyUrlForTarget("http://[::1]:11434/v1")).toBeUndefined(); + expect(resolveHttpProxyUrlForTarget("http://[::1]:11435/v1")?.href).toBe( + "http://proxy.example:8080/", + ); + }); +}); diff --git a/src/llm/utils/node-http-proxy.ts b/src/llm/utils/node-http-proxy.ts index 836725051dc..84f3d14d1aa 100644 --- a/src/llm/utils/node-http-proxy.ts +++ b/src/llm/utils/node-http-proxy.ts @@ -36,7 +36,40 @@ function parseProxyTargetUrl(targetUrl: string | URL): URL | undefined { } } +function normalizeProxyHostname(hostname: string): string { + let normalized = hostname.toLowerCase(); + if (normalized.startsWith("[") && normalized.endsWith("]")) { + normalized = normalized.slice(1, -1); + } + return normalized.endsWith(".") ? normalized.slice(0, -1) : normalized; +} + +function parseNoProxyEntry(entry: string): { hostname: string; port: number } { + const bracketed = entry.match(/^\[([^\]]+)\](?::(\d+))?$/); + if (bracketed) { + return { + hostname: normalizeProxyHostname(bracketed[1] ?? ""), + port: bracketed[2] ? Number.parseInt(bracketed[2], 10) : 0, + }; + } + + const firstColon = entry.indexOf(":"); + const lastColon = entry.lastIndexOf(":"); + if (firstColon > -1 && firstColon === lastColon) { + const portRaw = entry.slice(lastColon + 1); + if (/^\d+$/.test(portRaw)) { + return { + hostname: normalizeProxyHostname(entry.slice(0, lastColon)), + port: Number.parseInt(portRaw, 10), + }; + } + } + + return { hostname: normalizeProxyHostname(entry), port: 0 }; +} + function shouldProxyHostname(hostname: string, port: number): boolean { + const normalizedHostname = normalizeProxyHostname(hostname); const noProxy = getProxyEnv("no_proxy").toLowerCase(); if (!noProxy) { return true; @@ -50,21 +83,21 @@ function shouldProxyHostname(hostname: string, port: number): boolean { return true; } - const parsedProxy = proxy.match(/^(.+):(\d+)$/); - let proxyHostname = parsedProxy ? parsedProxy[1] : proxy; - const proxyPort = parsedProxy ? Number.parseInt(parsedProxy[2], 10) : 0; + const parsedProxy = parseNoProxyEntry(proxy); + let proxyHostname = parsedProxy.hostname; + const proxyPort = parsedProxy.port; if (proxyPort && proxyPort !== port) { return true; } if (!/^[.*]/.test(proxyHostname)) { - return hostname !== proxyHostname; + return normalizedHostname !== proxyHostname; } if (proxyHostname.startsWith("*")) { proxyHostname = proxyHostname.slice(1); } - return !hostname.endsWith(proxyHostname); + return !normalizedHostname.endsWith(proxyHostname); }); } @@ -75,7 +108,7 @@ function getProxyForUrl(targetUrl: string | URL): string { } const protocol = parsedUrl.protocol.split(":", 1)[0]; - const hostname = parsedUrl.host.replace(/:\d*$/, ""); + const hostname = parsedUrl.hostname; const port = Number.parseInt(parsedUrl.port, 10) || DEFAULT_PROXY_PORTS[protocol] || 0; if (!shouldProxyHostname(hostname, port)) { return "";