Skip to content
5 changes: 3 additions & 2 deletions docs/reference/troubleshooting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
35 changes: 29 additions & 6 deletions nemoclaw/src/lib/subprocess-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>): void {
const hasProxy = env.HTTP_PROXY || env.HTTPS_PROXY || env.http_proxy || env.https_proxy;
Expand All @@ -65,7 +80,15 @@ export function withLocalNoProxy(env: Record<string, string>): 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;
Expand Down
46 changes: 35 additions & 11 deletions src/lib/onboard/http-proxy-preflight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -35,45 +35,58 @@ 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));
expect(fired).toBe(false);
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" },
Expand All @@ -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" },
Expand All @@ -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");
});
});
28 changes: 17 additions & 11 deletions src/lib/onboard/http-proxy-preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
}

Expand Down
54 changes: 51 additions & 3 deletions src/lib/subprocess-env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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", () => {
Expand All @@ -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<string, string> = { 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<string, string> = { 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<string, string> = { 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<string, string> = {
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", () => {
Expand Down
40 changes: 33 additions & 7 deletions src/lib/subprocess-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>): 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;
Expand Down
8 changes: 6 additions & 2 deletions test/credential-exposure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
);
}
});

Expand Down
Loading
Loading