Skip to content

Commit fdcd53b

Browse files
authored
Merge branch 'main' into refactor/remove-unused-exports
2 parents 558c801 + c051cbb commit fdcd53b

File tree

2 files changed

+310
-1
lines changed

2 files changed

+310
-1
lines changed

scripts/nemoclaw-start.sh

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,80 @@ PYAUTOPAIR
205205
echo "[gateway] auto-pair watcher launched (pid $!)"
206206
}
207207

208+
# ── Proxy environment ────────────────────────────────────────────
209+
# OpenShell injects HTTP_PROXY/HTTPS_PROXY/NO_PROXY into the sandbox, but its
210+
# NO_PROXY is limited to 127.0.0.1,localhost,::1 — missing inference.local and
211+
# the gateway IP. Without these entries, LLM inference requests are routed
212+
# through the egress proxy instead of going direct, and the proxy gateway IP
213+
# itself gets proxied (potential infinite loop).
214+
#
215+
# NEMOCLAW_PROXY_HOST / NEMOCLAW_PROXY_PORT can be overridden at sandbox
216+
# creation time if the gateway IP or port changes in a future OpenShell release.
217+
# Ref: https://github.com/NVIDIA/NemoClaw/issues/626
218+
PROXY_HOST="${NEMOCLAW_PROXY_HOST:-10.200.0.1}"
219+
PROXY_PORT="${NEMOCLAW_PROXY_PORT:-3128}"
220+
_PROXY_URL="http://${PROXY_HOST}:${PROXY_PORT}"
221+
_NO_PROXY_VAL="localhost,127.0.0.1,::1,inference.local,${PROXY_HOST}"
222+
export HTTP_PROXY="$_PROXY_URL"
223+
export HTTPS_PROXY="$_PROXY_URL"
224+
export NO_PROXY="$_NO_PROXY_VAL"
225+
export http_proxy="$_PROXY_URL"
226+
export https_proxy="$_PROXY_URL"
227+
export no_proxy="$_NO_PROXY_VAL"
228+
229+
# OpenShell re-injects narrow NO_PROXY/no_proxy=127.0.0.1,localhost,::1 every
230+
# time a user connects via `openshell sandbox connect`. The connect path spawns
231+
# `/bin/bash -i` (interactive, non-login), which sources ~/.bashrc — NOT
232+
# ~/.profile or /etc/profile.d/*. Write the full proxy config to ~/.bashrc so
233+
# interactive sessions see the correct values.
234+
#
235+
# Both uppercase and lowercase variants are required: Node.js undici prefers
236+
# lowercase (no_proxy) over uppercase (NO_PROXY) when both are set.
237+
# curl/wget use uppercase. gRPC C-core uses lowercase.
238+
#
239+
# Also write to ~/.profile for login-shell paths (e.g. `sandbox create -- cmd`
240+
# which spawns `bash -lc`).
241+
#
242+
# Idempotency: begin/end markers delimit the block so it can be replaced
243+
# on restart if NEMOCLAW_PROXY_HOST/PORT change, without duplicating.
244+
_PROXY_MARKER_BEGIN="# nemoclaw-proxy-config begin"
245+
_PROXY_MARKER_END="# nemoclaw-proxy-config end"
246+
_PROXY_SNIPPET="${_PROXY_MARKER_BEGIN}
247+
export HTTP_PROXY=\"$_PROXY_URL\"
248+
export HTTPS_PROXY=\"$_PROXY_URL\"
249+
export NO_PROXY=\"$_NO_PROXY_VAL\"
250+
export http_proxy=\"$_PROXY_URL\"
251+
export https_proxy=\"$_PROXY_URL\"
252+
export no_proxy=\"$_NO_PROXY_VAL\"
253+
${_PROXY_MARKER_END}"
254+
255+
if [ "$(id -u)" -eq 0 ]; then
256+
_SANDBOX_HOME=$(getent passwd sandbox 2>/dev/null | cut -d: -f6)
257+
_SANDBOX_HOME="${_SANDBOX_HOME:-/sandbox}"
258+
else
259+
_SANDBOX_HOME="${HOME:-/sandbox}"
260+
fi
261+
262+
_write_proxy_snippet() {
263+
local target="$1"
264+
if [ -f "$target" ] && grep -qF "$_PROXY_MARKER_BEGIN" "$target" 2>/dev/null; then
265+
local tmp
266+
tmp="$(mktemp)"
267+
awk -v b="$_PROXY_MARKER_BEGIN" -v e="$_PROXY_MARKER_END" \
268+
'$0==b{s=1;next} $0==e{s=0;next} !s' "$target" >"$tmp"
269+
printf '%s\n' "$_PROXY_SNIPPET" >>"$tmp"
270+
cat "$tmp" >"$target"
271+
rm -f "$tmp"
272+
return 0
273+
fi
274+
printf '\n%s\n' "$_PROXY_SNIPPET" >>"$target"
275+
}
276+
277+
if [ -w "$_SANDBOX_HOME" ]; then
278+
_write_proxy_snippet "${_SANDBOX_HOME}/.bashrc"
279+
_write_proxy_snippet "${_SANDBOX_HOME}/.profile"
280+
fi
281+
208282
# ── Main ─────────────────────────────────────────────────────────
209283

