diff --git a/Dockerfile b/Dockerfile index d62ee05582b..12f16982f59 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 and host in loopback_hosts for host in origin_hosts); \ +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 fedb3b31f2a..9e96e724592 100644 --- a/README.md +++ b/README.md @@ -71,10 +71,21 @@ 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 ``` +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 901eccac9b2..e20065f956f 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, @@ -136,6 +142,7 @@ function normalizeSession(data) { 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, @@ -370,6 +377,7 @@ function filterSafeUpdates(updates) { 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"); } @@ -398,6 +406,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 e6e2c092510..10924c8334a 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -1020,9 +1020,11 @@ function patchStagedDockerfile( buildId = String(Date.now()), provider = null, preferredInferenceApi = null, + options = {}, ) { const { providerKey, primaryModelRef, inferenceBaseUrl, inferenceApi, inferenceCompat } = getSandboxInferenceConfig(model, provider, preferredInferenceApi); + const insecureLocalUiValue = options.insecureLocalUi === true ? "1" : "0"; let dockerfile = fs.readFileSync(dockerfilePath, "utf8"); dockerfile = dockerfile.replace(/^ARG NEMOCLAW_MODEL=.*$/m, `ARG NEMOCLAW_MODEL=${model}`); dockerfile = dockerfile.replace( @@ -1034,6 +1036,10 @@ function patchStagedDockerfile( `ARG NEMOCLAW_PRIMARY_MODEL_REF=${primaryModelRef}`, ); dockerfile = dockerfile.replace(/^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}`, @@ -1053,6 +1059,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 { @@ -2313,6 +2335,7 @@ async function createSandbox( provider, preferredInferenceApi = null, sandboxNameOverride = null, + options = {}, ) { step(5, 7, "Creating sandbox"); @@ -2371,6 +2394,10 @@ async function createSandbox( // --gpu is intentionally omitted. See comment in startGateway(). console.log(` Creating sandbox '${sandboxName}' (this takes a few minutes on first run)...`); + const insecureLocalUiEnabled = + typeof options.insecureLocalUi === "boolean" + ? options.insecureLocalUi + : isInsecureLocalUiRequested(); patchStagedDockerfile( stagedDockerfile, model, @@ -2378,6 +2405,7 @@ async function createSandbox( 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 @@ -3680,6 +3708,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" }, @@ -3840,6 +3926,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 = @@ -3912,29 +4000,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 }); - sandboxName = await createSandbox(gpu, model, provider, preferredInferenceApi, sandboxName); - onboardSession.markStepComplete("sandbox", { sandboxName, provider, model, nimContainer }); - } + ({ sandboxName, insecureLocalUi } = await ensureOnboardSandbox({ + gpu, + model, + provider, + preferredInferenceApi, + sandboxName, + nimContainer, + insecureLocalUi, + resume, + session, + })); const resumeOpenclaw = resume && sandboxName && isOpenclawReady(sandboxName); if (resumeOpenclaw) { @@ -4026,6 +4102,7 @@ module.exports = { onboardSession, printSandboxCreateRecoveryHints, pruneStaleSandboxEntry, + resolveInsecureLocalUiPreference, repairRecordedSandbox, recoverGatewayRuntime, resolveDashboardForwardTarget, 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 1ecc18ebda4..e7ddac120f8 100755 --- a/install.sh +++ b/install.sh @@ -206,6 +206,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" @@ -222,6 +226,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..391d2dc145d 100644 --- a/k8s/README.md +++ b/k8s/README.md @@ -2,6 +2,13 @@ > **⚠️ 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`, `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. --- @@ -17,8 +24,16 @@ 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 namespace nemoclaw +kubectl create secret generic nemoclaw-compatible-api-key \ + -n nemoclaw \ + --from-literal=api-key='' kubectl apply -f https://raw.githubusercontent.com/NVIDIA/NemoClaw/main/k8s/nemoclaw-k8s.yaml ``` @@ -48,9 +63,21 @@ 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` | 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 in Step 1: + +```bash +kubectl create secret generic nemoclaw-compatible-api-key \ + -n nemoclaw \ + --from-literal=api-key='' +``` ### Example: Custom Endpoint @@ -60,8 +87,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 edc8748cf56..e840088a1d5 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,13 @@ spec: # Workspace - runs official NemoClaw installer - name: workspace image: node:22 + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + seccompProfile: + type: RuntimeDefault command: - bash - -c @@ -61,9 +70,20 @@ 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 -fsSL https://nvidia.com/nemoclaw.sh | bash + 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 # Keep running after onboard echo "Onboard complete. Container staying alive." @@ -82,13 +102,17 @@ 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 value: "my-assistant" - name: NEMOCLAW_POLICY_MODE - value: "skip" + value: "suggested" volumeMounts: - name: docker-socket mountPath: /var/run 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\}/); + }); +}); diff --git a/test/onboard-session.test.js b/test/onboard-session.test.js index 43537b53291..09e590b2d41 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,12 +110,34 @@ 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"); 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", { @@ -202,13 +225,16 @@ 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 267696119bd..dfbd2953058 100644 --- a/test/onboard.test.js +++ b/test/onboard.test.js @@ -100,25 +100,99 @@ 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"), ); try { + 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); 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 { + 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 }); + } + }); + + 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 }); } @@ -1074,7 +1148,11 @@ const { setupInference } = require(${onboardPath}); assert.match( source, - /startRecordedStep\("sandbox", \{ sandboxName, provider, model \}\);\s*sandboxName = await createSandbox\(gpu, model, provider, preferredInferenceApi, sandboxName\);/, + /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]*\}\)\);/, ); }); diff --git a/test/security-configuration-hardening.test.js b/test/security-configuration-hardening.test.js new file mode 100644 index 00000000000..7e4d2494965 --- /dev/null +++ b/test/security-configuration-hardening.test.js @@ -0,0 +1,53 @@ +// 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 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); + 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"); + 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"); + 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(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}"'); + 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.toMatch(/curl\b[^\n|]*\|\s*(?:ba|z|k)?sh\b/i); + }); +});