From c2c262f640342667c7c288b84a4609e58560c726 Mon Sep 17 00:00:00 2001 From: ColinM-sys Date: Tue, 7 Apr 2026 00:52:55 -0400 Subject: [PATCH] fix(onboard): honor NEMOCLAW_PROXY_HOST/PORT env at sandbox build time The sandbox-side nemoclaw-start.sh already reads NEMOCLAW_PROXY_HOST and NEMOCLAW_PROXY_PORT from its environment with the documented intent that operators can override the default 10.200.0.1:3128 egress proxy at sandbox creation time. The plumbing on the host side was missing, however: the Dockerfile declared no ARG for either variable and patchStagedDockerfile() never read them from process.env, so values exported in the host shell before 'nemoclaw onboard' were silently dropped and the sandbox always landed on the defaults. Three changes: - Dockerfile: declare ARG NEMOCLAW_PROXY_HOST and ARG NEMOCLAW_PROXY_PORT with the existing defaults, and promote them to ENV alongside the other build-arg-derived envs so the in-container start script sees them. - bin/lib/onboard.js: in patchStagedDockerfile(), if process.env contains NEMOCLAW_PROXY_HOST / NEMOCLAW_PROXY_PORT, validate them against tight regexes and bake the values into the staged Dockerfile via the same ARG-line replacement pattern used for the other deploy-time settings. Malformed values are rejected so an attacker who controls the host env cannot break out of the ARG line into arbitrary Dockerfile directives. - test/onboard.test.js: three regression tests covering (a) the override-baked-in case, (b) the env-unset defaults-preserved case, and (c) the malformed-input-rejected case. Closes #1409 --- Dockerfile | 10 ++- bin/lib/onboard.js | 20 ++++++ test/onboard.test.js | 145 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 174 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 134e41e3997..0c80cc2e635 100644 --- a/Dockerfile +++ b/Dockerfile @@ -73,6 +73,12 @@ ARG NEMOCLAW_DISABLE_DEVICE_AUTH=0 # Unique per build to ensure each image gets a fresh auth token. # Pass --build-arg NEMOCLAW_BUILD_ID=$(date +%s) to bust the cache. ARG NEMOCLAW_BUILD_ID=default +# Sandbox egress proxy host/port. Defaults match the OpenShell-injected +# gateway (10.200.0.1:3128). Operators on non-default networks can override +# at sandbox creation time by exporting NEMOCLAW_PROXY_HOST / NEMOCLAW_PROXY_PORT +# before running `nemoclaw onboard`. See #1409. +ARG NEMOCLAW_PROXY_HOST=10.200.0.1 +ARG NEMOCLAW_PROXY_PORT=3128 # SECURITY: Promote build-args to env vars so the Python script reads them # via os.environ, never via string interpolation into Python source code. @@ -87,7 +93,9 @@ ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \ NEMOCLAW_WEB_CONFIG_B64=${NEMOCLAW_WEB_CONFIG_B64} \ NEMOCLAW_MESSAGING_CHANNELS_B64=${NEMOCLAW_MESSAGING_CHANNELS_B64} \ NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=${NEMOCLAW_MESSAGING_ALLOWED_IDS_B64} \ - NEMOCLAW_DISABLE_DEVICE_AUTH=${NEMOCLAW_DISABLE_DEVICE_AUTH} + NEMOCLAW_DISABLE_DEVICE_AUTH=${NEMOCLAW_DISABLE_DEVICE_AUTH} \ + NEMOCLAW_PROXY_HOST=${NEMOCLAW_PROXY_HOST} \ + NEMOCLAW_PROXY_PORT=${NEMOCLAW_PROXY_PORT} WORKDIR /sandbox USER sandbox diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index c08103526b6..3c30a8f004e 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -935,6 +935,26 @@ function patchStagedDockerfile( /^ARG NEMOCLAW_BUILD_ID=.*$/m, `ARG NEMOCLAW_BUILD_ID=${buildId}`, ); + // Honor NEMOCLAW_PROXY_HOST / NEMOCLAW_PROXY_PORT exported in the host + // shell so the sandbox-side nemoclaw-start.sh sees them via $ENV at runtime. + // Without this, the host export is silently dropped at image build time and + // the sandbox falls back to the default 10.200.0.1:3128 proxy. See #1409. + const PROXY_HOST_RE = /^[A-Za-z0-9._:-]+$/; + const PROXY_PORT_RE = /^[0-9]{1,5}$/; + const proxyHostEnv = process.env.NEMOCLAW_PROXY_HOST; + if (proxyHostEnv && PROXY_HOST_RE.test(proxyHostEnv)) { + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_PROXY_HOST=.*$/m, + `ARG NEMOCLAW_PROXY_HOST=${proxyHostEnv}`, + ); + } + const proxyPortEnv = process.env.NEMOCLAW_PROXY_PORT; + if (proxyPortEnv && PROXY_PORT_RE.test(proxyPortEnv)) { + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_PROXY_PORT=.*$/m, + `ARG NEMOCLAW_PROXY_PORT=${proxyPortEnv}`, + ); + } dockerfile = dockerfile.replace( /^ARG NEMOCLAW_WEB_CONFIG_B64=.*$/m, `ARG NEMOCLAW_WEB_CONFIG_B64=${webSearch.buildWebSearchDockerConfig( diff --git a/test/onboard.test.js b/test/onboard.test.js index bfe4fa10fcf..724841827ec 100644 --- a/test/onboard.test.js +++ b/test/onboard.test.js @@ -230,6 +230,151 @@ describe("onboard helpers", () => { } }); + it("regression #1409: bakes NEMOCLAW_PROXY_HOST/PORT env into the staged Dockerfile", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-dockerfile-proxy-")); + const dockerfilePath = path.join(tmpDir, "Dockerfile"); + fs.writeFileSync( + dockerfilePath, + [ + "ARG NEMOCLAW_MODEL=nvidia/nemotron-3-super-120b-a12b", + "ARG NEMOCLAW_PROVIDER_KEY=nvidia", + "ARG NEMOCLAW_PRIMARY_MODEL_REF=nvidia/nemotron-3-super-120b-a12b", + "ARG CHAT_UI_URL=http://127.0.0.1:18789", + "ARG NEMOCLAW_INFERENCE_BASE_URL=https://inference.local/v1", + "ARG NEMOCLAW_INFERENCE_API=openai-completions", + "ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30=", + "ARG NEMOCLAW_WEB_CONFIG_B64=e30=", + "ARG NEMOCLAW_BUILD_ID=default", + "ARG NEMOCLAW_PROXY_HOST=10.200.0.1", + "ARG NEMOCLAW_PROXY_PORT=3128", + ].join("\n"), + ); + + const priorHost = process.env.NEMOCLAW_PROXY_HOST; + const priorPort = process.env.NEMOCLAW_PROXY_PORT; + process.env.NEMOCLAW_PROXY_HOST = "1.2.3.4"; + process.env.NEMOCLAW_PROXY_PORT = "9999"; + try { + patchStagedDockerfile( + dockerfilePath, + "gpt-5.4", + "http://127.0.0.1:18789", + "build-proxy", + "openai-api", + ); + const patched = fs.readFileSync(dockerfilePath, "utf8"); + assert.match(patched, /^ARG NEMOCLAW_PROXY_HOST=1\.2\.3\.4$/m); + assert.match(patched, /^ARG NEMOCLAW_PROXY_PORT=9999$/m); + } finally { + if (priorHost === undefined) { + delete process.env.NEMOCLAW_PROXY_HOST; + } else { + process.env.NEMOCLAW_PROXY_HOST = priorHost; + } + if (priorPort === undefined) { + delete process.env.NEMOCLAW_PROXY_PORT; + } else { + process.env.NEMOCLAW_PROXY_PORT = priorPort; + } + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("regression #1409: leaves Dockerfile defaults when proxy env is unset", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-dockerfile-proxy-default-")); + const dockerfilePath = path.join(tmpDir, "Dockerfile"); + fs.writeFileSync( + dockerfilePath, + [ + "ARG NEMOCLAW_MODEL=nvidia/nemotron-3-super-120b-a12b", + "ARG NEMOCLAW_PROVIDER_KEY=nvidia", + "ARG NEMOCLAW_PRIMARY_MODEL_REF=nvidia/nemotron-3-super-120b-a12b", + "ARG CHAT_UI_URL=http://127.0.0.1:18789", + "ARG NEMOCLAW_INFERENCE_BASE_URL=https://inference.local/v1", + "ARG NEMOCLAW_INFERENCE_API=openai-completions", + "ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30=", + "ARG NEMOCLAW_WEB_CONFIG_B64=e30=", + "ARG NEMOCLAW_BUILD_ID=default", + "ARG NEMOCLAW_PROXY_HOST=10.200.0.1", + "ARG NEMOCLAW_PROXY_PORT=3128", + ].join("\n"), + ); + + const priorHost = process.env.NEMOCLAW_PROXY_HOST; + const priorPort = process.env.NEMOCLAW_PROXY_PORT; + delete process.env.NEMOCLAW_PROXY_HOST; + delete process.env.NEMOCLAW_PROXY_PORT; + try { + patchStagedDockerfile( + dockerfilePath, + "gpt-5.4", + "http://127.0.0.1:18789", + "build-proxy-default", + "openai-api", + ); + const patched = fs.readFileSync(dockerfilePath, "utf8"); + // Defaults must be preserved when no env override is in effect. + assert.match(patched, /^ARG NEMOCLAW_PROXY_HOST=10\.200\.0\.1$/m); + assert.match(patched, /^ARG NEMOCLAW_PROXY_PORT=3128$/m); + } finally { + if (priorHost !== undefined) process.env.NEMOCLAW_PROXY_HOST = priorHost; + if (priorPort !== undefined) process.env.NEMOCLAW_PROXY_PORT = priorPort; + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("regression #1409: rejects malformed NEMOCLAW_PROXY_HOST/PORT and keeps defaults", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-dockerfile-proxy-bad-")); + const dockerfilePath = path.join(tmpDir, "Dockerfile"); + fs.writeFileSync( + dockerfilePath, + [ + "ARG NEMOCLAW_MODEL=nvidia/nemotron-3-super-120b-a12b", + "ARG NEMOCLAW_PROVIDER_KEY=nvidia", + "ARG NEMOCLAW_PRIMARY_MODEL_REF=nvidia/nemotron-3-super-120b-a12b", + "ARG CHAT_UI_URL=http://127.0.0.1:18789", + "ARG NEMOCLAW_INFERENCE_BASE_URL=https://inference.local/v1", + "ARG NEMOCLAW_INFERENCE_API=openai-completions", + "ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30=", + "ARG NEMOCLAW_WEB_CONFIG_B64=e30=", + "ARG NEMOCLAW_BUILD_ID=default", + "ARG NEMOCLAW_PROXY_HOST=10.200.0.1", + "ARG NEMOCLAW_PROXY_PORT=3128", + ].join("\n"), + ); + + const priorHost = process.env.NEMOCLAW_PROXY_HOST; + const priorPort = process.env.NEMOCLAW_PROXY_PORT; + // Inject malicious values that could break out of the ARG line if not validated. + process.env.NEMOCLAW_PROXY_HOST = "1.2.3.4\nRUN rm -rf /"; + process.env.NEMOCLAW_PROXY_PORT = "abcd"; + try { + patchStagedDockerfile( + dockerfilePath, + "gpt-5.4", + "http://127.0.0.1:18789", + "build-proxy-bad", + "openai-api", + ); + const patched = fs.readFileSync(dockerfilePath, "utf8"); + assert.match(patched, /^ARG NEMOCLAW_PROXY_HOST=10\.200\.0\.1$/m); + assert.match(patched, /^ARG NEMOCLAW_PROXY_PORT=3128$/m); + assert.doesNotMatch(patched, /RUN rm -rf/); + } finally { + if (priorHost === undefined) { + delete process.env.NEMOCLAW_PROXY_HOST; + } else { + process.env.NEMOCLAW_PROXY_HOST = priorHost; + } + if (priorPort === undefined) { + delete process.env.NEMOCLAW_PROXY_PORT; + } else { + process.env.NEMOCLAW_PROXY_PORT = priorPort; + } + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it("patches the staged Dockerfile with Brave Search config when enabled", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-dockerfile-web-")); const dockerfilePath = path.join(tmpDir, "Dockerfile");