From 0d45692bfad37c3c5bdb8df7463b9f7e8fe205ab Mon Sep 17 00:00:00 2001 From: 13ernkastel Date: Tue, 31 Mar 2026 14:56:04 +0800 Subject: [PATCH 1/8] fix(security): harden control UI and k8s defaults --- Dockerfile | 11 ++++++-- README.md | 3 +++ bin/lib/onboard.js | 7 +++++ docs/reference/architecture.md | 1 + install.sh | 1 + k8s/README.md | 9 ++++++- k8s/nemoclaw-k8s.yaml | 12 +++++++-- test/onboard.test.js | 9 +++++++ test/security-config-quickwins.test.js | 37 ++++++++++++++++++++++++++ 9 files changed, 85 insertions(+), 5 deletions(-) create mode 100644 test/security-config-quickwins.test.js diff --git a/Dockerfile b/Dockerfile index d62ee05582b..e27b5ce994a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -51,6 +51,7 @@ 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_INSECURE_LOCAL_UI=0 ARG NEMOCLAW_INFERENCE_BASE_URL=https://inference.local/v1 ARG NEMOCLAW_INFERENCE_API=openai-completions ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30= @@ -65,6 +66,7 @@ ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \ NEMOCLAW_PROVIDER_KEY=${NEMOCLAW_PROVIDER_KEY} \ NEMOCLAW_PRIMARY_MODEL_REF=${NEMOCLAW_PRIMARY_MODEL_REF} \ CHAT_UI_URL=${CHAT_UI_URL} \ + NEMOCLAW_INSECURE_LOCAL_UI=${NEMOCLAW_INSECURE_LOCAL_UI} \ NEMOCLAW_INFERENCE_BASE_URL=${NEMOCLAW_INFERENCE_BASE_URL} \ NEMOCLAW_INFERENCE_API=${NEMOCLAW_INFERENCE_API} \ NEMOCLAW_INFERENCE_COMPAT_B64=${NEMOCLAW_INFERENCE_COMPAT_B64} @@ -87,10 +89,15 @@ primary_model_ref = os.environ['NEMOCLAW_PRIMARY_MODEL_REF']; \ inference_base_url = os.environ['NEMOCLAW_INFERENCE_BASE_URL']; \ inference_api = os.environ['NEMOCLAW_INFERENCE_API']; \ inference_compat = json.loads(base64.b64decode(os.environ['NEMOCLAW_INFERENCE_COMPAT_B64']).decode('utf-8')); \ +insecure_local_ui = os.environ.get('NEMOCLAW_INSECURE_LOCAL_UI', '0').strip().lower() in ('1', 'true', 'yes', 'on'); \ parsed = urlparse(chat_ui_url); \ chat_origin = f'{parsed.scheme}://{parsed.netloc}' if parsed.scheme and parsed.netloc else 'http://127.0.0.1:18789'; \ origins = ['http://127.0.0.1:18789']; \ origins = list(dict.fromkeys(origins + [chat_origin])); \ +loopback_hosts = {'127.0.0.1', 'localhost', '::1'}; \ +origin_hosts = {urlparse(origin).hostname for origin in origins}; \ +loopback_only_origins = bool(origin_hosts) and all(host in loopback_hosts for host in origin_hosts if host); \ +enable_insecure_local_ui = insecure_local_ui and loopback_only_origins; \ providers = { \ provider_key: { \ 'baseUrl': inference_base_url, \ @@ -106,8 +113,8 @@ config = { \ 'gateway': { \ 'mode': 'local', \ 'controlUi': { \ - 'allowInsecureAuth': True, \ - 'dangerouslyDisableDeviceAuth': True, \ + 'allowInsecureAuth': enable_insecure_local_ui, \ + 'dangerouslyDisableDeviceAuth': enable_insecure_local_ui, \ 'allowedOrigins': origins, \ }, \ 'trustedProxies': ['127.0.0.1', '::1'], \ diff --git a/README.md b/README.md index 9f715cc742f..36425dfbaa9 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,9 @@ The script installs Node.js if it is not already present, then runs the guided o curl -fsSL https://www.nvidia.com/nemoclaw.sh | bash ``` +For the safer default, NemoClaw keeps OpenClaw device auth enabled for the Control UI. +If you explicitly want token-only auth for a loopback-only local dashboard, set `NEMOCLAW_INSECURE_LOCAL_UI=1` before onboarding. + If you use nvm or fnm to manage Node.js, the installer may not update your current shell's PATH. If `nemoclaw` is not found after install, run `source ~/.bashrc` (or `source ~/.zshrc` for zsh) or open a new terminal. diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index f4d4e3935cf..d3a60f218fd 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -970,6 +970,9 @@ function patchStagedDockerfile(dockerfilePath, model, chatUiUrl, buildId = Strin inferenceApi, inferenceCompat, } = getSandboxInferenceConfig(model, provider, preferredInferenceApi); + const insecureLocalUiValue = /^(1|true|yes|on)$/i.test(process.env.NEMOCLAW_INSECURE_LOCAL_UI || "") + ? "1" + : "0"; let dockerfile = fs.readFileSync(dockerfilePath, "utf8"); dockerfile = dockerfile.replace( /^ARG NEMOCLAW_MODEL=.*$/m, @@ -987,6 +990,10 @@ function patchStagedDockerfile(dockerfilePath, model, chatUiUrl, buildId = Strin /^ARG CHAT_UI_URL=.*$/m, `ARG CHAT_UI_URL=${chatUiUrl}` ); + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_INSECURE_LOCAL_UI=.*$/m, + `ARG NEMOCLAW_INSECURE_LOCAL_UI=${insecureLocalUiValue}` + ); dockerfile = dockerfile.replace( /^ARG NEMOCLAW_INFERENCE_BASE_URL=.*$/m, `ARG NEMOCLAW_INFERENCE_BASE_URL=${inferenceBaseUrl}` diff --git a/docs/reference/architecture.md b/docs/reference/architecture.md index ca6b8e993a2..980e7287d9d 100644 --- a/docs/reference/architecture.md +++ b/docs/reference/architecture.md @@ -187,5 +187,6 @@ The following environment variables configure optional services and local access | `TELEGRAM_BOT_TOKEN` | Bot token for the Telegram bridge. | | `ALLOWED_CHAT_IDS` | Comma-separated list of Telegram chat IDs allowed to message the agent. | | `CHAT_UI_URL` | URL for the optional chat UI endpoint. | +| `NEMOCLAW_INSECURE_LOCAL_UI` | Set to `1` to allow token-only Control UI auth for loopback-only dashboard origins. | For normal setup and reconfiguration, prefer `nemoclaw onboard` over editing these files by hand. diff --git a/install.sh b/install.sh index d2d87cbeff5..acf319e8d87 100755 --- a/install.sh +++ b/install.sh @@ -217,6 +217,7 @@ usage() { printf " NEMOCLAW_POLICY_PRESETS Comma-separated policy presets\n" printf " NEMOCLAW_EXPERIMENTAL=1 Show experimental/local options\n" printf " CHAT_UI_URL Chat UI URL to open after setup\n" + printf " NEMOCLAW_INSECURE_LOCAL_UI=1 Allow token-only local UI auth for loopback origins\n" printf " DISCORD_BOT_TOKEN Auto-enable Discord policy support\n" printf " SLACK_BOT_TOKEN Auto-enable Slack policy support\n" printf " TELEGRAM_BOT_TOKEN Auto-enable Telegram policy support\n" diff --git a/k8s/README.md b/k8s/README.md index be1a262486b..e5e54b785f5 100644 --- a/k8s/README.md +++ b/k8s/README.md @@ -2,6 +2,12 @@ > **⚠️ Experimental**: This deployment method is intended for **trying out NemoClaw on Kubernetes**, not for production use. It requires a **privileged pod** running **Docker-in-Docker (DinD)** to create isolated sandbox environments. Operational requirements (storage, runtime, security policies) vary by cluster configuration. +The sample manifest now uses a few safer defaults out of the box: +- disables Kubernetes service account token automounting +- disables service-link environment injection +- runs the workspace container with `allowPrivilegeEscalation: false` and `RuntimeDefault` seccomp +- applies NemoClaw's suggested policy presets instead of skipping policy setup + Run [NemoClaw](https://github.com/NVIDIA/NemoClaw) on Kubernetes with GPU inference powered by [Dynamo](https://github.com/ai-dynamo/dynamo) or any OpenAI-compatible endpoint. --- @@ -48,9 +54,10 @@ Edit the environment variables in `nemoclaw-k8s.yaml` before deploying: |----------|----------|-------------| | `DYNAMO_HOST` | Yes | Inference endpoint for socat proxy (e.g., `vllm-frontend.dynamo.svc:8000`) | | `NEMOCLAW_ENDPOINT_URL` | Yes | URL the sandbox uses (usually `http://host.openshell.internal:8000/v1`) | -| `COMPATIBLE_API_KEY` | Yes | API key (use `dummy` for Dynamo/vLLM) | +| `COMPATIBLE_API_KEY` | Yes | API key (use `dummy` for Dynamo/vLLM; use a Kubernetes `Secret` for real credentials) | | `NEMOCLAW_MODEL` | Yes | Model name (e.g., `meta-llama/Llama-3.1-8B-Instruct`) | | `NEMOCLAW_SANDBOX_NAME` | No | Sandbox name (default: `my-assistant`) | +| `NEMOCLAW_POLICY_MODE` | No | Policy preset mode for non-interactive onboarding (default: `suggested`) | ### Example: Custom Endpoint diff --git a/k8s/nemoclaw-k8s.yaml b/k8s/nemoclaw-k8s.yaml index edc8748cf56..e8159169a9a 100644 --- a/k8s/nemoclaw-k8s.yaml +++ b/k8s/nemoclaw-k8s.yaml @@ -9,6 +9,8 @@ metadata: labels: app: nemoclaw spec: + automountServiceAccountToken: false + enableServiceLinks: false containers: # Docker daemon (DinD) - name: dind @@ -34,6 +36,10 @@ spec: # Workspace - runs official NemoClaw installer - name: workspace image: node:22 + securityContext: + allowPrivilegeEscalation: false + seccompProfile: + type: RuntimeDefault command: - bash - -c @@ -63,7 +69,9 @@ spec: # Run official NemoClaw installer echo "[4/4] Running NemoClaw installer..." - curl -fsSL https://nvidia.com/nemoclaw.sh | bash + curl -fsSLo /tmp/nemoclaw-install.sh https://www.nvidia.com/nemoclaw.sh + bash /tmp/nemoclaw-install.sh + rm -f /tmp/nemoclaw-install.sh # Keep running after onboard echo "Onboard complete. Container staying alive." @@ -88,7 +96,7 @@ spec: - name: NEMOCLAW_SANDBOX_NAME value: "my-assistant" - name: NEMOCLAW_POLICY_MODE - value: "skip" + value: "suggested" volumeMounts: - name: docker-socket mountPath: /var/run diff --git a/test/onboard.test.js b/test/onboard.test.js index 96dc1e0e936..d24e7377959 100644 --- a/test/onboard.test.js +++ b/test/onboard.test.js @@ -95,20 +95,29 @@ describe("onboard helpers", () => { "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_INSECURE_LOCAL_UI=0", "ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30=", "ARG NEMOCLAW_BUILD_ID=default", ].join("\n") ); + const originalInsecureLocalUi = process.env.NEMOCLAW_INSECURE_LOCAL_UI; try { + process.env.NEMOCLAW_INSECURE_LOCAL_UI = "1"; patchStagedDockerfile(dockerfilePath, "gpt-5.4", "http://127.0.0.1:19999", "build-123", "openai-api"); const patched = fs.readFileSync(dockerfilePath, "utf8"); assert.match(patched, /^ARG NEMOCLAW_MODEL=gpt-5\.4$/m); assert.match(patched, /^ARG NEMOCLAW_PROVIDER_KEY=openai$/m); assert.match(patched, /^ARG NEMOCLAW_PRIMARY_MODEL_REF=openai\/gpt-5\.4$/m); assert.match(patched, /^ARG CHAT_UI_URL=http:\/\/127\.0\.0\.1:19999$/m); + assert.match(patched, /^ARG NEMOCLAW_INSECURE_LOCAL_UI=1$/m); assert.match(patched, /^ARG NEMOCLAW_BUILD_ID=build-123$/m); } finally { + if (originalInsecureLocalUi === undefined) { + delete process.env.NEMOCLAW_INSECURE_LOCAL_UI; + } else { + process.env.NEMOCLAW_INSECURE_LOCAL_UI = originalInsecureLocalUi; + } fs.rmSync(tmpDir, { recursive: true, force: true }); } }); diff --git a/test/security-config-quickwins.test.js b/test/security-config-quickwins.test.js new file mode 100644 index 00000000000..68cd40af775 --- /dev/null +++ b/test/security-config-quickwins.test.js @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; + +const ROOT = path.join(import.meta.dirname, ".."); +const DOCKERFILE = path.join(ROOT, "Dockerfile"); +const K8S_MANIFEST = path.join(ROOT, "k8s", "nemoclaw-k8s.yaml"); + +describe("security config quick wins", () => { + it("gates insecure Control UI auth behind explicit opt-in and loopback-only origins", () => { + const dockerfile = fs.readFileSync(DOCKERFILE, "utf8"); + expect(dockerfile).toMatch(/^ARG NEMOCLAW_INSECURE_LOCAL_UI=0$/m); + expect(dockerfile).toContain("NEMOCLAW_INSECURE_LOCAL_UI=${NEMOCLAW_INSECURE_LOCAL_UI}"); + expect(dockerfile).toContain("os.environ.get('NEMOCLAW_INSECURE_LOCAL_UI', '0')"); + expect(dockerfile).toContain("loopback_hosts = {'127.0.0.1', 'localhost', '::1'}"); + expect(dockerfile).toContain("enable_insecure_local_ui = insecure_local_ui and loopback_only_origins"); + expect(dockerfile).not.toContain("'allowInsecureAuth': True"); + expect(dockerfile).not.toContain("'dangerouslyDisableDeviceAuth': True"); + expect(dockerfile).toContain("'allowInsecureAuth': enable_insecure_local_ui"); + expect(dockerfile).toContain("'dangerouslyDisableDeviceAuth': enable_insecure_local_ui"); + }); + + it("hardens the Kubernetes sample manifest with safer defaults", () => { + const manifest = fs.readFileSync(K8S_MANIFEST, "utf8"); + expect(manifest).toMatch(/automountServiceAccountToken:\s*false/); + expect(manifest).toMatch(/enableServiceLinks:\s*false/); + expect(manifest).toMatch(/- name: workspace[\s\S]*allowPrivilegeEscalation:\s*false/); + expect(manifest).toMatch(/- name: workspace[\s\S]*seccompProfile:\s*[\r\n]+\s*type:\s*RuntimeDefault/); + expect(manifest).toMatch(/- name: NEMOCLAW_POLICY_MODE[\s\S]*value:\s*"suggested"/); + expect(manifest).toContain("curl -fsSLo /tmp/nemoclaw-install.sh https://www.nvidia.com/nemoclaw.sh"); + expect(manifest).toContain("bash /tmp/nemoclaw-install.sh"); + expect(manifest).not.toContain("curl -fsSL https://nvidia.com/nemoclaw.sh | bash"); + }); +}); From 2aa2a9f037a95c995bd17d38369b49ad33f72f25 Mon Sep 17 00:00:00 2001 From: 13ernkastel Date: Tue, 31 Mar 2026 15:00:00 +0800 Subject: [PATCH 2/8] test(security): rename hardening coverage suite --- ...ickwins.test.js => security-configuration-hardening.test.js} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename test/{security-config-quickwins.test.js => security-configuration-hardening.test.js} (97%) diff --git a/test/security-config-quickwins.test.js b/test/security-configuration-hardening.test.js similarity index 97% rename from test/security-config-quickwins.test.js rename to test/security-configuration-hardening.test.js index 68cd40af775..2be222fa99e 100644 --- a/test/security-config-quickwins.test.js +++ b/test/security-configuration-hardening.test.js @@ -9,7 +9,7 @@ const ROOT = path.join(import.meta.dirname, ".."); const DOCKERFILE = path.join(ROOT, "Dockerfile"); const K8S_MANIFEST = path.join(ROOT, "k8s", "nemoclaw-k8s.yaml"); -describe("security config quick wins", () => { +describe("security configuration hardening", () => { it("gates insecure Control UI auth behind explicit opt-in and loopback-only origins", () => { const dockerfile = fs.readFileSync(DOCKERFILE, "utf8"); expect(dockerfile).toMatch(/^ARG NEMOCLAW_INSECURE_LOCAL_UI=0$/m); From 560f2c0670f08d7ecdd705216337a2a94ae1cffd Mon Sep 17 00:00:00 2001 From: 13ernkastel Date: Tue, 31 Mar 2026 15:08:19 +0800 Subject: [PATCH 3/8] fix(security): tighten control UI and k8s hardening --- Dockerfile | 2 +- bin/lib/onboard.js | 25 ++++++++-- k8s/README.md | 18 ++++++-- k8s/nemoclaw-k8s.yaml | 20 +++++++- test/onboard.test.js | 46 +++++++++++++++---- test/security-configuration-hardening.test.js | 9 +++- 6 files changed, 99 insertions(+), 21 deletions(-) diff --git a/Dockerfile b/Dockerfile index e27b5ce994a..12f16982f59 100644 --- a/Dockerfile +++ b/Dockerfile @@ -96,7 +96,7 @@ origins = ['http://127.0.0.1:18789']; \ origins = list(dict.fromkeys(origins + [chat_origin])); \ loopback_hosts = {'127.0.0.1', 'localhost', '::1'}; \ origin_hosts = {urlparse(origin).hostname for origin in origins}; \ -loopback_only_origins = bool(origin_hosts) and all(host in loopback_hosts for host in origin_hosts if host); \ +loopback_only_origins = bool(origin_hosts) and all(host and host in loopback_hosts for host in origin_hosts); \ enable_insecure_local_ui = insecure_local_ui and loopback_only_origins; \ providers = { \ provider_key: { \ diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index d3a60f218fd..ce89aec3eb5 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -962,7 +962,15 @@ function getSandboxInferenceConfig(model, provider = null, preferredInferenceApi return { providerKey, primaryModelRef, inferenceBaseUrl, inferenceApi, inferenceCompat }; } -function patchStagedDockerfile(dockerfilePath, model, chatUiUrl, buildId = String(Date.now()), provider = null, preferredInferenceApi = null) { +function patchStagedDockerfile( + dockerfilePath, + model, + chatUiUrl, + buildId = String(Date.now()), + provider = null, + preferredInferenceApi = null, + options = {}, +) { const { providerKey, primaryModelRef, @@ -970,9 +978,7 @@ function patchStagedDockerfile(dockerfilePath, model, chatUiUrl, buildId = Strin inferenceApi, inferenceCompat, } = getSandboxInferenceConfig(model, provider, preferredInferenceApi); - const insecureLocalUiValue = /^(1|true|yes|on)$/i.test(process.env.NEMOCLAW_INSECURE_LOCAL_UI || "") - ? "1" - : "0"; + const insecureLocalUiValue = options.insecureLocalUi ? "1" : "0"; let dockerfile = fs.readFileSync(dockerfilePath, "utf8"); dockerfile = dockerfile.replace( /^ARG NEMOCLAW_MODEL=.*$/m, @@ -2165,7 +2171,16 @@ async function createSandbox(gpu, model, provider, preferredInferenceApi = null, console.log(` Creating sandbox '${sandboxName}' (this takes a few minutes on first run)...`); const chatUiUrl = process.env.CHAT_UI_URL || "http://127.0.0.1:18789"; - patchStagedDockerfile(stagedDockerfile, model, chatUiUrl, String(Date.now()), provider, preferredInferenceApi); + const insecureLocalUiEnabled = /^(1|true|yes|on)$/i.test(process.env.NEMOCLAW_INSECURE_LOCAL_UI || ""); + patchStagedDockerfile( + stagedDockerfile, + model, + chatUiUrl, + String(Date.now()), + provider, + preferredInferenceApi, + { insecureLocalUi: insecureLocalUiEnabled }, + ); // Only pass non-sensitive env vars to the sandbox. NVIDIA_API_KEY is NOT // needed inside the sandbox — inference is proxied through the OpenShell // gateway which injects the stored credential server-side. The gateway diff --git a/k8s/README.md b/k8s/README.md index e5e54b785f5..d25d6eede76 100644 --- a/k8s/README.md +++ b/k8s/README.md @@ -5,8 +5,9 @@ The sample manifest now uses a few safer defaults out of the box: - disables Kubernetes service account token automounting - disables service-link environment injection -- runs the workspace container with `allowPrivilegeEscalation: false` and `RuntimeDefault` seccomp +- runs the workspace container with `allowPrivilegeEscalation: false`, `capabilities.drop: [ALL]`, and `RuntimeDefault` seccomp - applies NemoClaw's suggested policy presets instead of skipping policy setup +- downloads the installer to a local file with HTTPS-only curl flags before execution Run [NemoClaw](https://github.com/NVIDIA/NemoClaw) on Kubernetes with GPU inference powered by [Dynamo](https://github.com/ai-dynamo/dynamo) or any OpenAI-compatible endpoint. @@ -54,11 +55,22 @@ Edit the environment variables in `nemoclaw-k8s.yaml` before deploying: |----------|----------|-------------| | `DYNAMO_HOST` | Yes | Inference endpoint for socat proxy (e.g., `vllm-frontend.dynamo.svc:8000`) | | `NEMOCLAW_ENDPOINT_URL` | Yes | URL the sandbox uses (usually `http://host.openshell.internal:8000/v1`) | -| `COMPATIBLE_API_KEY` | Yes | API key (use `dummy` for Dynamo/vLLM; use a Kubernetes `Secret` for real credentials) | +| `COMPATIBLE_API_KEY` | No | Loaded from the optional `nemoclaw-compatible-api-key` Secret; defaults to `dummy` for Dynamo/vLLM when the Secret is absent | | `NEMOCLAW_MODEL` | Yes | Model name (e.g., `meta-llama/Llama-3.1-8B-Instruct`) | | `NEMOCLAW_SANDBOX_NAME` | No | Sandbox name (default: `my-assistant`) | | `NEMOCLAW_POLICY_MODE` | No | Policy preset mode for non-interactive onboarding (default: `suggested`) | +### Optional: Store a Real API Key in a Secret + +If your compatible endpoint requires authentication, create the Secret before +you apply the manifest: + +```bash +kubectl create secret generic nemoclaw-compatible-api-key \ + -n nemoclaw \ + --from-literal=api-key='' +``` + ### Example: Custom Endpoint ```yaml @@ -67,8 +79,6 @@ env: value: "my-vllm.my-namespace.svc.cluster.local:8000" - name: NEMOCLAW_ENDPOINT_URL value: "http://host.openshell.internal:8000/v1" - - name: COMPATIBLE_API_KEY - value: "dummy" - name: NEMOCLAW_MODEL value: "mistralai/Mistral-7B-Instruct-v0.3" ``` diff --git a/k8s/nemoclaw-k8s.yaml b/k8s/nemoclaw-k8s.yaml index e8159169a9a..e840088a1d5 100644 --- a/k8s/nemoclaw-k8s.yaml +++ b/k8s/nemoclaw-k8s.yaml @@ -38,6 +38,9 @@ spec: image: node:22 securityContext: allowPrivilegeEscalation: false + capabilities: + drop: + - ALL seccompProfile: type: RuntimeDefault command: @@ -67,9 +70,18 @@ spec: docker info >/dev/null 2>&1 || { echo "Docker not ready"; exit 1; } echo "Docker ready" + # Default to a dummy compatible API key for unauthenticated endpoints + # such as Dynamo/vLLM while still allowing a Secret-backed override. + export COMPATIBLE_API_KEY="${COMPATIBLE_API_KEY:-dummy}" + # Run official NemoClaw installer echo "[4/4] Running NemoClaw installer..." - curl -fsSLo /tmp/nemoclaw-install.sh https://www.nvidia.com/nemoclaw.sh + umask 077 + curl --proto '=https' --tlsv1.2 --fail --show-error --silent \ + --location \ + --output /tmp/nemoclaw-install.sh \ + https://www.nvidia.com/nemoclaw.sh + chmod 700 /tmp/nemoclaw-install.sh bash /tmp/nemoclaw-install.sh rm -f /tmp/nemoclaw-install.sh @@ -90,7 +102,11 @@ spec: - name: NEMOCLAW_ENDPOINT_URL value: "http://host.openshell.internal:8000/v1" - name: COMPATIBLE_API_KEY - value: "dummy" + valueFrom: + secretKeyRef: + name: nemoclaw-compatible-api-key + key: api-key + optional: true - name: NEMOCLAW_MODEL value: "meta-llama/Llama-3.1-8B-Instruct" - name: NEMOCLAW_SANDBOX_NAME diff --git a/test/onboard.test.js b/test/onboard.test.js index d24e7377959..e66d5f96505 100644 --- a/test/onboard.test.js +++ b/test/onboard.test.js @@ -101,10 +101,17 @@ describe("onboard helpers", () => { ].join("\n") ); - const originalInsecureLocalUi = process.env.NEMOCLAW_INSECURE_LOCAL_UI; try { - process.env.NEMOCLAW_INSECURE_LOCAL_UI = "1"; - patchStagedDockerfile(dockerfilePath, "gpt-5.4", "http://127.0.0.1:19999", "build-123", "openai-api"); + process.env.NEMOCLAW_INSECURE_LOCAL_UI = "0"; + patchStagedDockerfile( + dockerfilePath, + "gpt-5.4", + "http://127.0.0.1:19999", + "build-123", + "openai-api", + null, + { insecureLocalUi: true }, + ); const patched = fs.readFileSync(dockerfilePath, "utf8"); assert.match(patched, /^ARG NEMOCLAW_MODEL=gpt-5\.4$/m); assert.match(patched, /^ARG NEMOCLAW_PROVIDER_KEY=openai$/m); @@ -113,11 +120,34 @@ describe("onboard helpers", () => { assert.match(patched, /^ARG NEMOCLAW_INSECURE_LOCAL_UI=1$/m); assert.match(patched, /^ARG NEMOCLAW_BUILD_ID=build-123$/m); } finally { - if (originalInsecureLocalUi === undefined) { - delete process.env.NEMOCLAW_INSECURE_LOCAL_UI; - } else { - process.env.NEMOCLAW_INSECURE_LOCAL_UI = originalInsecureLocalUi; - } + delete process.env.NEMOCLAW_INSECURE_LOCAL_UI; + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("leaves insecure local UI disabled unless explicitly requested", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-dockerfile-ui-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_INSECURE_LOCAL_UI=0", + "ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30=", + "ARG NEMOCLAW_BUILD_ID=default", + ].join("\n") + ); + + try { + process.env.NEMOCLAW_INSECURE_LOCAL_UI = "1"; + patchStagedDockerfile(dockerfilePath, "gpt-5.4", "http://127.0.0.1:19999", "build-456", "openai-api"); + const patched = fs.readFileSync(dockerfilePath, "utf8"); + assert.match(patched, /^ARG NEMOCLAW_INSECURE_LOCAL_UI=0$/m); + } finally { + delete process.env.NEMOCLAW_INSECURE_LOCAL_UI; fs.rmSync(tmpDir, { recursive: true, force: true }); } }); diff --git a/test/security-configuration-hardening.test.js b/test/security-configuration-hardening.test.js index 2be222fa99e..ffa16747781 100644 --- a/test/security-configuration-hardening.test.js +++ b/test/security-configuration-hardening.test.js @@ -16,6 +16,7 @@ describe("security configuration hardening", () => { expect(dockerfile).toContain("NEMOCLAW_INSECURE_LOCAL_UI=${NEMOCLAW_INSECURE_LOCAL_UI}"); expect(dockerfile).toContain("os.environ.get('NEMOCLAW_INSECURE_LOCAL_UI', '0')"); expect(dockerfile).toContain("loopback_hosts = {'127.0.0.1', 'localhost', '::1'}"); + expect(dockerfile).toContain("all(host and host in loopback_hosts for host in origin_hosts)"); expect(dockerfile).toContain("enable_insecure_local_ui = insecure_local_ui and loopback_only_origins"); expect(dockerfile).not.toContain("'allowInsecureAuth': True"); expect(dockerfile).not.toContain("'dangerouslyDisableDeviceAuth': True"); @@ -28,9 +29,15 @@ describe("security configuration hardening", () => { expect(manifest).toMatch(/automountServiceAccountToken:\s*false/); expect(manifest).toMatch(/enableServiceLinks:\s*false/); expect(manifest).toMatch(/- name: workspace[\s\S]*allowPrivilegeEscalation:\s*false/); + expect(manifest).toMatch(/- name: workspace[\s\S]*capabilities:\s*[\r\n]+\s*drop:\s*[\r\n]+\s*-\s*ALL/); expect(manifest).toMatch(/- name: workspace[\s\S]*seccompProfile:\s*[\r\n]+\s*type:\s*RuntimeDefault/); expect(manifest).toMatch(/- name: NEMOCLAW_POLICY_MODE[\s\S]*value:\s*"suggested"/); - expect(manifest).toContain("curl -fsSLo /tmp/nemoclaw-install.sh https://www.nvidia.com/nemoclaw.sh"); + expect(manifest).toContain('export COMPATIBLE_API_KEY="${COMPATIBLE_API_KEY:-dummy}"'); + expect(manifest).toMatch(/- name: COMPATIBLE_API_KEY[\s\S]*secretKeyRef:[\s\S]*name:\s*nemoclaw-compatible-api-key/); + expect(manifest).toMatch(/- name: COMPATIBLE_API_KEY[\s\S]*optional:\s*true/); + expect(manifest).toContain("curl --proto '=https' --tlsv1.2 --fail --show-error --silent"); + expect(manifest).toContain("--output /tmp/nemoclaw-install.sh"); + expect(manifest).toContain("chmod 700 /tmp/nemoclaw-install.sh"); expect(manifest).toContain("bash /tmp/nemoclaw-install.sh"); expect(manifest).not.toContain("curl -fsSL https://nvidia.com/nemoclaw.sh | bash"); }); From 6a660508b5e0402b32b1ac5d48f21a24f9468489 Mon Sep 17 00:00:00 2001 From: 13ernkastel Date: Tue, 31 Mar 2026 15:31:31 +0800 Subject: [PATCH 4/8] fix(security): address review feedback on UI hardening --- README.md | 12 ++++- bin/lib/onboard-session.js | 9 ++++ bin/lib/onboard.js | 54 +++++++++++++++++-- install.sh | 4 ++ k8s/README.md | 13 ++++- test/onboard-session.test.js | 5 +- test/onboard.test.js | 2 +- test/security-configuration-hardening.test.js | 9 ++-- 8 files changed, 96 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 36425dfbaa9..8bfc456e1ee 100644 --- a/README.md +++ b/README.md @@ -71,12 +71,20 @@ The script installs Node.js if it is not already present, then runs the guided o > > NemoClaw creates a fresh OpenClaw instance inside the sandbox during the onboarding process. +For the safer default, NemoClaw keeps OpenClaw device auth enabled for the Control UI. +If you explicitly want token-only auth for a loopback-only local dashboard on first run, use: + +```bash +curl -fsSL https://www.nvidia.com/nemoclaw.sh | NEMOCLAW_INSECURE_LOCAL_UI=1 bash +``` + +Otherwise, use the standard install path: + ```bash curl -fsSL https://www.nvidia.com/nemoclaw.sh | bash ``` -For the safer default, NemoClaw keeps OpenClaw device auth enabled for the Control UI. -If you explicitly want token-only auth for a loopback-only local dashboard, set `NEMOCLAW_INSECURE_LOCAL_UI=1` before onboarding. +You can also `export NEMOCLAW_INSECURE_LOCAL_UI=1` before running the installer if you prefer a two-step flow. If you use nvm or fnm to manage Node.js, the installer may not update your current shell's PATH. If `nemoclaw` is not found after install, run `source ~/.bashrc` (or `source ~/.zshrc` for zsh) or open a new terminal. diff --git a/bin/lib/onboard-session.js b/bin/lib/onboard-session.js index bbe095b4395..e36fa01c97b 100644 --- a/bin/lib/onboard-session.js +++ b/bin/lib/onboard-session.js @@ -34,8 +34,13 @@ function defaultSteps() { }; } +function optionalBoolean(value) { + return typeof value === "boolean" ? value : null; +} + function createSession(overrides = {}) { const now = new Date().toISOString(); + const insecureLocalUi = optionalBoolean(overrides.insecureLocalUi); return { version: SESSION_VERSION, sessionId: overrides.sessionId || `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`, @@ -54,6 +59,7 @@ function createSession(overrides = {}) { credentialEnv: overrides.credentialEnv || null, preferredInferenceApi: overrides.preferredInferenceApi || null, nimContainer: overrides.nimContainer || null, + insecureLocalUi, policyPresets: Array.isArray(overrides.policyPresets) ? overrides.policyPresets.filter((value) => typeof value === "string") : null, metadata: { gatewayName: overrides.metadata?.gatewayName || "nemoclaw", @@ -129,6 +135,7 @@ function normalizeSession(data) { credentialEnv: typeof data.credentialEnv === "string" ? data.credentialEnv : null, preferredInferenceApi: typeof data.preferredInferenceApi === "string" ? data.preferredInferenceApi : null, nimContainer: typeof data.nimContainer === "string" ? data.nimContainer : null, + insecureLocalUi: optionalBoolean(data.insecureLocalUi), policyPresets: Array.isArray(data.policyPresets) ? data.policyPresets.filter((value) => typeof value === "string") : null, lastStepStarted: typeof data.lastStepStarted === "string" ? data.lastStepStarted : null, lastCompletedStep: typeof data.lastCompletedStep === "string" ? data.lastCompletedStep : null, @@ -360,6 +367,7 @@ function filterSafeUpdates(updates) { if (typeof updates.credentialEnv === "string") safe.credentialEnv = updates.credentialEnv; if (typeof updates.preferredInferenceApi === "string") safe.preferredInferenceApi = updates.preferredInferenceApi; if (typeof updates.nimContainer === "string") safe.nimContainer = updates.nimContainer; + if (typeof updates.insecureLocalUi === "boolean") safe.insecureLocalUi = updates.insecureLocalUi; if (Array.isArray(updates.policyPresets)) { safe.policyPresets = updates.policyPresets.filter((value) => typeof value === "string"); } @@ -388,6 +396,7 @@ function summarizeForDebug(session = loadSession()) { credentialEnv: session.credentialEnv, preferredInferenceApi: session.preferredInferenceApi, nimContainer: session.nimContainer, + insecureLocalUi: session.insecureLocalUi, policyPresets: session.policyPresets, lastStepStarted: session.lastStepStarted, lastCompletedStep: session.lastCompletedStep, diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index ce89aec3eb5..0c14a7fd5d5 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -1019,6 +1019,22 @@ function patchStagedDockerfile( fs.writeFileSync(dockerfilePath, dockerfile); } +function isInsecureLocalUiRequested(value = process.env.NEMOCLAW_INSECURE_LOCAL_UI || "") { + return /^(1|true|yes|on)$/i.test(value); +} + +function resolveInsecureLocalUiPreference(session) { + if (typeof session?.insecureLocalUi === "boolean") { + return session.insecureLocalUi; + } + const insecureLocalUi = isInsecureLocalUiRequested(); + onboardSession.updateSession((current) => { + current.insecureLocalUi = insecureLocalUi; + return current; + }); + return insecureLocalUi; +} + function summarizeProbeError(body, status) { if (!body) return `HTTP ${status} with no response body`; try { @@ -2120,7 +2136,14 @@ async function promptValidatedSandboxName() { // ── Step 5: Sandbox ────────────────────────────────────────────── // eslint-disable-next-line complexity -async function createSandbox(gpu, model, provider, preferredInferenceApi = null, sandboxNameOverride = null) { +async function createSandbox( + gpu, + model, + provider, + preferredInferenceApi = null, + sandboxNameOverride = null, + options = {}, +) { step(5, 7, "Creating sandbox"); const sandboxName = sandboxNameOverride || (await promptValidatedSandboxName()); @@ -2171,7 +2194,10 @@ async function createSandbox(gpu, model, provider, preferredInferenceApi = null, console.log(` Creating sandbox '${sandboxName}' (this takes a few minutes on first run)...`); const chatUiUrl = process.env.CHAT_UI_URL || "http://127.0.0.1:18789"; - const insecureLocalUiEnabled = /^(1|true|yes|on)$/i.test(process.env.NEMOCLAW_INSECURE_LOCAL_UI || ""); + const insecureLocalUiEnabled = + typeof options.insecureLocalUi === "boolean" + ? options.insecureLocalUi + : isInsecureLocalUiRequested(); patchStagedDockerfile( stagedDockerfile, model, @@ -3482,6 +3508,8 @@ async function onboard(opts = {}) { let credentialEnv = session?.credentialEnv || null; let preferredInferenceApi = session?.preferredInferenceApi || null; let nimContainer = session?.nimContainer || null; + let insecureLocalUi = + typeof session?.insecureLocalUi === "boolean" ? session.insecureLocalUi : null; let forceProviderSelection = false; while (true) { const resumeProviderSelection = @@ -3560,8 +3588,25 @@ async function onboard(opts = {}) { } } startRecordedStep("sandbox", { sandboxName, provider, model }); - sandboxName = await createSandbox(gpu, model, provider, preferredInferenceApi, sandboxName); - onboardSession.markStepComplete("sandbox", { sandboxName, provider, model, nimContainer }); + insecureLocalUi = + typeof insecureLocalUi === "boolean" + ? insecureLocalUi + : resolveInsecureLocalUiPreference(session); + sandboxName = await createSandbox( + gpu, + model, + provider, + preferredInferenceApi, + sandboxName, + { insecureLocalUi }, + ); + onboardSession.markStepComplete("sandbox", { + sandboxName, + provider, + model, + nimContainer, + insecureLocalUi, + }); } const resumeOpenclaw = resume && sandboxName && isOpenclawReady(sandboxName); @@ -3647,6 +3692,7 @@ module.exports = { onboardSession, printSandboxCreateRecoveryHints, pruneStaleSandboxEntry, + resolveInsecureLocalUiPreference, repairRecordedSandbox, recoverGatewayRuntime, startGatewayForRecovery, diff --git a/install.sh b/install.sh index acf319e8d87..a71d8d94cf3 100755 --- a/install.sh +++ b/install.sh @@ -201,6 +201,10 @@ usage() { printf " ${C_DIM}Usage:${C_RESET}\n" printf " curl -fsSL https://www.nvidia.com/nemoclaw.sh | bash\n" printf " curl -fsSL https://www.nvidia.com/nemoclaw.sh | bash -s -- [options]\n\n" + printf " ${C_DIM}Examples:${C_RESET}\n" + printf " curl -fsSL https://www.nvidia.com/nemoclaw.sh | NEMOCLAW_INSECURE_LOCAL_UI=1 bash\n" + printf " export NEMOCLAW_INSECURE_LOCAL_UI=1\n" + printf " curl -fsSL https://www.nvidia.com/nemoclaw.sh | bash\n\n" printf " ${C_DIM}Options:${C_RESET}\n" printf " --non-interactive Skip prompts (uses env vars / defaults)\n" printf " --version, -v Print installer version and exit\n" diff --git a/k8s/README.md b/k8s/README.md index d25d6eede76..1c1c93cc94b 100644 --- a/k8s/README.md +++ b/k8s/README.md @@ -24,6 +24,17 @@ Run [NemoClaw](https://github.com/NVIDIA/NemoClaw) on Kubernetes with GPU infere ### 1. Deploy NemoClaw +If your compatible endpoint requires an API key, create the optional +`nemoclaw-compatible-api-key` Secret after creating the namespace and before +running `kubectl apply`. The same Secret-backed flow is described again in the +configuration section below. + +```bash +kubectl create secret generic nemoclaw-compatible-api-key \ + -n nemoclaw \ + --from-literal=api-key='' +``` + ```bash kubectl create namespace nemoclaw kubectl apply -f https://raw.githubusercontent.com/NVIDIA/NemoClaw/main/k8s/nemoclaw-k8s.yaml @@ -63,7 +74,7 @@ Edit the environment variables in `nemoclaw-k8s.yaml` before deploying: ### Optional: Store a Real API Key in a Secret If your compatible endpoint requires authentication, create the Secret before -you apply the manifest: +you apply the manifest in Step 1: ```bash kubectl create secret generic nemoclaw-compatible-api-key \ diff --git a/test/onboard-session.test.js b/test/onboard-session.test.js index 08dc5d30f36..44fef505838 100644 --- a/test/onboard-session.test.js +++ b/test/onboard-session.test.js @@ -93,6 +93,7 @@ describe("onboard session", () => { credentialEnv: "NVIDIA_API_KEY", preferredInferenceApi: "openai-completions", nimContainer: "nim-123", + insecureLocalUi: true, policyPresets: ["pypi", "npm"], apiKey: "nvapi-secret", metadata: { @@ -109,6 +110,7 @@ describe("onboard session", () => { expect(loaded.credentialEnv).toBe("NVIDIA_API_KEY"); expect(loaded.preferredInferenceApi).toBe("openai-completions"); expect(loaded.nimContainer).toBe("nim-123"); + expect(loaded.insecureLocalUi).toBe(true); expect(loaded.policyPresets).toEqual(["pypi", "npm"]); expect(loaded.apiKey).toBeUndefined(); expect(loaded.metadata.gatewayName).toBe("nemoclaw"); @@ -198,13 +200,14 @@ describe("onboard session", () => { }); it("summarizes the session for debug output", () => { - session.saveSession(session.createSession({ sandboxName: "my-assistant" })); + session.saveSession(session.createSession({ sandboxName: "my-assistant", insecureLocalUi: true })); session.markStepStarted("preflight"); session.markStepComplete("preflight"); session.completeSession(); const summary = session.summarizeForDebug(); expect(summary.sandboxName).toBe("my-assistant"); + expect(summary.insecureLocalUi).toBe(true); expect(summary.steps.preflight.status).toBe("complete"); expect(summary.steps.preflight.startedAt).toBeTruthy(); expect(summary.steps.preflight.completedAt).toBeTruthy(); diff --git a/test/onboard.test.js b/test/onboard.test.js index e66d5f96505..b68318d4089 100644 --- a/test/onboard.test.js +++ b/test/onboard.test.js @@ -1062,7 +1062,7 @@ const { setupInference } = require(${onboardPath}); assert.match( source, - /startRecordedStep\("sandbox", \{ sandboxName, provider, model \}\);\s*sandboxName = await createSandbox\(gpu, model, provider, preferredInferenceApi, sandboxName\);/ + /startRecordedStep\("sandbox", \{ sandboxName, provider, model \}\);\s*insecureLocalUi =\s*typeof insecureLocalUi === "boolean"\s*\?\s*insecureLocalUi\s*:\s*resolveInsecureLocalUiPreference\(session\);\s*sandboxName = await createSandbox\(\s*gpu,\s*model,\s*provider,\s*preferredInferenceApi,\s*sandboxName,\s*\{\s*insecureLocalUi\s*\},\s*\);/ ); }); diff --git a/test/security-configuration-hardening.test.js b/test/security-configuration-hardening.test.js index ffa16747781..c228414fbf8 100644 --- a/test/security-configuration-hardening.test.js +++ b/test/security-configuration-hardening.test.js @@ -26,11 +26,14 @@ describe("security configuration hardening", () => { it("hardens the Kubernetes sample manifest with safer defaults", () => { const manifest = fs.readFileSync(K8S_MANIFEST, "utf8"); + const workspaceMatch = manifest.match(/- name: workspace[\s\S]*?(?=\n\s*-\s*name: |\n\s*initContainers:|\n\s*volumes:|$)/); + expect(workspaceMatch).not.toBeNull(); + const workspaceSection = workspaceMatch[0]; expect(manifest).toMatch(/automountServiceAccountToken:\s*false/); expect(manifest).toMatch(/enableServiceLinks:\s*false/); - expect(manifest).toMatch(/- name: workspace[\s\S]*allowPrivilegeEscalation:\s*false/); - expect(manifest).toMatch(/- name: workspace[\s\S]*capabilities:\s*[\r\n]+\s*drop:\s*[\r\n]+\s*-\s*ALL/); - expect(manifest).toMatch(/- name: workspace[\s\S]*seccompProfile:\s*[\r\n]+\s*type:\s*RuntimeDefault/); + expect(workspaceSection).toMatch(/allowPrivilegeEscalation:\s*false/); + expect(workspaceSection).toMatch(/capabilities:\s*[\r\n]+\s*drop:\s*[\r\n]+\s*-\s*ALL/); + expect(workspaceSection).toMatch(/seccompProfile:\s*[\r\n]+\s*type:\s*RuntimeDefault/); expect(manifest).toMatch(/- name: NEMOCLAW_POLICY_MODE[\s\S]*value:\s*"suggested"/); expect(manifest).toContain('export COMPATIBLE_API_KEY="${COMPATIBLE_API_KEY:-dummy}"'); expect(manifest).toMatch(/- name: COMPATIBLE_API_KEY[\s\S]*secretKeyRef:[\s\S]*name:\s*nemoclaw-compatible-api-key/); From cc7ffaf38022fe48c56de6a774857e903b7778f3 Mon Sep 17 00:00:00 2001 From: 13ernkastel Date: Tue, 31 Mar 2026 15:40:34 +0800 Subject: [PATCH 5/8] chore(ci): rerun PR checks after DCO sign-off update Signed-off-by: 13ernkastel From a4b87dca039eafa0ca3b93eb1963652d34370213 Mon Sep 17 00:00:00 2001 From: 13ernkastel Date: Tue, 31 Mar 2026 15:45:37 +0800 Subject: [PATCH 6/8] test(security): tighten hardening regression checks --- bin/lib/onboard.js | 2 +- test/onboard.test.js | 33 +++++++++++++++++++ test/security-configuration-hardening.test.js | 12 +++++-- 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 0c14a7fd5d5..b227dc26386 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -978,7 +978,7 @@ function patchStagedDockerfile( inferenceApi, inferenceCompat, } = getSandboxInferenceConfig(model, provider, preferredInferenceApi); - const insecureLocalUiValue = options.insecureLocalUi ? "1" : "0"; + const insecureLocalUiValue = options.insecureLocalUi === true ? "1" : "0"; let dockerfile = fs.readFileSync(dockerfilePath, "utf8"); dockerfile = dockerfile.replace( /^ARG NEMOCLAW_MODEL=.*$/m, diff --git a/test/onboard.test.js b/test/onboard.test.js index b68318d4089..f0ebc507f9e 100644 --- a/test/onboard.test.js +++ b/test/onboard.test.js @@ -152,6 +152,39 @@ describe("onboard helpers", () => { } }); + it("treats only boolean true as enabling insecure local UI", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-dockerfile-ui-strict-")); + 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_INSECURE_LOCAL_UI=0", + "ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30=", + "ARG NEMOCLAW_BUILD_ID=default", + ].join("\n") + ); + + try { + patchStagedDockerfile( + dockerfilePath, + "gpt-5.4", + "http://127.0.0.1:19999", + "build-789", + "openai-api", + null, + { insecureLocalUi: "false" }, + ); + const patched = fs.readFileSync(dockerfilePath, "utf8"); + assert.match(patched, /^ARG NEMOCLAW_INSECURE_LOCAL_UI=0$/m); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it("maps NVIDIA Endpoints to the routed inference provider", () => { assert.deepEqual( getSandboxInferenceConfig("qwen/qwen3.5-397b-a17b", "nvidia-prod", "openai-completions"), diff --git a/test/security-configuration-hardening.test.js b/test/security-configuration-hardening.test.js index c228414fbf8..7e4d2494965 100644 --- a/test/security-configuration-hardening.test.js +++ b/test/security-configuration-hardening.test.js @@ -36,12 +36,18 @@ describe("security configuration hardening", () => { expect(workspaceSection).toMatch(/seccompProfile:\s*[\r\n]+\s*type:\s*RuntimeDefault/); expect(manifest).toMatch(/- name: NEMOCLAW_POLICY_MODE[\s\S]*value:\s*"suggested"/); expect(manifest).toContain('export COMPATIBLE_API_KEY="${COMPATIBLE_API_KEY:-dummy}"'); - expect(manifest).toMatch(/- name: COMPATIBLE_API_KEY[\s\S]*secretKeyRef:[\s\S]*name:\s*nemoclaw-compatible-api-key/); - expect(manifest).toMatch(/- name: COMPATIBLE_API_KEY[\s\S]*optional:\s*true/); + const compatibleApiKeySection = manifest.match( + /- name: COMPATIBLE_API_KEY[\s\S]*?(?=\n\s*-\s*name: |\n\s*volumeMounts:|\n\s*command:|$)/ + )?.[0]; + expect(compatibleApiKeySection).toBeTruthy(); + expect(compatibleApiKeySection).toMatch( + /secretKeyRef:[\s\S]*name:\s*nemoclaw-compatible-api-key/ + ); + expect(compatibleApiKeySection).toMatch(/optional:\s*true/); expect(manifest).toContain("curl --proto '=https' --tlsv1.2 --fail --show-error --silent"); expect(manifest).toContain("--output /tmp/nemoclaw-install.sh"); expect(manifest).toContain("chmod 700 /tmp/nemoclaw-install.sh"); expect(manifest).toContain("bash /tmp/nemoclaw-install.sh"); - expect(manifest).not.toContain("curl -fsSL https://nvidia.com/nemoclaw.sh | bash"); + expect(manifest).not.toMatch(/curl\b[^\n|]*\|\s*(?:ba|z|k)?sh\b/i); }); }); From 4bd0255ed1595835a5808df91760cdcdfb4c051f Mon Sep 17 00:00:00 2001 From: 13ernkastel Date: Wed, 1 Apr 2026 10:17:09 +0800 Subject: [PATCH 7/8] fix(onboard): address remaining review follow-ups Signed-off-by: 13ernkastel --- bin/lib/onboard.js | 104 +++++++++++++++++++++++------------ k8s/README.md | 5 +- test/onboard-session.test.js | 21 +++++++ test/onboard.test.js | 6 +- 4 files changed, 96 insertions(+), 40 deletions(-) diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 63f65339675..a8ddc00d851 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -3689,6 +3689,64 @@ function startRecordedStep(stepName, updates = {}) { } } +async function ensureOnboardSandbox({ + gpu, + model, + provider, + preferredInferenceApi, + sandboxName, + nimContainer, + insecureLocalUi, + resume, + session, +}) { + const sandboxReuseState = getSandboxReuseState(sandboxName); + const resumeSandbox = + resume && session?.steps?.sandbox?.status === "complete" && sandboxReuseState === "ready"; + if (resumeSandbox) { + skippedStepMessage("sandbox", sandboxName); + return { sandboxName, insecureLocalUi }; + } + + if (resume && session?.steps?.sandbox?.status === "complete") { + if (sandboxReuseState === "not_ready") { + note(` [resume] Recorded sandbox '${sandboxName}' exists but is not ready; recreating it.`); + repairRecordedSandbox(sandboxName); + } else { + note(" [resume] Recorded sandbox state is unavailable; recreating it."); + if (sandboxName) { + registry.removeSandbox(sandboxName); + } + } + } + + startRecordedStep("sandbox", { sandboxName, provider, model }); + const resolvedInsecureLocalUi = + typeof insecureLocalUi === "boolean" + ? insecureLocalUi + : resolveInsecureLocalUiPreference(session); + const createdSandboxName = await createSandbox( + gpu, + model, + provider, + preferredInferenceApi, + sandboxName, + { insecureLocalUi: resolvedInsecureLocalUi }, + ); + onboardSession.markStepComplete("sandbox", { + sandboxName: createdSandboxName, + provider, + model, + nimContainer, + insecureLocalUi: resolvedInsecureLocalUi, + }); + + return { + sandboxName: createdSandboxName, + insecureLocalUi: resolvedInsecureLocalUi, + }; +} + const ONBOARD_STEP_INDEX = { preflight: { number: 1, title: "Preflight checks" }, gateway: { number: 2, title: "Starting OpenShell gateway" }, @@ -3923,41 +3981,17 @@ async function onboard(opts = {}) { break; } - const sandboxReuseState = getSandboxReuseState(sandboxName); - const resumeSandbox = - resume && session?.steps?.sandbox?.status === "complete" && sandboxReuseState === "ready"; - if (resumeSandbox) { - skippedStepMessage("sandbox", sandboxName); - } else { - if (resume && session?.steps?.sandbox?.status === "complete") { - if (sandboxReuseState === "not_ready") { - note( - ` [resume] Recorded sandbox '${sandboxName}' exists but is not ready; recreating it.`, - ); - repairRecordedSandbox(sandboxName); - } else { - note(" [resume] Recorded sandbox state is unavailable; recreating it."); - if (sandboxName) { - registry.removeSandbox(sandboxName); - } - } - } - startRecordedStep("sandbox", { sandboxName, provider, model }); - insecureLocalUi = - typeof insecureLocalUi === "boolean" - ? insecureLocalUi - : resolveInsecureLocalUiPreference(session); - sandboxName = await createSandbox(gpu, model, provider, preferredInferenceApi, sandboxName, { - insecureLocalUi, - }); - onboardSession.markStepComplete("sandbox", { - sandboxName, - provider, - model, - nimContainer, - insecureLocalUi, - }); - } + ({ sandboxName, insecureLocalUi } = await ensureOnboardSandbox({ + gpu, + model, + provider, + preferredInferenceApi, + sandboxName, + nimContainer, + insecureLocalUi, + resume, + session, + })); const resumeOpenclaw = resume && sandboxName && isOpenclawReady(sandboxName); if (resumeOpenclaw) { diff --git a/k8s/README.md b/k8s/README.md index 1c1c93cc94b..391d2dc145d 100644 --- a/k8s/README.md +++ b/k8s/README.md @@ -30,13 +30,10 @@ running `kubectl apply`. The same Secret-backed flow is described again in the configuration section below. ```bash +kubectl create namespace nemoclaw kubectl create secret generic nemoclaw-compatible-api-key \ -n nemoclaw \ --from-literal=api-key='' -``` - -```bash -kubectl create namespace nemoclaw kubectl apply -f https://raw.githubusercontent.com/NVIDIA/NemoClaw/main/k8s/nemoclaw-k8s.yaml ``` diff --git a/test/onboard-session.test.js b/test/onboard-session.test.js index 7be026c95cb..09e590b2d41 100644 --- a/test/onboard-session.test.js +++ b/test/onboard-session.test.js @@ -117,6 +117,27 @@ describe("onboard session", () => { expect(loaded.metadata.token).toBeUndefined(); }); + it("preserves false insecureLocalUi values across persisted and debug session state", () => { + session.saveSession( + session.createSession({ + sandboxName: "my-assistant", + insecureLocalUi: false, + }), + ); + session.markStepComplete("provider_selection", { + sandboxName: "my-assistant", + provider: "nvidia-nim", + model: "nvidia/test-model", + insecureLocalUi: false, + }); + + const loaded = session.loadSession(); + expect(loaded.insecureLocalUi).toBe(false); + + const summary = session.summarizeForDebug(loaded); + expect(summary.insecureLocalUi).toBe(false); + }); + it("does not clear existing metadata when updates omit whitelisted metadata fields", () => { session.saveSession(session.createSession({ metadata: { gatewayName: "nemoclaw" } })); session.markStepComplete("provider_selection", { diff --git a/test/onboard.test.js b/test/onboard.test.js index a18a6fcafb4..a1c6a1b5cf3 100644 --- a/test/onboard.test.js +++ b/test/onboard.test.js @@ -1147,7 +1147,11 @@ const { setupInference } = require(${onboardPath}); assert.match( source, - /startRecordedStep\("sandbox", \{ sandboxName, provider, model \}\);\s*insecureLocalUi =\s*typeof insecureLocalUi === "boolean"\s*\?\s*insecureLocalUi\s*:\s*resolveInsecureLocalUiPreference\(session\);\s*sandboxName = await createSandbox\(\s*gpu,\s*model,\s*provider,\s*preferredInferenceApi,\s*sandboxName,\s*\{\s*insecureLocalUi\s*\},\s*\);/, + /async function ensureOnboardSandbox\(\{[\s\S]*startRecordedStep\("sandbox", \{ sandboxName, provider, model \}\);[\s\S]*const resolvedInsecureLocalUi =[\s\S]*resolveInsecureLocalUiPreference\(session\);[\s\S]*const createdSandboxName = await createSandbox\(/, + ); + assert.match( + source, + /\(\{ sandboxName, insecureLocalUi \} = await ensureOnboardSandbox\(\{[\s\S]*gpu,[\s\S]*preferredInferenceApi,[\s\S]*session,[\s\S]*\}\)\);/, ); }); From 50b93e302e636dfe29cc74e08d3c061f93a5f465 Mon Sep 17 00:00:00 2001 From: 13ernkastel Date: Wed, 1 Apr 2026 10:43:11 +0800 Subject: [PATCH 8/8] fix(security): restrict auto-pair approvals Signed-off-by: 13ernkastel --- scripts/nemoclaw-start.sh | 18 +++++++++++++++--- test/nemoclaw-start.test.js | 29 +++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index fbdebda2090..8774c02527a 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -153,6 +153,9 @@ OPENCLAW = os.environ.get('OPENCLAW_BIN', 'openclaw') DEADLINE = time.time() + 600 QUIET_POLLS = 0 APPROVED = 0 +HANDLED = set() +ALLOWED_CLIENTS = {'openclaw-control-ui'} +ALLOWED_MODES = {'webchat'} def run(*args): proc = subprocess.run(args, capture_output=True, text=True) @@ -176,13 +179,22 @@ while time.time() < DEADLINE: if pending: QUIET_POLLS = 0 for device in pending: - request_id = (device or {}).get('requestId') - if not request_id: + if not isinstance(device, dict): + continue + request_id = device.get('requestId') + if not request_id or request_id in HANDLED: + continue + client_id = device.get('clientId', '') + client_mode = device.get('clientMode', '') + if client_id not in ALLOWED_CLIENTS and client_mode not in ALLOWED_MODES: + HANDLED.add(request_id) + print(f'[auto-pair] rejected unknown client={client_id} mode={client_mode}') continue arc, aout, aerr = run(OPENCLAW, 'devices', 'approve', request_id, '--json') + HANDLED.add(request_id) if arc == 0: APPROVED += 1 - print(f'[auto-pair] approved request={request_id}') + print(f'[auto-pair] approved request={request_id} client={client_id}') elif aout or aerr: print(f'[auto-pair] approve failed request={request_id}: {(aerr or aout)[:400]}') time.sleep(1) diff --git a/test/nemoclaw-start.test.js b/test/nemoclaw-start.test.js index d54ac970962..55e540c20e1 100644 --- a/test/nemoclaw-start.test.js +++ b/test/nemoclaw-start.test.js @@ -59,3 +59,32 @@ describe("nemoclaw-start non-root fallback", () => { } }); }); + +describe("nemoclaw-start auto-pair hardening", () => { + const src = fs.readFileSync(START_SCRIPT, "utf-8"); + + it("defines auto-pair allowlists for expected dashboard clients", () => { + expect(src).toMatch(/ALLOWED_CLIENTS\s*=\s*\{[^}]*'openclaw-control-ui'[^}]*\}/); + expect(src).toMatch(/ALLOWED_MODES\s*=\s*\{[^}]*'webchat'[^}]*\}/); + }); + + it("validates pending devices before field access", () => { + expect(src).toMatch(/if not isinstance\(device, dict\):/); + expect(src).not.toMatch(/\(device or \{\}\)\.get\('requestId'\)/); + }); + + it("rejects unknown clients instead of approving every pending request", () => { + expect(src).toMatch(/client_id not in ALLOWED_CLIENTS and client_mode not in ALLOWED_MODES/); + expect(src).toMatch(/\[auto-pair\] rejected unknown client=/); + }); + + it("tracks handled requests to avoid reprocessing rejected or approved devices", () => { + expect(src).toMatch(/HANDLED\s*=\s*set\(\)/); + expect(src).toMatch(/request_id in HANDLED/); + expect(src.match(/HANDLED\.add\(request_id\)/g)?.length).toBeGreaterThanOrEqual(2); + }); + + it("includes the approved client identity in the audit log", () => { + expect(src).toMatch(/\[auto-pair\] approved request=\{request_id\} client=\{client_id\}/); + }); +});