diff --git a/nemoclaw/src/lib/subprocess-env.ts b/nemoclaw/src/lib/subprocess-env.ts index 0ab7a9ad5e5..c00e633c059 100644 --- a/nemoclaw/src/lib/subprocess-env.ts +++ b/nemoclaw/src/lib/subprocess-env.ts @@ -48,6 +48,30 @@ const ALLOWED_ENV_PREFIXES = ["LC_", "XDG_", "OPENSHELL_", "GRPC_"]; // ── Public API ───────────────────────────────────────────────── +/** + * When any HTTP proxy is forwarded, ensure localhost and loopback traffic is + * not routed through it. Without this, tools that respect HTTP_PROXY (curl, + * Node.js http, Python requests) will tunnel loopback requests to the user's + * proxy (e.g. Privoxy), which fails with HTTP 500. + * See: #2616 + */ +export function withLocalNoProxy(env: Record): void { + const hasProxy = env.HTTP_PROXY || env.HTTPS_PROXY || env.http_proxy || env.https_proxy; + if (!hasProxy) return; + for (const key of ["NO_PROXY", "no_proxy"] as const) { + const current = env[key] ?? ""; + const parts = current ? current.split(",").map((s) => s.trim()) : []; + let changed = false; + for (const host of ["localhost", "127.0.0.1"]) { + if (!parts.includes(host)) { + parts.push(host); + changed = true; + } + } + if (changed) env[key] = parts.join(","); + } +} + export function buildSubprocessEnv(extra?: Record): Record { const env: Record = {}; for (const [key, value] of Object.entries(process.env)) { @@ -59,5 +83,6 @@ export function buildSubprocessEnv(extra?: Record): Record { + it("does nothing when no proxy vars are present", () => { + const env: Record = { PATH: "/usr/bin" }; + withLocalNoProxy(env); + expect(env).toEqual({ PATH: "/usr/bin" }); + }); + + it("adds localhost and 127.0.0.1 to NO_PROXY and no_proxy when HTTP_PROXY is set and NO_PROXY is absent", () => { + const env: Record = { HTTP_PROXY: "http://proxy:8888" }; + withLocalNoProxy(env); + expect(env.NO_PROXY).toBe("localhost,127.0.0.1"); + expect(env.no_proxy).toBe("localhost,127.0.0.1"); + }); + + it("adds localhost and 127.0.0.1 when HTTPS_PROXY is set", () => { + const env: Record = { HTTPS_PROXY: "http://proxy:8888" }; + withLocalNoProxy(env); + expect(env.NO_PROXY).toBe("localhost,127.0.0.1"); + expect(env.no_proxy).toBe("localhost,127.0.0.1"); + }); + + it("adds localhost and 127.0.0.1 when lowercase http_proxy is set", () => { + const env: Record = { http_proxy: "http://proxy:8888" }; + withLocalNoProxy(env); + expect(env.NO_PROXY).toBe("localhost,127.0.0.1"); + expect(env.no_proxy).toBe("localhost,127.0.0.1"); + }); + + it("appends only the missing loopback entries when NO_PROXY already has localhost", () => { + const env: Record = { + HTTP_PROXY: "http://proxy:8888", + NO_PROXY: "example.com,localhost", + no_proxy: "example.com,localhost", + }; + withLocalNoProxy(env); + expect(env.NO_PROXY).toBe("example.com,localhost,127.0.0.1"); + expect(env.no_proxy).toBe("example.com,localhost,127.0.0.1"); + }); + + it("does not duplicate entries when both loopback hosts are already present", () => { + const env: Record = { + HTTP_PROXY: "http://proxy:8888", + NO_PROXY: "localhost,127.0.0.1,corp.internal", + no_proxy: "localhost,127.0.0.1,corp.internal", + }; + withLocalNoProxy(env); + expect(env.NO_PROXY).toBe("localhost,127.0.0.1,corp.internal"); + expect(env.no_proxy).toBe("localhost,127.0.0.1,corp.internal"); + }); + + it("preserves existing NO_PROXY entries and adds loopback hosts", () => { + const env: Record = { + HTTP_PROXY: "http://proxy:8888", + NO_PROXY: "corp.internal,.nvidia.com", + no_proxy: "corp.internal,.nvidia.com", + }; + withLocalNoProxy(env); + expect(env.NO_PROXY).toBe("corp.internal,.nvidia.com,localhost,127.0.0.1"); + expect(env.no_proxy).toBe("corp.internal,.nvidia.com,localhost,127.0.0.1"); + }); +}); + +describe("buildSubprocessEnv NO_PROXY injection", () => { + const originalEnv = process.env; + + beforeEach(() => { + vi.resetModules(); + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it("injects NO_PROXY=localhost,127.0.0.1 when HTTP_PROXY is set and NO_PROXY is absent", async () => { + process.env.HTTP_PROXY = "http://proxy.example.com:8888"; + delete process.env.NO_PROXY; + delete process.env.no_proxy; + + const { buildSubprocessEnv } = await import("../../dist/lib/subprocess-env"); + const env = buildSubprocessEnv(); + expect(env.NO_PROXY).toBe("localhost,127.0.0.1"); + expect(env.no_proxy).toBe("localhost,127.0.0.1"); + }); + + it("augments an existing NO_PROXY to add loopback hosts", async () => { + process.env.HTTP_PROXY = "http://proxy.example.com:8888"; + process.env.NO_PROXY = "corp.internal"; + process.env.no_proxy = "corp.internal"; + + const { buildSubprocessEnv } = await import("../../dist/lib/subprocess-env"); + const env = buildSubprocessEnv(); + expect(env.NO_PROXY).toBe("corp.internal,localhost,127.0.0.1"); + expect(env.no_proxy).toBe("corp.internal,localhost,127.0.0.1"); + }); + + it("does not add NO_PROXY when no proxy is set", async () => { + delete process.env.HTTP_PROXY; + delete process.env.HTTPS_PROXY; + delete process.env.http_proxy; + delete process.env.https_proxy; + delete process.env.NO_PROXY; + delete process.env.no_proxy; + + const { buildSubprocessEnv } = await import("../../dist/lib/subprocess-env"); + const env = buildSubprocessEnv(); + expect(env.NO_PROXY).toBeUndefined(); + expect(env.no_proxy).toBeUndefined(); + }); + + it("extra vars passed to buildSubprocessEnv override env vars before NO_PROXY injection", async () => { + process.env.HTTP_PROXY = "http://proxy.example.com:8888"; + delete process.env.NO_PROXY; + delete process.env.no_proxy; + + const { buildSubprocessEnv } = await import("../../dist/lib/subprocess-env"); + const env = buildSubprocessEnv({ MY_TOKEN: "abc123" }); + expect(env.MY_TOKEN).toBe("abc123"); + expect(env.NO_PROXY).toBe("localhost,127.0.0.1"); + }); +}); diff --git a/src/lib/subprocess-env.ts b/src/lib/subprocess-env.ts index 8efa17117ec..e118f3b158d 100644 --- a/src/lib/subprocess-env.ts +++ b/src/lib/subprocess-env.ts @@ -48,6 +48,30 @@ const ALLOWED_ENV_PREFIXES = ["LC_", "XDG_", "OPENSHELL_", "GRPC_"]; // ── Public API ───────────────────────────────────────────────── +/** + * When any HTTP proxy is forwarded, ensure localhost and loopback traffic is + * not routed through it. Without this, tools that respect HTTP_PROXY (curl, + * Node.js http, Python requests) will tunnel loopback requests to the user's + * proxy (e.g. Privoxy), which fails with HTTP 500. + * See: #2616 + */ +export function withLocalNoProxy(env: Record): void { + const hasProxy = env.HTTP_PROXY || env.HTTPS_PROXY || env.http_proxy || env.https_proxy; + if (!hasProxy) return; + for (const key of ["NO_PROXY", "no_proxy"] as const) { + const current = env[key] ?? ""; + const parts = current ? current.split(",").map((s) => s.trim()) : []; + let changed = false; + for (const host of ["localhost", "127.0.0.1"]) { + if (!parts.includes(host)) { + parts.push(host); + changed = true; + } + } + if (changed) env[key] = parts.join(","); + } +} + export function buildSubprocessEnv(extra?: Record): Record { const env: Record = {}; for (const [key, value] of Object.entries(process.env)) { @@ -59,5 +83,6 @@ export function buildSubprocessEnv(extra?: Record): Record