diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 6472459c618..e3a3dc21116 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -205,6 +205,80 @@ PYAUTOPAIR echo "[gateway] auto-pair watcher launched (pid $!)" } +# ── Proxy environment ──────────────────────────────────────────── +# OpenShell injects HTTP_PROXY/HTTPS_PROXY/NO_PROXY into the sandbox, but its +# NO_PROXY is limited to 127.0.0.1,localhost,::1 — missing inference.local and +# the gateway IP. Without these entries, LLM inference requests are routed +# through the egress proxy instead of going direct, and the proxy gateway IP +# itself gets proxied (potential infinite loop). +# +# NEMOCLAW_PROXY_HOST / NEMOCLAW_PROXY_PORT can be overridden at sandbox +# creation time if the gateway IP or port changes in a future OpenShell release. +# Ref: https://github.com/NVIDIA/NemoClaw/issues/626 +PROXY_HOST="${NEMOCLAW_PROXY_HOST:-10.200.0.1}" +PROXY_PORT="${NEMOCLAW_PROXY_PORT:-3128}" +_PROXY_URL="http://${PROXY_HOST}:${PROXY_PORT}" +_NO_PROXY_VAL="localhost,127.0.0.1,::1,inference.local,${PROXY_HOST}" +export HTTP_PROXY="$_PROXY_URL" +export HTTPS_PROXY="$_PROXY_URL" +export NO_PROXY="$_NO_PROXY_VAL" +export http_proxy="$_PROXY_URL" +export https_proxy="$_PROXY_URL" +export no_proxy="$_NO_PROXY_VAL" + +# OpenShell re-injects narrow NO_PROXY/no_proxy=127.0.0.1,localhost,::1 every +# time a user connects via `openshell sandbox connect`. The connect path spawns +# `/bin/bash -i` (interactive, non-login), which sources ~/.bashrc — NOT +# ~/.profile or /etc/profile.d/*. Write the full proxy config to ~/.bashrc so +# interactive sessions see the correct values. +# +# Both uppercase and lowercase variants are required: Node.js undici prefers +# lowercase (no_proxy) over uppercase (NO_PROXY) when both are set. +# curl/wget use uppercase. gRPC C-core uses lowercase. +# +# Also write to ~/.profile for login-shell paths (e.g. `sandbox create -- cmd` +# which spawns `bash -lc`). +# +# Idempotency: begin/end markers delimit the block so it can be replaced +# on restart if NEMOCLAW_PROXY_HOST/PORT change, without duplicating. +_PROXY_MARKER_BEGIN="# nemoclaw-proxy-config begin" +_PROXY_MARKER_END="# nemoclaw-proxy-config end" +_PROXY_SNIPPET="${_PROXY_MARKER_BEGIN} +export HTTP_PROXY=\"$_PROXY_URL\" +export HTTPS_PROXY=\"$_PROXY_URL\" +export NO_PROXY=\"$_NO_PROXY_VAL\" +export http_proxy=\"$_PROXY_URL\" +export https_proxy=\"$_PROXY_URL\" +export no_proxy=\"$_NO_PROXY_VAL\" +${_PROXY_MARKER_END}" + +if [ "$(id -u)" -eq 0 ]; then + _SANDBOX_HOME=$(getent passwd sandbox 2>/dev/null | cut -d: -f6) + _SANDBOX_HOME="${_SANDBOX_HOME:-/sandbox}" +else + _SANDBOX_HOME="${HOME:-/sandbox}" +fi + +_write_proxy_snippet() { + local target="$1" + if [ -f "$target" ] && grep -qF "$_PROXY_MARKER_BEGIN" "$target" 2>/dev/null; then + local tmp + tmp="$(mktemp)" + awk -v b="$_PROXY_MARKER_BEGIN" -v e="$_PROXY_MARKER_END" \ + '$0==b{s=1;next} $0==e{s=0;next} !s' "$target" >"$tmp" + printf '%s\n' "$_PROXY_SNIPPET" >>"$tmp" + cat "$tmp" >"$target" + rm -f "$tmp" + return 0 + fi + printf '\n%s\n' "$_PROXY_SNIPPET" >>"$target" +} + +if [ -w "$_SANDBOX_HOME" ]; then + _write_proxy_snippet "${_SANDBOX_HOME}/.bashrc" + _write_proxy_snippet "${_SANDBOX_HOME}/.profile" +fi + # ── Main ───────────────────────────────────────────────────────── echo 'Setting up NemoClaw...' diff --git a/test/service-env.test.js b/test/service-env.test.js index b3da3131440..1edd7b15ecf 100644 --- a/test/service-env.test.js +++ b/test/service-env.test.js @@ -2,7 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, it, expect } from "vitest"; -import { execSync } from "node:child_process"; +import { execSync, execFileSync } from "node:child_process"; +import { writeFileSync, unlinkSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { resolveOpenshell } from "../bin/lib/resolve-openshell"; describe("service environment", () => { @@ -95,4 +98,236 @@ describe("service environment", () => { expect(result).toBe("default"); }); }); + + describe("proxy environment variables (issue #626)", () => { + function extractProxyVars(env = {}) { + const scriptPath = join(import.meta.dirname, "../scripts/nemoclaw-start.sh"); + const proxyBlock = execFileSync( + "sed", + ["-n", "/^PROXY_HOST=/,/^export no_proxy=/p", scriptPath], + { encoding: "utf-8" } + ); + if (!proxyBlock.trim()) { + throw new Error( + "Failed to extract proxy configuration from scripts/nemoclaw-start.sh — " + + "the PROXY_HOST..no_proxy block may have been moved or renamed" + ); + } + const wrapper = [ + "#!/usr/bin/env bash", + proxyBlock.trimEnd(), + 'echo "HTTP_PROXY=${HTTP_PROXY}"', + 'echo "HTTPS_PROXY=${HTTPS_PROXY}"', + 'echo "NO_PROXY=${NO_PROXY}"', + 'echo "http_proxy=${http_proxy}"', + 'echo "https_proxy=${https_proxy}"', + 'echo "no_proxy=${no_proxy}"', + ].join("\n"); + const tmpFile = join(tmpdir(), `nemoclaw-proxy-test-${process.pid}.sh`); + try { + writeFileSync(tmpFile, wrapper, { mode: 0o700 }); + const out = execFileSync("bash", [tmpFile], { + encoding: "utf-8", + env: { ...process.env, ...env }, + }).trim(); + return Object.fromEntries(out.split("\n").map((l) => { + const idx = l.indexOf("="); + return [l.slice(0, idx), l.slice(idx + 1)]; + })); + } finally { + try { unlinkSync(tmpFile); } catch { /* ignore */ } + } + } + + it("sets HTTP_PROXY to default gateway address", () => { + const vars = extractProxyVars(); + expect(vars.HTTP_PROXY).toBe("http://10.200.0.1:3128"); + }); + + it("sets HTTPS_PROXY to default gateway address", () => { + const vars = extractProxyVars(); + expect(vars.HTTPS_PROXY).toBe("http://10.200.0.1:3128"); + }); + + it("NEMOCLAW_PROXY_HOST overrides default gateway IP", () => { + const vars = extractProxyVars({ NEMOCLAW_PROXY_HOST: "192.168.64.1" }); + expect(vars.HTTP_PROXY).toBe("http://192.168.64.1:3128"); + expect(vars.HTTPS_PROXY).toBe("http://192.168.64.1:3128"); + }); + + it("NEMOCLAW_PROXY_PORT overrides default proxy port", () => { + const vars = extractProxyVars({ NEMOCLAW_PROXY_PORT: "8080" }); + expect(vars.HTTP_PROXY).toBe("http://10.200.0.1:8080"); + expect(vars.HTTPS_PROXY).toBe("http://10.200.0.1:8080"); + }); + + it("NO_PROXY includes loopback and inference.local", () => { + const vars = extractProxyVars(); + const noProxy = vars.NO_PROXY.split(","); + expect(noProxy).toContain("localhost"); + expect(noProxy).toContain("127.0.0.1"); + expect(noProxy).toContain("::1"); + expect(noProxy).toContain("inference.local"); + }); + + it("NO_PROXY includes OpenShell gateway IP", () => { + const vars = extractProxyVars(); + expect(vars.NO_PROXY).toContain("10.200.0.1"); + }); + + it("exports lowercase proxy variants for undici/gRPC compatibility", () => { + const vars = extractProxyVars(); + expect(vars.http_proxy).toBe("http://10.200.0.1:3128"); + expect(vars.https_proxy).toBe("http://10.200.0.1:3128"); + const noProxy = vars.no_proxy.split(","); + expect(noProxy).toContain("inference.local"); + expect(noProxy).toContain("10.200.0.1"); + }); + + it("entrypoint persistence writes proxy snippet to ~/.bashrc and ~/.profile", () => { + const fakeHome = join(tmpdir(), `nemoclaw-home-test-${process.pid}`); + execFileSync("mkdir", ["-p", fakeHome]); + const tmpFile = join(tmpdir(), `nemoclaw-bashrc-write-test-${process.pid}.sh`); + try { + const scriptPath = join(import.meta.dirname, "../scripts/nemoclaw-start.sh"); + const persistBlock = execFileSync( + "sed", + ["-n", "/^_PROXY_URL=/,/^# ── Main/{ /^# ── Main/d; p; }", scriptPath], + { encoding: "utf-8" } + ); + const wrapper = [ + "#!/usr/bin/env bash", + 'PROXY_HOST="10.200.0.1"', + 'PROXY_PORT="3128"', + persistBlock.trimEnd(), + ].join("\n"); + writeFileSync(tmpFile, wrapper, { mode: 0o700 }); + execFileSync("bash", [tmpFile], { + encoding: "utf-8", + env: { ...process.env, HOME: fakeHome }, + }); + + const bashrc = readFileSync(join(fakeHome, ".bashrc"), "utf-8"); + expect(bashrc).toContain("export HTTP_PROXY="); + expect(bashrc).toContain("export HTTPS_PROXY="); + expect(bashrc).toContain("export NO_PROXY="); + expect(bashrc).toContain("inference.local"); + expect(bashrc).toContain("10.200.0.1"); + + const profile = readFileSync(join(fakeHome, ".profile"), "utf-8"); + expect(profile).toContain("inference.local"); + } finally { + try { unlinkSync(tmpFile); } catch { /* ignore */ } + try { execFileSync("rm", ["-rf", fakeHome]); } catch { /* ignore */ } + } + }); + + it("entrypoint persistence is idempotent across repeated invocations", () => { + const fakeHome = join(tmpdir(), `nemoclaw-idempotent-test-${process.pid}`); + execFileSync("mkdir", ["-p", fakeHome]); + const tmpFile = join(tmpdir(), `nemoclaw-idempotent-write-test-${process.pid}.sh`); + try { + const scriptPath = join(import.meta.dirname, "../scripts/nemoclaw-start.sh"); + const persistBlock = execFileSync( + "sed", + ["-n", "/^_PROXY_URL=/,/^# ── Main/{ /^# ── Main/d; p; }", scriptPath], + { encoding: "utf-8" } + ); + const wrapper = [ + "#!/usr/bin/env bash", + 'PROXY_HOST="10.200.0.1"', + 'PROXY_PORT="3128"', + persistBlock.trimEnd(), + ].join("\n"); + writeFileSync(tmpFile, wrapper, { mode: 0o700 }); + const runOpts = { encoding: /** @type {const} */ ("utf-8"), env: { ...process.env, HOME: fakeHome } }; + execFileSync("bash", [tmpFile], runOpts); + execFileSync("bash", [tmpFile], runOpts); + execFileSync("bash", [tmpFile], runOpts); + + const bashrc = readFileSync(join(fakeHome, ".bashrc"), "utf-8"); + const beginCount = (bashrc.match(/nemoclaw-proxy-config begin/g) || []).length; + const endCount = (bashrc.match(/nemoclaw-proxy-config end/g) || []).length; + expect(beginCount).toBe(1); + expect(endCount).toBe(1); + } finally { + try { unlinkSync(tmpFile); } catch { /* ignore */ } + try { execFileSync("rm", ["-rf", fakeHome]); } catch { /* ignore */ } + } + }); + + it("entrypoint persistence replaces stale proxy values on restart", () => { + const fakeHome = join(tmpdir(), `nemoclaw-replace-test-${process.pid}`); + execFileSync("mkdir", ["-p", fakeHome]); + const tmpFile = join(tmpdir(), `nemoclaw-replace-write-test-${process.pid}.sh`); + try { + const scriptPath = join(import.meta.dirname, "../scripts/nemoclaw-start.sh"); + const persistBlock = execFileSync( + "sed", + ["-n", "/^_PROXY_URL=/,/^# ── Main/{ /^# ── Main/d; p; }", scriptPath], + { encoding: "utf-8" } + ); + const makeWrapper = (host) => [ + "#!/usr/bin/env bash", + `PROXY_HOST="${host}"`, + 'PROXY_PORT="3128"', + persistBlock.trimEnd(), + ].join("\n"); + + writeFileSync(tmpFile, makeWrapper("10.200.0.1"), { mode: 0o700 }); + execFileSync("bash", [tmpFile], { + encoding: "utf-8", + env: { ...process.env, HOME: fakeHome }, + }); + let bashrc = readFileSync(join(fakeHome, ".bashrc"), "utf-8"); + expect(bashrc).toContain("10.200.0.1"); + + writeFileSync(tmpFile, makeWrapper("192.168.1.99"), { mode: 0o700 }); + execFileSync("bash", [tmpFile], { + encoding: "utf-8", + env: { ...process.env, HOME: fakeHome }, + }); + bashrc = readFileSync(join(fakeHome, ".bashrc"), "utf-8"); + expect(bashrc).toContain("192.168.1.99"); + expect(bashrc).not.toContain("10.200.0.1"); + const beginCount = (bashrc.match(/nemoclaw-proxy-config begin/g) || []).length; + expect(beginCount).toBe(1); + } finally { + try { unlinkSync(tmpFile); } catch { /* ignore */ } + try { execFileSync("rm", ["-rf", fakeHome]); } catch { /* ignore */ } + } + }); + + it("[simulation] sourcing ~/.bashrc overrides narrow NO_PROXY and no_proxy", () => { + const fakeHome = join(tmpdir(), `nemoclaw-bashi-test-${process.pid}`); + execFileSync("mkdir", ["-p", fakeHome]); + try { + const bashrcContent = [ + "# nemoclaw-proxy-config begin", + 'export HTTP_PROXY="http://10.200.0.1:3128"', + 'export HTTPS_PROXY="http://10.200.0.1:3128"', + 'export NO_PROXY="localhost,127.0.0.1,::1,inference.local,10.200.0.1"', + 'export http_proxy="http://10.200.0.1:3128"', + 'export https_proxy="http://10.200.0.1:3128"', + 'export no_proxy="localhost,127.0.0.1,::1,inference.local,10.200.0.1"', + "# nemoclaw-proxy-config end", + ].join("\n"); + writeFileSync(join(fakeHome, ".bashrc"), bashrcContent); + + const out = execFileSync("bash", ["--norc", "-c", [ + `export HOME=${JSON.stringify(fakeHome)}`, + 'export NO_PROXY="127.0.0.1,localhost,::1"', + 'export no_proxy="127.0.0.1,localhost,::1"', + `source ${JSON.stringify(join(fakeHome, ".bashrc"))}`, + 'echo "NO_PROXY=$NO_PROXY"', + 'echo "no_proxy=$no_proxy"', + ].join("; ")], { encoding: "utf-8" }).trim(); + + expect(out).toContain("NO_PROXY=localhost,127.0.0.1,::1,inference.local,10.200.0.1"); + expect(out).toContain("no_proxy=localhost,127.0.0.1,::1,inference.local,10.200.0.1"); + } finally { + try { execFileSync("rm", ["-rf", fakeHome]); } catch { /* ignore */ } + } + }); + }); });