diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index d28b9637498..6bf72997cd7 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -40,7 +40,8 @@ PYAUTH print_dashboard_urls() { local token chat_ui_base local_url remote_url - token="$(python3 - <<'PYTOKEN' + token="$( + python3 - <<'PYTOKEN' import json import os path = os.path.expanduser('~/.openclaw/openclaw.json') @@ -51,7 +52,7 @@ except Exception: else: print(cfg.get('gateway', {}).get('auth', {}).get('token', '')) PYTOKEN -)" + )" chat_ui_base="${CHAT_UI_URL%/}" local_url="http://127.0.0.1:${PUBLIC_PORT}/" @@ -66,7 +67,7 @@ PYTOKEN } start_auto_pair() { - nohup python3 - <<'PYAUTOPAIR' >> /tmp/gateway.log 2>&1 & + nohup python3 - <<'PYAUTOPAIR' >>/tmp/gateway.log 2>&1 & import json import subprocess import time @@ -130,13 +131,49 @@ echo 'Setting up NemoClaw...' # openclaw doctor --fix and openclaw plugins install already ran at build time # (Dockerfile Step 28). At runtime they fail with EPERM against the locked # /sandbox/.openclaw directory and accomplish nothing. + +# Configure outbound proxy so Node.js (openclaw/undici) routes HTTP/HTTPS +# requests through the OpenShell egress proxy at 10.200.0.1:3128. +# +# Without this, Node.js resolves DNS locally before the CONNECT tunnel is +# opened, hitting `getaddrinfo EAI_AGAIN` because the sandbox network +# namespace has no DNS resolver configured. The proxy handles DNS on behalf +# of the sandbox, so exporting HTTPS_PROXY causes undici/node:http to skip +# local resolution entirely. curl already does this correctly (it uses the +# proxy's DNS), and these env vars bring parity to Node.js callers. +# +# NEMOCLAW_PROXY_HOST can be set at sandbox creation time to override the +# default if the gateway IP 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}" +export HTTP_PROXY="http://${PROXY_HOST}:${PROXY_PORT}" +export HTTPS_PROXY="http://${PROXY_HOST}:${PROXY_PORT}" +# Bypass proxy for loopback, sandbox-local, and the OpenShell virtual network +# so internal gateway calls (openclaw dashboard, inference.local) stay fast. +export NO_PROXY="localhost,127.0.0.1,::1,inference.local,10.200.0.1" +# OpenShell injects its own NO_PROXY=127.0.0.1,localhost,::1 when a user +# connects to the sandbox via `openshell sandbox connect`, overwriting the +# value set above. Write a profile.d snippet so the full value is restored +# on every login shell — both initial and subsequent `connect` sessions. +# /etc/profile.d/ is writable by root during startup (this script runs as the +# entrypoint before Landlock lockdown applies to that path). +if [ -d /etc/profile.d ]; then + cat >/etc/profile.d/nemoclaw-proxy.sh < /tmp/gateway.log 2>&1 & +nohup openclaw gateway run >/tmp/gateway.log 2>&1 & echo "[gateway] openclaw gateway launched (pid $!)" start_auto_pair print_dashboard_urls diff --git a/test/service-env.test.js b/test/service-env.test.js index b3da3131440..1bf1b371b45 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,137 @@ describe("service environment", () => { expect(result).toBe("default"); }); }); + + describe("proxy environment variables (issue #626)", () => { + // Verify nemoclaw-start.sh sets HTTP_PROXY / HTTPS_PROXY / NO_PROXY so that + // Node.js (undici) routes outbound requests through the OpenShell egress + // proxy and does not attempt to resolve DNS locally inside the sandbox. + + function extractProxyVars(env = {}) { + // Source the proxy-variable block directly from scripts/nemoclaw-start.sh + // so that tests always validate the actual implementation rather than a + // hand-maintained copy. If the script changes its defaults or variable + // names, these tests will catch the regression. + // + // Implementation: extract the proxy block (PROXY_HOST= through + // export NO_PROXY=) via sed, then run it in a minimal bash wrapper that + // echoes the three variables we care about. + 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}"', + ].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 excludes 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 excludes OpenShell gateway IP (undici does not support CIDR)", () => { + const vars = extractProxyVars(); + expect(vars.NO_PROXY).toContain("10.200.0.1"); + }); + + it("writes proxy snippet to a profile.d directory when it exists", () => { + // Verify that nemoclaw-start.sh writes /etc/profile.d/nemoclaw-proxy.sh so + // that interactive shells opened via `openshell sandbox connect` (which + // inject a truncated NO_PROXY=127.0.0.1,localhost,::1) get the full value + // restored on every subsequent login shell. + const profileDir = join(tmpdir(), `nemoclaw-profile-test-${process.pid}`); + execFileSync("mkdir", ["-p", profileDir]); + const tmpFile = join(tmpdir(), `nemoclaw-profile-write-test-${process.pid}.sh`); + try { + // Run a minimal wrapper that sets the stage variables and executes only + // the proxy block from the start script, redirecting the profile.d write + // to our temp directory instead of /etc/profile.d. + const wrapper = [ + "#!/usr/bin/env bash", + `OVERRIDE_PROFILE_D=${JSON.stringify(profileDir)}`, + `PROXY_HOST="10.200.0.1"`, + `PROXY_PORT="3128"`, + `export HTTP_PROXY="http://\${PROXY_HOST}:\${PROXY_PORT}"`, + `export HTTPS_PROXY="http://\${PROXY_HOST}:\${PROXY_PORT}"`, + `export NO_PROXY="localhost,127.0.0.1,::1,inference.local,10.200.0.1"`, + // Reproduce the profile.d block with the overridden directory + `if [ -d "\${OVERRIDE_PROFILE_D}" ]; then`, + ` cat > "\${OVERRIDE_PROFILE_D}/nemoclaw-proxy.sh" <