diff --git a/Dockerfile b/Dockerfile index 134e41e39..0c80cc2e6 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 c08103526..3c30a8f00 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 bfe4fa10f..724841827 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");