210284
echo 'Setting up NemoClaw...'

test/service-env.test.js

Lines changed: 236 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22
// SPDX-License-Identifier: Apache-2.0
33

44
import { describe, it, expect } from "vitest";
5-
import { execSync } from "node:child_process";
5+
import { execSync, execFileSync } from "node:child_process";
6+
import { writeFileSync, unlinkSync, readFileSync } from "node:fs";
7+
import { tmpdir } from "node:os";
8+
import { join } from "node:path";
69
import { resolveOpenshell } from "../bin/lib/resolve-openshell";
710

811
describe("service environment", () => {
@@ -95,4 +98,236 @@ describe("service environment", () => {
9598
expect(result).toBe("default");
9699
});
97100
});
101+
102+
describe("proxy environment variables (issue #626)", () => {
103+
function extractProxyVars(env = {}) {
104+
const scriptPath = join(import.meta.dirname, "../scripts/nemoclaw-start.sh");
105+
const proxyBlock = execFileSync(
106+
"sed",
107+
["-n", "/^PROXY_HOST=/,/^export no_proxy=/p", scriptPath],
108+
{ encoding: "utf-8" }
109+
);
110+
if (!proxyBlock.trim()) {
111+
throw new Error(
112+
"Failed to extract proxy configuration from scripts/nemoclaw-start.sh — " +
113+
"the PROXY_HOST..no_proxy block may have been moved or renamed"
114+
);
115+
}
116+
const wrapper = [
117+
"#!/usr/bin/env bash",
118+
proxyBlock.trimEnd(),
119+
'echo "HTTP_PROXY=${HTTP_PROXY}"',
120+
'echo "HTTPS_PROXY=${HTTPS_PROXY}"',
121+
'echo "NO_PROXY=${NO_PROXY}"',
122+
'echo "http_proxy=${http_proxy}"',
123+
'echo "https_proxy=${https_proxy}"',
124+
'echo "no_proxy=${no_proxy}"',
125+
].join("\n");
126+
const tmpFile = join(tmpdir(), `nemoclaw-proxy-test-${process.pid}.sh`);
127+
try {
128+
writeFileSync(tmpFile, wrapper, { mode: 0o700 });
129+
const out = execFileSync("bash", [tmpFile], {
130+
encoding: "utf-8",
131+
env: { ...process.env, ...env },
132+
}).trim();
133+
return Object.fromEntries(out.split("\n").map((l) => {
134+
const idx = l.indexOf("=");
135+
return [l.slice(0, idx), l.slice(idx + 1)];
136+
}));
137+
} finally {
138+
try { unlinkSync(tmpFile); } catch { /* ignore */ }
139+
}
140+
}
141+
142+
it("sets HTTP_PROXY to default gateway address", () => {
143+
const vars = extractProxyVars();
144+
expect(vars.HTTP_PROXY).toBe("http://10.200.0.1:3128");
145+
});
146+
147+
it("sets HTTPS_PROXY to default gateway address", () => {
148+
const vars = extractProxyVars();
149+
expect(vars.HTTPS_PROXY).toBe("http://10.200.0.1:3128");
150+
});
151+
152+
it("NEMOCLAW_PROXY_HOST overrides default gateway IP", () => {
153+
const vars = extractProxyVars({ NEMOCLAW_PROXY_HOST: "192.168.64.1" });
154+
expect(vars.HTTP_PROXY).toBe("http://192.168.64.1:3128");
155+
expect(vars.HTTPS_PROXY).toBe("http://192.168.64.1:3128");
156+
});
157+
158+
it("NEMOCLAW_PROXY_PORT overrides default proxy port", () => {
159+
const vars = extractProxyVars({ NEMOCLAW_PROXY_PORT: "8080" });
160+
expect(vars.HTTP_PROXY).toBe("http://10.200.0.1:8080");
161+
expect(vars.HTTPS_PROXY).toBe("http://10.200.0.1:8080");
162+
});
163+
164+
it("NO_PROXY includes loopback and inference.local", () => {
165+
const vars = extractProxyVars();
166+
const noProxy = vars.NO_PROXY.split(",");
167+
expect(noProxy).toContain("localhost");
168+
expect(noProxy).toContain("127.0.0.1");
169+
expect(noProxy).toContain("::1");
170+
expect(noProxy).toContain("inference.local");
171+
});
172+
173+
it("NO_PROXY includes OpenShell gateway IP", () => {
174+
const vars = extractProxyVars();
175+
expect(vars.NO_PROXY).toContain("10.200.0.1");
176+
});
177+
178+
it("exports lowercase proxy variants for undici/gRPC compatibility", () => {
179+
const vars = extractProxyVars();
180+
expect(vars.http_proxy).toBe("http://10.200.0.1:3128");
181+
expect(vars.https_proxy).toBe("http://10.200.0.1:3128");
182+
const noProxy = vars.no_proxy.split(",");
183+
expect(noProxy).toContain("inference.local");
184+
expect(noProxy).toContain("10.200.0.1");
185+
});
186+
187+
it("entrypoint persistence writes proxy snippet to ~/.bashrc and ~/.profile", () => {
188+
const fakeHome = join(tmpdir(), `nemoclaw-home-test-${process.pid}`);
189+
execFileSync("mkdir", ["-p", fakeHome]);
190+
const tmpFile = join(tmpdir(), `nemoclaw-bashrc-write-test-${process.pid}.sh`);
191+
try {
192+
const scriptPath = join(import.meta.dirname, "../scripts/nemoclaw-start.sh");
193+
const persistBlock = execFileSync(
194+
"sed",
195+
["-n", "/^_PROXY_URL=/,/^# ── Main/{ /^# ── Main/d; p; }", scriptPath],
196+
{ encoding: "utf-8" }
197+
);
198+
const wrapper = [
199+
"#!/usr/bin/env bash",
200+
'PROXY_HOST="10.200.0.1"',
201+
'PROXY_PORT="3128"',
202+
persistBlock.trimEnd(),
203+
].join("\n");
204+
writeFileSync(tmpFile, wrapper, { mode: 0o700 });
205+
execFileSync("bash", [tmpFile], {
206+
encoding: "utf-8",
207+
env: { ...process.env, HOME: fakeHome },
208+
});
209+
210+
const bashrc = readFileSync(join(fakeHome, ".bashrc"), "utf-8");
211+
expect(bashrc).toContain("export HTTP_PROXY=");
212+
expect(bashrc).toContain("export HTTPS_PROXY=");
213+
expect(bashrc).toContain("export NO_PROXY=");
214+
expect(bashrc).toContain("inference.local");
215+
expect(bashrc).toContain("10.200.0.1");
216+
217+
const profile = readFileSync(join(fakeHome, ".profile"), "utf-8");
218+
expect(profile).toContain("inference.local");
219+
} finally {
220+
try { unlinkSync(tmpFile); } catch { /* ignore */ }
221+
try { execFileSync("rm", ["-rf", fakeHome]); } catch { /* ignore */ }
222+
}
223+
});
224+
225+
it("entrypoint persistence is idempotent across repeated invocations", () => {
226+
const fakeHome = join(tmpdir(), `nemoclaw-idempotent-test-${process.pid}`);
227+
execFileSync("mkdir", ["-p", fakeHome]);
228+
const tmpFile = join(tmpdir(), `nemoclaw-idempotent-write-test-${process.pid}.sh`);
229+
try {
230+
const scriptPath = join(import.meta.dirname, "../scripts/nemoclaw-start.sh");
231+
const persistBlock = execFileSync(
232+
"sed",
233+
["-n", "/^_PROXY_URL=/,/^# ── Main/{ /^# ── Main/d; p; }", scriptPath],
234+
{ encoding: "utf-8" }
235+
);
236+
const wrapper = [
237+
"#!/usr/bin/env bash",
238+
'PROXY_HOST="10.200.0.1"',
239+
'PROXY_PORT="3128"',
240+
persistBlock.trimEnd(),
241+
].join("\n");
242+
writeFileSync(tmpFile, wrapper, { mode: 0o700 });
243+
const runOpts = { encoding: /** @type {const} */ ("utf-8"), env: { ...process.env, HOME: fakeHome } };
244+
execFileSync("bash", [tmpFile], runOpts);
245+
execFileSync("bash", [tmpFile], runOpts);
246+
execFileSync("bash", [tmpFile], runOpts);
247+
248+
const bashrc = readFileSync(join(fakeHome, ".bashrc"), "utf-8");
249+
const beginCount = (bashrc.match(/nemoclaw-proxy-config begin/g) || []).length;
250+
const endCount = (bashrc.match(/nemoclaw-proxy-config end/g) || []).length;
251+
expect(beginCount).toBe(1);
252+
expect(endCount).toBe(1);
253+
} finally {
254+
try { unlinkSync(tmpFile); } catch { /* ignore */ }
255+
try { execFileSync("rm", ["-rf", fakeHome]); } catch { /* ignore */ }
256+
}
257+
});
258+
259+
it("entrypoint persistence replaces stale proxy values on restart", () => {
260+
const fakeHome = join(tmpdir(), `nemoclaw-replace-test-${process.pid}`);
261+
execFileSync("mkdir", ["-p", fakeHome]);
262+
const tmpFile = join(tmpdir(), `nemoclaw-replace-write-test-${process.pid}.sh`);
263+
try {
264+
const scriptPath = join(import.meta.dirname, "../scripts/nemoclaw-start.sh");
265+
const persistBlock = execFileSync(
266+
"sed",
267+
["-n", "/^_PROXY_URL=/,/^# ── Main/{ /^# ── Main/d; p; }", scriptPath],
268+
{ encoding: "utf-8" }
269+
);
270+
const makeWrapper = (host) => [
271+
"#!/usr/bin/env bash",
272+
`PROXY_HOST="${host}"`,
273+
'PROXY_PORT="3128"',
274+
persistBlock.trimEnd(),
275+
].join("\n");
276+
277+
writeFileSync(tmpFile, makeWrapper("10.200.0.1"), { mode: 0o700 });
278+
execFileSync("bash", [tmpFile], {
279+
encoding: "utf-8",
280+
env: { ...process.env, HOME: fakeHome },
281+
});
282+
let bashrc = readFileSync(join(fakeHome, ".bashrc"), "utf-8");
283+
expect(bashrc).toContain("10.200.0.1");
284+
285+
writeFileSync(tmpFile, makeWrapper("192.168.1.99"), { mode: 0o700 });
286+
execFileSync("bash", [tmpFile], {
287+
encoding: "utf-8",
288+
env: { ...process.env, HOME: fakeHome },
289+
});
290+
bashrc = readFileSync(join(fakeHome, ".bashrc"), "utf-8");
291+
expect(bashrc).toContain("192.168.1.99");
292+
expect(bashrc).not.toContain("10.200.0.1");
293+
const beginCount = (bashrc.match(/nemoclaw-proxy-config begin/g) || []).length;
294+
expect(beginCount).toBe(1);
295+
} finally {
296+
try { unlinkSync(tmpFile); } catch { /* ignore */ }
297+
try { execFileSync("rm", ["-rf", fakeHome]); } catch { /* ignore */ }
298+
}
299+
});
300+
301+
it("[simulation] sourcing ~/.bashrc overrides narrow NO_PROXY and no_proxy", () => {
302+
const fakeHome = join(tmpdir(), `nemoclaw-bashi-test-${process.pid}`);
303+
execFileSync("mkdir", ["-p", fakeHome]);
304+
try {
305+
const bashrcContent = [
306+
"# nemoclaw-proxy-config begin",
307+
'export HTTP_PROXY="http://10.200.0.1:3128"',
308+
'export HTTPS_PROXY="http://10.200.0.1:3128"',
309+
'export NO_PROXY="localhost,127.0.0.1,::1,inference.local,10.200.0.1"',
310+
'export http_proxy="http://10.200.0.1:3128"',
311+
'export https_proxy="http://10.200.0.1:3128"',
312+
'export no_proxy="localhost,127.0.0.1,::1,inference.local,10.200.0.1"',
313+
"# nemoclaw-proxy-config end",
314+
].join("\n");
315+
writeFileSync(join(fakeHome, ".bashrc"), bashrcContent);
316+
317+
const out = execFileSync("bash", ["--norc", "-c", [
318+
`export HOME=${JSON.stringify(fakeHome)}`,
319+
'export NO_PROXY="127.0.0.1,localhost,::1"',
320+
'export no_proxy="127.0.0.1,localhost,::1"',
321+
`source ${JSON.stringify(join(fakeHome, ".bashrc"))}`,
322+
'echo "NO_PROXY=$NO_PROXY"',
323+
'echo "no_proxy=$no_proxy"',
324+
].join("; ")], { encoding: "utf-8" }).trim();
325+
326+
expect(out).toContain("NO_PROXY=localhost,127.0.0.1,::1,inference.local,10.200.0.1");
327+
expect(out).toContain("no_proxy=localhost,127.0.0.1,::1,inference.local,10.200.0.1");
328+
} finally {
329+
try { execFileSync("rm", ["-rf", fakeHome]); } catch { /* ignore */ }
330+
}
331+
});
332+
});
98333
});

0 commit comments

Comments
 (0)