Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions scripts/nemoclaw-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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...'
Expand Down
237 changes: 236 additions & 1 deletion test/service-env.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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 */ }
}
});
});
});
Loading