diff --git a/docs/reference/troubleshooting.mdx b/docs/reference/troubleshooting.mdx index 326f13b5f43..bd7f35dcbba 100644 --- a/docs/reference/troubleshooting.mdx +++ b/docs/reference/troubleshooting.mdx @@ -1028,8 +1028,9 @@ $$nemoclaw onboard These are build-time settings baked into the sandbox image. Changing them after onboarding requires re-running `$$nemoclaw onboard` to rebuild the image. -When `HTTP_PROXY` or `HTTPS_PROXY` is set on the host, NemoClaw adds `localhost` and `127.0.0.1` to `NO_PROXY` for managed subprocesses. -This keeps local Ollama health checks and model pulls from being routed through a corporate or desktop proxy while preserving the proxy for external hosts. +When `HTTP_PROXY` or `HTTPS_PROXY` is set on the host, NemoClaw adds `localhost`, `127.0.0.1`, `::1`, `0.0.0.0`, the container-host aliases `host.docker.internal` and `host.containers.internal`, and the managed inference hostname `inference.local` to `NO_PROXY` for host-side subprocesses and for the env forwarded into `openshell sandbox create`. +This keeps local Ollama health checks, model pulls, and managed inference traffic from being chained through a corporate or desktop proxy at the sandbox-create boundary, while preserving the proxy for external hosts. +Inside the running sandbox, processes continue to use the OpenShell L7 proxy for `inference.local` so OpenShell's internal routing, DNS, and audit boundaries stay intact. ### Agent cannot reach a host-side HTTP service diff --git a/nemoclaw/src/lib/subprocess-env.ts b/nemoclaw/src/lib/subprocess-env.ts index a1819ce4292..de497710b5c 100644 --- a/nemoclaw/src/lib/subprocess-env.ts +++ b/nemoclaw/src/lib/subprocess-env.ts @@ -49,11 +49,26 @@ const ALLOWED_ENV_PREFIXES = ["LC_", "XDG_", "OPENSHELL_", "GRPC_"]; // ── Public API ───────────────────────────────────────────────── /** - * When any HTTP proxy is forwarded, ensure local host-bound traffic is not - * routed through it. Without this, tools that respect HTTP_PROXY (curl, Node.js - * http, Python requests) will tunnel loopback or WSL Windows-host requests to - * the user's proxy (e.g. Privoxy), which fails with HTTP 500. - * See: #2616 + * When any HTTP proxy is forwarded, augment NO_PROXY so the host proxy is + * never asked to forward traffic destined for the host loopback, the + * container-host aliases, or the OpenShell-managed inference hostname. + * + * Boundary: the helper covers host-side subprocesses (curl, Node.js http, + * Python requests) and the env forwarded into `openshell sandbox create + * -- env ...`. The latter is what determines whether OpenShell's L7 proxy + * chains a hostname through the host HTTP_PROXY when the host has one set + * (for example Privoxy at 127.0.0.1:8118 on macOS + Colima). Adding + * `inference.local` here is the seed that keeps OpenShell-internal + * inference traffic off the host proxy chain. + * + * The sandbox runtime's own NO_PROXY is set later by + * `scripts/nemoclaw-start.sh` against the OpenShell L7 proxy address and + * intentionally does not include `inference.local`, which is orthogonal + * to this seed and unaffected by the augmentation. + * + * Removal condition: when OpenShell's host-side proxy chaining no longer + * consults the caller's NO_PROXY for sandbox-create env decisions, this + * augmentation can be dropped. */ export function withLocalNoProxy(env: Record): void { const hasProxy = env.HTTP_PROXY || env.HTTPS_PROXY || env.http_proxy || env.https_proxy; @@ -65,7 +80,15 @@ export function withLocalNoProxy(env: Record): void { .map((s) => s.trim()) .filter(Boolean); let changed = false; - for (const host of ["localhost", "127.0.0.1", "host.docker.internal", "::1", "0.0.0.0"]) { + for (const host of [ + "localhost", + "127.0.0.1", + "host.docker.internal", + "host.containers.internal", + "::1", + "0.0.0.0", + "inference.local", + ]) { if (!parts.includes(host)) { parts.push(host); changed = true; diff --git a/src/lib/onboard/http-proxy-preflight.test.ts b/src/lib/onboard/http-proxy-preflight.test.ts index 80cee3c6538..06cd8d7a289 100644 --- a/src/lib/onboard/http-proxy-preflight.test.ts +++ b/src/lib/onboard/http-proxy-preflight.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest"; import { redactProxyCredentials, warnIfHostProxyMissesLoopback } from "./http-proxy-preflight"; -describe("redactProxyCredentials (#2616)", () => { +describe("redactProxyCredentials", () => { it("returns plain proxy URLs unchanged", () => { expect(redactProxyCredentials("http://127.0.0.1:8118")).toBe("http://127.0.0.1:8118"); expect(redactProxyCredentials("http://corp-proxy.example.com:3128")).toBe( @@ -35,7 +35,7 @@ describe("redactProxyCredentials (#2616)", () => { }); }); -describe("warnIfHostProxyMissesLoopback (#2616)", () => { +describe("warnIfHostProxyMissesLoopback", () => { it("does not warn when no HTTP_PROXY is set", () => { const lines: string[] = []; const fired = warnIfHostProxyMissesLoopback({}, (line) => lines.push(line)); @@ -43,37 +43,50 @@ describe("warnIfHostProxyMissesLoopback (#2616)", () => { expect(lines).toEqual([]); }); - it("does not warn when NO_PROXY already includes localhost", () => { + it("does not warn when NO_PROXY includes loopback and the managed inference hostname", () => { const lines: string[] = []; const fired = warnIfHostProxyMissesLoopback( - { http_proxy: "http://127.0.0.1:8118", NO_PROXY: "localhost,127.0.0.1" }, + { + http_proxy: "http://127.0.0.1:8118", + NO_PROXY: "localhost,127.0.0.1,inference.local", + }, (line) => lines.push(line), ); expect(fired).toBe(false); expect(lines).toEqual([]); }); - it("warns when NO_PROXY only has localhost (127.0.0.1 still proxied) (CodeRabbit #3801)", () => { + it("warns when NO_PROXY has loopback but is missing the managed inference hostname", () => { + const lines: string[] = []; + const fired = warnIfHostProxyMissesLoopback( + { http_proxy: "http://127.0.0.1:8118", NO_PROXY: "localhost,127.0.0.1" }, + (line) => lines.push(line), + ); + expect(fired).toBe(true); + expect(lines.join("\n")).toContain("inference.local"); + }); + + it("warns when NO_PROXY only has localhost (127.0.0.1 still proxied)", () => { const lines: string[] = []; const fired = warnIfHostProxyMissesLoopback( { http_proxy: "http://127.0.0.1:8118", NO_PROXY: "localhost" }, (line) => lines.push(line), ); expect(fired).toBe(true); - expect(lines.join("\n")).toContain("export NO_PROXY=localhost,127.0.0.1"); + expect(lines.join("\n")).toContain("export NO_PROXY=localhost,127.0.0.1,inference.local"); }); - it("warns when NO_PROXY only has 127.0.0.1 (localhost still proxied) (CodeRabbit #3801)", () => { + it("warns when NO_PROXY only has 127.0.0.1 (localhost still proxied)", () => { const lines: string[] = []; const fired = warnIfHostProxyMissesLoopback( { http_proxy: "http://127.0.0.1:8118", NO_PROXY: "127.0.0.1" }, (line) => lines.push(line), ); expect(fired).toBe(true); - expect(lines.join("\n")).toContain("export NO_PROXY=localhost,127.0.0.1"); + expect(lines.join("\n")).toContain("export NO_PROXY=localhost,127.0.0.1,inference.local"); }); - it("warns when HTTP_PROXY is set without NO_PROXY=localhost", () => { + it("warns when HTTP_PROXY is set without NO_PROXY", () => { const lines: string[] = []; const fired = warnIfHostProxyMissesLoopback( { http_proxy: "http://127.0.0.1:8118" }, @@ -82,10 +95,10 @@ describe("warnIfHostProxyMissesLoopback (#2616)", () => { expect(fired).toBe(true); expect(lines.join("\n")).toContain("HTTP_PROXY/http_proxy is set"); expect(lines.join("\n")).toContain("Detected proxy: http://127.0.0.1:8118"); - expect(lines.join("\n")).toContain("export NO_PROXY=localhost,127.0.0.1"); + expect(lines.join("\n")).toContain("export NO_PROXY=localhost,127.0.0.1,inference.local"); }); - it("redacts credentials in the proxy URL it logs (CodeRabbit #3801)", () => { + it("redacts credentials in the proxy URL it logs", () => { const lines: string[] = []; warnIfHostProxyMissesLoopback( { http_proxy: "http://alice:s3cret@proxy.example.com:3128" }, @@ -107,4 +120,15 @@ describe("warnIfHostProxyMissesLoopback (#2616)", () => { expect(fired).toBe(true); expect(lines.join("\n")).toContain("corp-proxy:3128"); }); + + it("surfaces the managed inference hostname in the suggested NO_PROXY export", () => { + const lines: string[] = []; + warnIfHostProxyMissesLoopback({ http_proxy: "http://127.0.0.1:8118" }, (line) => + lines.push(line), + ); + const joined = lines.join("\n"); + expect(joined).toContain("inference.local"); + expect(joined).toContain("export NO_PROXY=localhost,127.0.0.1,inference.local"); + expect(joined).toContain("export no_proxy=localhost,127.0.0.1,inference.local"); + }); }); diff --git a/src/lib/onboard/http-proxy-preflight.ts b/src/lib/onboard/http-proxy-preflight.ts index 1a068de178b..11538a582b2 100644 --- a/src/lib/onboard/http-proxy-preflight.ts +++ b/src/lib/onboard/http-proxy-preflight.ts @@ -3,7 +3,7 @@ /** * Preflight warning when the user's shell has HTTP_PROXY set without a - * NO_PROXY=localhost,127.0.0.1 bypass. See #2616. + * NO_PROXY bypass for loopback and the managed inference hostname. * * NemoClaw's own subprocess spawn helpers (`buildSubprocessEnv`) inject * NO_PROXY for loopback hosts, so NemoClaw-managed processes are safe. But @@ -19,19 +19,25 @@ export function warnIfHostProxyMissesLoopback( const proxyEnv = env.HTTP_PROXY || env.http_proxy; if (!proxyEnv) return false; const noProxyEnv = env.NO_PROXY || env.no_proxy || ""; - // Require BOTH entries — HTTP libraries match the literal hostname against - // NO_PROXY, so `NO_PROXY=localhost` alone still proxies `127.0.0.1` requests - // (and vice versa). Only suppress the warning when both are present. + // Require all three entries — HTTP libraries match the literal hostname + // against NO_PROXY, so partial coverage still proxies the missing entries. + // Suppress the warning only when localhost, 127.0.0.1, and the managed + // inference hostname are all present. const hasLocalhost = /(^|,)\s*localhost\s*(,|$)/.test(noProxyEnv); const hasLoopback = /(^|,)\s*127\.0\.0\.1\s*(,|$)/.test(noProxyEnv); - if (hasLocalhost && hasLoopback) return false; - warn(" ⚠ HTTP_PROXY/http_proxy is set without NO_PROXY=localhost,127.0.0.1."); + const hasInference = /(^|,)\s*inference\.local\s*(,|$)/.test(noProxyEnv); + if (hasLocalhost && hasLoopback && hasInference) return false; + warn( + " ⚠ HTTP_PROXY/http_proxy is set without NO_PROXY=localhost,127.0.0.1,inference.local.", + ); warn(` Detected proxy: ${redactProxyCredentials(proxyEnv)}`); - warn(" NemoClaw injects NO_PROXY for its own subprocess spawns, but any tool you run"); - warn(" that respects HTTP_PROXY (curl, Node fetch, Python requests) will still tunnel"); - warn(" localhost traffic through your host proxy. To bypass loopback (see #2616):"); - warn(" export NO_PROXY=localhost,127.0.0.1"); - warn(" export no_proxy=localhost,127.0.0.1"); + warn(" NemoClaw injects NO_PROXY for its own subprocess spawns (loopback hosts,"); + warn(" container-host aliases, and the managed inference hostname inference.local),"); + warn(" but any tool you run that respects HTTP_PROXY (curl, Node fetch, Python"); + warn(" requests) will still tunnel localhost traffic through your host proxy."); + warn(" To bypass loopback and the managed inference hostname:"); + warn(" export NO_PROXY=localhost,127.0.0.1,inference.local"); + warn(" export no_proxy=localhost,127.0.0.1,inference.local"); return true; } diff --git a/src/lib/subprocess-env.test.ts b/src/lib/subprocess-env.test.ts index 02abf5c65f7..0b5dc239a65 100644 --- a/src/lib/subprocess-env.test.ts +++ b/src/lib/subprocess-env.test.ts @@ -4,7 +4,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { withLocalNoProxy } from "../../dist/lib/subprocess-env"; -const LOCAL_NO_PROXY = "localhost,127.0.0.1,host.docker.internal,::1,0.0.0.0"; +const LOCAL_NO_PROXY = + "localhost,127.0.0.1,host.docker.internal,host.containers.internal,::1,0.0.0.0,inference.local"; describe("withLocalNoProxy", () => { it("does nothing when no proxy vars are present", () => { @@ -62,8 +63,12 @@ describe("withLocalNoProxy", () => { no_proxy: "example.com,localhost", }; withLocalNoProxy(env); - expect(env.NO_PROXY).toBe("example.com,localhost,127.0.0.1,host.docker.internal,::1,0.0.0.0"); - expect(env.no_proxy).toBe("example.com,localhost,127.0.0.1,host.docker.internal,::1,0.0.0.0"); + expect(env.NO_PROXY).toBe( + "example.com,localhost,127.0.0.1,host.docker.internal,host.containers.internal,::1,0.0.0.0,inference.local", + ); + expect(env.no_proxy).toBe( + "example.com,localhost,127.0.0.1,host.docker.internal,host.containers.internal,::1,0.0.0.0,inference.local", + ); }); it("does not duplicate entries when all local hosts are already present", () => { @@ -87,6 +92,49 @@ describe("withLocalNoProxy", () => { expect(env.NO_PROXY).toBe(`corp.internal,.nvidia.com,${LOCAL_NO_PROXY}`); expect(env.no_proxy).toBe(`corp.internal,.nvidia.com,${LOCAL_NO_PROXY}`); }); + + it("bypasses the host proxy for the managed inference hostname when HTTP_PROXY is set", () => { + const env: Record = { HTTP_PROXY: "http://127.0.0.1:8118" }; + withLocalNoProxy(env); + expect(env.NO_PROXY?.split(",")).toContain("inference.local"); + expect(env.no_proxy?.split(",")).toContain("inference.local"); + }); + + it("bypasses the host proxy for the rootless container host alias when HTTPS_PROXY is set", () => { + const env: Record = { HTTPS_PROXY: "http://127.0.0.1:8118" }; + withLocalNoProxy(env); + expect(env.NO_PROXY?.split(",")).toContain("host.containers.internal"); + expect(env.no_proxy?.split(",")).toContain("host.containers.internal"); + }); + + it("does not inject a broad .local suffix or arbitrary *.local hostnames", () => { + const env: Record = { HTTP_PROXY: "http://127.0.0.1:8118" }; + withLocalNoProxy(env); + for (const key of ["NO_PROXY", "no_proxy"] as const) { + const parts = (env[key] ?? "").split(","); + expect(parts).not.toContain(".local"); + expect(parts).not.toContain("*.local"); + expect(parts).not.toContain("evil.local"); + expect(parts).not.toContain("attacker.local"); + expect(parts.filter((p) => p.endsWith(".local"))).toEqual(["inference.local"]); + } + }); + + it("preserves a caller-provided .local entry without expanding the bypass", () => { + const env: Record = { + HTTP_PROXY: "http://127.0.0.1:8118", + NO_PROXY: "trusted.local", + no_proxy: "trusted.local", + }; + withLocalNoProxy(env); + for (const key of ["NO_PROXY", "no_proxy"] as const) { + const parts = (env[key] ?? "").split(","); + expect(parts).toContain("trusted.local"); + expect(parts).toContain("inference.local"); + expect(parts).not.toContain(".local"); + expect(parts).not.toContain("*.local"); + } + }); }); describe("buildSubprocessEnv NO_PROXY injection", () => { diff --git a/src/lib/subprocess-env.ts b/src/lib/subprocess-env.ts index a829a0a66c0..0ff8b59bdc5 100644 --- a/src/lib/subprocess-env.ts +++ b/src/lib/subprocess-env.ts @@ -49,20 +49,46 @@ const ALLOWED_ENV_PREFIXES = ["LC_", "XDG_", "OPENSHELL_", "GRPC_"]; // ── Public API ───────────────────────────────────────────────── /** - * When any HTTP proxy is forwarded, ensure local host-bound traffic is not - * routed through it. Without this, tools that respect HTTP_PROXY (curl, Node.js - * http, Python requests) will tunnel loopback or WSL Windows-host requests to - * the user's proxy (e.g. Privoxy), which fails with HTTP 500. - * See: #2616 + * When any HTTP proxy is forwarded, augment NO_PROXY so the host proxy is + * never asked to forward traffic destined for the host loopback, the + * container-host aliases, or the OpenShell-managed inference hostname. + * + * Boundary: the helper covers host-side subprocesses (curl, Node.js http, + * Python requests) and the env forwarded into `openshell sandbox create + * -- env ...`. The latter is what determines whether OpenShell's L7 proxy + * chains a hostname through the host HTTP_PROXY when the host has one set + * (for example Privoxy at 127.0.0.1:8118 on macOS + Colima). Adding + * `inference.local` here is the seed that keeps OpenShell-internal + * inference traffic off the host proxy chain. + * + * The sandbox runtime's own NO_PROXY is set later by + * `scripts/nemoclaw-start.sh` against the OpenShell L7 proxy address and + * intentionally does not include `inference.local`, which is orthogonal + * to this seed and unaffected by the augmentation. + * + * Removal condition: when OpenShell's host-side proxy chaining no longer + * consults the caller's NO_PROXY for sandbox-create env decisions, this + * augmentation can be dropped. */ 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.split(",").map((s) => s.trim()).filter(Boolean); + const parts = current + .split(",") + .map((s) => s.trim()) + .filter(Boolean); let changed = false; - for (const host of ["localhost", "127.0.0.1", "host.docker.internal", "::1", "0.0.0.0"]) { + for (const host of [ + "localhost", + "127.0.0.1", + "host.docker.internal", + "host.containers.internal", + "::1", + "0.0.0.0", + "inference.local", + ]) { if (!parts.includes(host)) { parts.push(host); changed = true; diff --git a/test/credential-exposure.test.ts b/test/credential-exposure.test.ts index a77d163476a..f1189341f81 100644 --- a/test/credential-exposure.test.ts +++ b/test/credential-exposure.test.ts @@ -112,8 +112,12 @@ describe("credential exposure in process arguments", () => { withLocalNoProxy(env); - expect(env.NO_PROXY).toBe("corp.internal,localhost,127.0.0.1,host.docker.internal,::1,0.0.0.0"); - expect(env.no_proxy).toBe("corp.internal,localhost,127.0.0.1,host.docker.internal,::1,0.0.0.0"); + expect(env.NO_PROXY).toBe( + "corp.internal,localhost,127.0.0.1,host.docker.internal,host.containers.internal,::1,0.0.0.0,inference.local", + ); + expect(env.no_proxy).toBe( + "corp.internal,localhost,127.0.0.1,host.docker.internal,host.containers.internal,::1,0.0.0.0,inference.local", + ); } }); diff --git a/test/host-proxy-inference-local-e2e.test.ts b/test/host-proxy-inference-local-e2e.test.ts new file mode 100644 index 00000000000..51399992a5e --- /dev/null +++ b/test/host-proxy-inference-local-e2e.test.ts @@ -0,0 +1,166 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { execFileSync, spawn } from "node:child_process"; +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { buildSubprocessEnv } from "../dist/lib/subprocess-env"; + +function runCurl( + args: string[], + env: NodeJS.ProcessEnv, +): Promise<{ status: number | null; stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + const child = spawn("curl", args, { env }); + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + child.stdout.on("data", (c) => stdoutChunks.push(c)); + child.stderr.on("data", (c) => stderrChunks.push(c)); + child.on("error", reject); + child.on("close", (status) => { + resolve({ + status, + stdout: Buffer.concat(stdoutChunks).toString("utf-8"), + stderr: Buffer.concat(stderrChunks).toString("utf-8"), + }); + }); + }); +} + +function curlAvailable(): boolean { + try { + execFileSync("curl", ["--version"], { stdio: "pipe" }); + return true; + } catch { + return false; + } +} + +const curlOk = curlAvailable(); +if (!curlOk && process.env.CI === "true") { + throw new Error( + "[host-proxy-inference-local-e2e] CI=true but curl unavailable. " + + "This test must not silently skip in CI — install curl on the runner.", + ); +} + +// Boundary: this E2E proves that the env produced by `buildSubprocessEnv()` +// causes a host-side `curl` to reach `inference.local` directly when a host +// HTTP proxy is set, exercising the seed list `withLocalNoProxy()` injects. +// The test is run against a deliberately unreachable proxy (127.0.0.1:1) +// so that the negative control case fails fast when the bypass is absent, +// and the positive case proves that `no_proxy` (lowercase, the form curl +// honours for plain http:// URLs) is responsible for routing the request +// directly to the local listener. +// +// The full sandbox path on macOS + Colima (where OpenShell's L7 proxy +// chains through the host HTTP_PROXY and must bypass for `inference.local`) +// requires a macOS + Colima runner and is not covered here. +describe("inference.local bypass via host NO_PROXY seed", () => { + const saved: Record = {}; + let server: http.Server; + let port: number; + let received: { url: string | undefined; host: string | undefined }[]; + + const curlArgs = () => [ + "-sS", + "--max-time", + "5", + "--resolve", + `inference.local:${port}:127.0.0.1`, + `http://inference.local:${port}/v1/chat/completions`, + ]; + + const stripInferenceLocal = (env: Record) => { + for (const key of ["NO_PROXY", "no_proxy"] as const) { + const cur = env[key] ?? ""; + env[key] = cur + .split(",") + .map((p) => p.trim()) + .filter((p) => p && p !== "inference.local") + .join(","); + } + }; + + beforeEach(async () => { + received = []; + server = http.createServer((req, res) => { + received.push({ url: req.url, host: req.headers.host }); + res.writeHead(200, { "content-type": "text/plain" }); + res.end("inference-local-direct"); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolve()); + }); + const addr = server.address() as AddressInfo | null; + if (!addr) throw new Error("listener address unavailable"); + port = addr.port; + + for (const key of [ + "HTTP_PROXY", + "HTTPS_PROXY", + "NO_PROXY", + "http_proxy", + "https_proxy", + "no_proxy", + ]) { + saved[key] = process.env[key]; + } + // Set both cases — curl honours lowercase `http_proxy` for http:// URLs + // and uppercase HTTPS_PROXY for https:// URLs. Pointing both at a + // deliberately unreachable address (127.0.0.1:1, refused) ensures a + // proxied request fails fast. + process.env.HTTP_PROXY = "http://127.0.0.1:1"; + process.env.HTTPS_PROXY = "http://127.0.0.1:1"; + process.env.http_proxy = "http://127.0.0.1:1"; + process.env.https_proxy = "http://127.0.0.1:1"; + delete process.env.NO_PROXY; + delete process.env.no_proxy; + }); + + afterEach(async () => { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + await new Promise((resolve) => server.close(() => resolve())); + }); + + it.skipIf(!curlOk)( + "negative control: without inference.local in no_proxy, curl is routed through the broken proxy and the listener never sees the request", + async () => { + const env = buildSubprocessEnv(); + stripInferenceLocal(env); + expect(env.NO_PROXY?.split(",")).not.toContain("inference.local"); + expect(env.no_proxy?.split(",")).not.toContain("inference.local"); + + const result = await runCurl(curlArgs(), env); + + expect( + result.status, + `curl should fail when routed through the broken proxy; stderr: ${result.stderr}`, + ).not.toBe(0); + expect(received).toHaveLength(0); + }, + ); + + it.skipIf(!curlOk)( + "positive: subprocess env carries inference.local in no_proxy so curl bypasses the broken proxy and reaches the listener", + async () => { + const env = buildSubprocessEnv(); + expect(env.NO_PROXY?.split(",")).toContain("inference.local"); + expect(env.no_proxy?.split(",")).toContain("inference.local"); + + const result = await runCurl(curlArgs(), env); + + expect(result.status, `curl exit ${result.status}, stderr: ${result.stderr}`).toBe(0); + expect(result.stdout).toBe("inference-local-direct"); + expect(received).toHaveLength(1); + expect(received[0]?.url).toBe("/v1/chat/completions"); + expect(received[0]?.host).toBe(`inference.local:${port}`); + }, + ); +}); diff --git a/test/onboard.test.ts b/test/onboard.test.ts index 761a9ad198e..bc9af37d668 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -173,6 +173,34 @@ describe("onboard helpers", () => { } }); + it("seeds inference.local and host.containers.internal into the sandbox-create NO_PROXY/no_proxy", () => { + // Boundary pin: appendHostProxyEnvArgs() forwards env into `openshell + // sandbox create -- env ...`, and OpenShell consults the seeded + // NO_PROXY at sandbox-create time when deciding whether to chain its + // L7 proxy through the host HTTP_PROXY for a given hostname. Both + // `inference.local` (OpenShell-managed inference) and + // `host.containers.internal` (rootless container host alias) must be + // emitted here so the L7 proxy never tunnels them through the host + // proxy. The complementary runtime exclusion (nemoclaw-start.sh sets a + // narrower NO_PROXY without inference.local once sandbox boots) is + // asserted in test/service-env.test.ts. + const envArgs: string[] = []; + + appendHostProxyEnvArgs(envArgs, { + HTTP_PROXY: "http://127.0.0.1:8118", + }); + + const upper = envArgs.find((e) => e.startsWith("NO_PROXY=")); + const lower = envArgs.find((e) => e.startsWith("no_proxy=")); + expect(upper, "NO_PROXY should be synthesized").toBeDefined(); + expect(lower, "no_proxy should be synthesized").toBeDefined(); + for (const v of [upper, lower]) { + const parts = (v ?? "").split("=")[1]?.split(",") ?? []; + expect(parts).toContain("inference.local"); + expect(parts).toContain("host.containers.internal"); + } + }); + it("propagates NEMOCLAW_MINIMAL_BOOTSTRAP=1 from host into sandbox env (#2598)", () => { const envArgs: string[] = []; appendHostProxyEnvArgs(envArgs, { NEMOCLAW_MINIMAL_BOOTSTRAP: "1" });