diff --git a/Dockerfile b/Dockerfile index 309ba1d8feb..25891bbfe56 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,6 +27,15 @@ RUN (apt-get remove --purge -y gcc gcc-12 g++ g++-12 cpp cpp-12 make \ && apt-get autoremove --purge -y \ && rm -rf /var/lib/apt/lists/* +# Apply config overrides shim to the pre-installed OpenClaw CLI. +# The shim adds OPENCLAW_CONFIG_OVERRIDES_FILE support: a deep-merged overlay +# file that enables runtime config changes without modifying the frozen +# openclaw.json. Applied to ALL dist entry points because the bundler +# duplicates resolveConfigForRead across multiple chunks. +COPY patches/apply-openclaw-shim.js /tmp/apply-openclaw-shim.js +RUN node /tmp/apply-openclaw-shim.js /usr/local/lib/node_modules/openclaw \ + && rm /tmp/apply-openclaw-shim.js + # Copy built plugin and blueprint into the sandbox COPY --from=builder /opt/nemoclaw/dist/ /opt/nemoclaw/dist/ COPY nemoclaw/openclaw.plugin.json /opt/nemoclaw/ @@ -67,7 +76,8 @@ ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \ CHAT_UI_URL=${CHAT_UI_URL} \ NEMOCLAW_INFERENCE_BASE_URL=${NEMOCLAW_INFERENCE_BASE_URL} \ NEMOCLAW_INFERENCE_API=${NEMOCLAW_INFERENCE_API} \ - NEMOCLAW_INFERENCE_COMPAT_B64=${NEMOCLAW_INFERENCE_COMPAT_B64} + NEMOCLAW_INFERENCE_COMPAT_B64=${NEMOCLAW_INFERENCE_COMPAT_B64} \ + OPENCLAW_CONFIG_OVERRIDES_FILE=/sandbox/.openclaw-data/config-overrides.json5 WORKDIR /sandbox USER sandbox @@ -103,6 +113,7 @@ config = { \ 'agents': {'defaults': {'model': {'primary': primary_model_ref}}}, \ 'models': {'mode': 'merge', 'providers': providers}, \ 'channels': {'defaults': {'configWrites': False}}, \ + 'ui': {'assistant': {'name': 'Lew Alcindor'}}, \ 'gateway': { \ 'mode': 'local', \ 'controlUi': { \ diff --git a/bin/lib/config-set.js b/bin/lib/config-set.js new file mode 100644 index 00000000000..abd3e95fae1 --- /dev/null +++ b/bin/lib/config-set.js @@ -0,0 +1,223 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Runtime config overrides for sandboxed OpenClaw instances. +// Reads/writes the config-overrides.json5 file in the sandbox's writable +// partition. Changes trigger OpenClaw's config file watcher for hot-reload. + +const fs = require("fs"); +const path = require("path"); +const { ROOT, runCapture, shellQuote } = require("./runner"); + +const OVERRIDES_PATH = "/sandbox/.openclaw-data/config-overrides.json5"; + +/** + * Load the allow-list of mutable config fields from the policy YAML. + * Returns a Set of dotted-path keys (e.g. "agents.defaults.model.primary"). + */ +function loadAllowList() { + const policyPath = path.join(ROOT, "nemoclaw-blueprint", "policies", "openclaw-sandbox.yaml"); + if (!fs.existsSync(policyPath)) return new Set(); + + const yaml = fs.readFileSync(policyPath, "utf-8"); + // Extract everything after "config_overrides:" to end of file + const startIdx = yaml.indexOf("\nconfig_overrides:\n"); + if (startIdx === -1) return new Set(); + const block = yaml.slice(startIdx); + + const keys = new Set(); + // Match top-level entries: exactly 2-space indent, dotted path, colon + const entryPattern = /^ {2}([\w.]+):/gm; + let m; + while ((m = entryPattern.exec(block)) !== null) { + // Skip "default:" which is a value key, not an entry key + if (m[1] === "default") continue; + keys.add(m[1]); + } + return keys; +} + +/** + * Run a script inside the sandbox via `sandbox connect` with stdin piping. + * This is the same mechanism onboard uses — no `exec` command needed. + */ +function _sandboxRun(sandboxName, script) { + const os = require("os"); + const tmpFile = path.join(os.tmpdir(), `nemoclaw-cfg-${Date.now()}.sh`); + fs.writeFileSync(tmpFile, script + "\nexit\n", { mode: 0o600 }); + try { + return runCapture( + `openshell sandbox connect ${shellQuote(sandboxName)} < ${shellQuote(tmpFile)} 2>&1`, + { ignoreError: true } + ); + } finally { + fs.unlinkSync(tmpFile); + } +} + +/** + * Read the current overrides file from inside the sandbox via download. + */ +function readOverrides(sandboxName) { + const os = require("os"); + const tmpDir = path.join(os.tmpdir(), `nemoclaw-dl-${Date.now()}`); + try { + const gwFlag = process.env.OPENSHELL_GATEWAY ? `-g ${shellQuote(process.env.OPENSHELL_GATEWAY)}` : ""; + runCapture( + `openshell sandbox download ${gwFlag} ${shellQuote(sandboxName)} ${OVERRIDES_PATH} ${shellQuote(tmpDir)} 2>&1`, + { ignoreError: true } + ); + const dlFile = path.join(tmpDir, "config-overrides.json5"); + if (!fs.existsSync(dlFile)) return {}; + const raw = fs.readFileSync(dlFile, "utf-8"); + return JSON.parse(raw); + } catch { + return {}; + } finally { + try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) { /* cleanup best-effort */ } + } +} + +/** + * Write the overrides object back into the sandbox via file upload. + * sandbox connect sessions can't write to the filesystem (different mount + * namespace), so we use openshell sandbox upload instead. + */ +function writeOverrides(sandboxName, overrides) { + const os = require("os"); + const json = JSON.stringify(overrides, null, 2); + const tmpFile = path.join(os.tmpdir(), "config-overrides.json5"); + fs.writeFileSync(tmpFile, json); + try { + const gwFlag = process.env.OPENSHELL_GATEWAY ? `-g ${shellQuote(process.env.OPENSHELL_GATEWAY)}` : ""; + runCapture( + `openshell sandbox upload ${gwFlag} ${shellQuote(sandboxName)} ${shellQuote(tmpFile)} /sandbox/.openclaw-data/ 2>&1`, + { ignoreError: false } + ); + } finally { + fs.unlinkSync(tmpFile); + } +} + +/** + * Set a value at a dotted path in a nested object. + */ +function setNestedValue(obj, dottedPath, value) { + const parts = dottedPath.split("."); + let current = obj; + for (let i = 0; i < parts.length - 1; i++) { + if (!(parts[i] in current) || typeof current[parts[i]] !== "object") { + current[parts[i]] = {}; + } + current = current[parts[i]]; + } + current[parts[parts.length - 1]] = value; +} + +/** + * Get a value at a dotted path from a nested object. + */ +function getNestedValue(obj, dottedPath) { + const parts = dottedPath.split("."); + let current = obj; + for (const part of parts) { + if (current == null || typeof current !== "object") return undefined; + current = current[part]; + } + return current; +} + +/** + * Parse a string value into the appropriate JS type. + */ +function parseValue(raw) { + if (raw === "true") return true; + if (raw === "false") return false; + if (raw === "null") return null; + if (!isNaN(raw) && raw !== "") return Number(raw); + // Try JSON (for arrays/objects) + try { + const parsed = JSON.parse(raw); + if (typeof parsed === "object") return parsed; + } catch { /* not JSON, treat as string */ } + return raw; +} + +/** + * nemoclaw config-set --key --value + */ +function configSet(sandboxName, args) { + let key = null; + let value = null; + + for (let i = 0; i < args.length; i++) { + if (args[i] === "--key" && i + 1 < args.length) { + key = args[++i]; + } else if (args[i] === "--value" && i + 1 < args.length) { + value = args[++i]; + } + } + + if (!key || value === null) { + console.error(" Usage: nemoclaw config-set --key --value "); + console.error(" Example: nemoclaw my-assistant config-set --key agents.defaults.model.primary --value 'inference/new-model'"); + process.exit(1); + } + + // Security: block gateway.* regardless of allow-list + if (key.startsWith("gateway.") || key === "gateway") { + console.error(` Refused: gateway.* fields are immutable (security-enforced).`); + process.exit(1); + } + + // Validate against allow-list + const allowList = loadAllowList(); + if (allowList.size > 0 && !allowList.has(key)) { + console.error(` Refused: '${key}' is not in the config_overrides allow-list.`); + console.error(` Allowed keys: ${[...allowList].join(", ")}`); + process.exit(1); + } + + const overrides = readOverrides(sandboxName); + const parsedValue = parseValue(value); + setNestedValue(overrides, key, parsedValue); + writeOverrides(sandboxName, overrides); + + console.log(` ✓ Set ${key} = ${JSON.stringify(parsedValue)}`); + console.log(` OpenClaw will hot-reload the change automatically.`); +} + +/** + * nemoclaw config-get [--key ] + */ +function configGet(sandboxName, args) { + let key = null; + + for (let i = 0; i < args.length; i++) { + if (args[i] === "--key" && i + 1 < args.length) { + key = args[++i]; + } + } + + const overrides = readOverrides(sandboxName); + + if (key) { + const val = getNestedValue(overrides, key); + if (val === undefined) { + console.log(` ${key}: (not set — using frozen config default)`); + } else { + console.log(` ${key}: ${JSON.stringify(val)}`); + } + } else { + // Show all overrides + if (Object.keys(overrides).length === 0) { + console.log(" No runtime config overrides active."); + console.log(" All values are from the frozen openclaw.json defaults."); + } else { + console.log(" Active runtime config overrides:"); + console.log(JSON.stringify(overrides, null, 2).split("\n").map(l => ` ${l}`).join("\n")); + } + } +} + +module.exports = { configSet, configGet, loadAllowList, OVERRIDES_PATH }; diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index e58c64502dc..8569fea433e 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -1516,6 +1516,36 @@ async function preflight() { console.log(" Add that export to your shell profile, or open a new terminal before running openshell directly."); } + // Enforce min_openshell_version from blueprint.yaml + const installedVersion = getInstalledOpenshellVersion(); + if (installedVersion) { + const blueprintPath = path.join(ROOT, "nemoclaw-blueprint", "blueprint.yaml"); + if (fs.existsSync(blueprintPath)) { + const blueprintRaw = fs.readFileSync(blueprintPath, "utf-8"); + const minMatch = blueprintRaw.match(/min_openshell_version:\s*"([^"]+)"/); + if (minMatch) { + const minRequired = minMatch[1]; + const vGte = (a, b) => { + const pa = a.split(".").map(Number); + const pb = b.split(".").map(Number); + for (let i = 0; i < 3; i++) { + if ((pa[i] || 0) > (pb[i] || 0)) return true; + if ((pa[i] || 0) < (pb[i] || 0)) return false; + } + return true; + }; + if (!vGte(installedVersion, minRequired)) { + console.error(""); + console.error(` !! OpenShell ${installedVersion} is below the minimum required version ${minRequired}.`); + console.error(` Please upgrade: https://github.com/NVIDIA/OpenShell/releases`); + console.error(""); + process.exit(1); + } + console.log(` ✓ openshell version ${installedVersion} meets minimum ${minRequired}`); + } + } + } + // Clean up stale or unnamed NemoClaw gateway state before checking ports. // A healthy named gateway can be reused later in onboarding, so avoid // tearing it down here. If some other gateway is active, do not treat it @@ -1667,12 +1697,21 @@ async function startGatewayForRecovery(_gpu) { function getGatewayStartEnv() { const gatewayEnv = {}; const openshellVersion = getInstalledOpenshellVersion(); - const stableGatewayImage = openshellVersion - ? `ghcr.io/nvidia/openshell/cluster:${openshellVersion}` - : null; - if (stableGatewayImage && openshellVersion) { - gatewayEnv.OPENSHELL_CLUSTER_IMAGE = stableGatewayImage; - gatewayEnv.IMAGE_TAG = openshellVersion; + const versionOutput = String(runCapture("openshell -V", { ignoreError: true })).trim(); + const isDevBuild = versionOutput.includes("-dev") || versionOutput.includes("+"); + if (isDevBuild) { + // Dev/locally-built OpenShell — use the local image tag that + // `mise run cluster` / `docker-build-image.sh` produces. + // The bootstrap's ensure_image() will find it locally and skip GHCR pull. + gatewayEnv.OPENSHELL_CLUSTER_IMAGE = "openshell/cluster:dev"; + } else { + const stableGatewayImage = openshellVersion + ? `ghcr.io/nvidia/openshell/cluster:${openshellVersion}` + : null; + if (stableGatewayImage && openshellVersion) { + gatewayEnv.OPENSHELL_CLUSTER_IMAGE = stableGatewayImage; + gatewayEnv.IMAGE_TAG = openshellVersion; + } } return gatewayEnv; } @@ -1767,6 +1806,7 @@ async function createSandbox(gpu, model, provider, preferredInferenceApi = null, copyBuildContextDir(path.join(ROOT, "nemoclaw"), path.join(buildCtx, "nemoclaw")); copyBuildContextDir(path.join(ROOT, "nemoclaw-blueprint"), path.join(buildCtx, "nemoclaw-blueprint")); copyBuildContextDir(path.join(ROOT, "scripts"), path.join(buildCtx, "scripts")); + copyBuildContextDir(path.join(ROOT, "patches"), path.join(buildCtx, "patches")); // Create sandbox (use -- echo to avoid dropping into interactive shell) // Pass the base policy so sandbox starts in proxy mode (required for policy updates later) @@ -1787,7 +1827,10 @@ async function createSandbox(gpu, model, provider, preferredInferenceApi = null, // also strips any Authorization headers sent by the sandbox client. // See: crates/openshell-sandbox/src/proxy.rs (header stripping), // crates/openshell-router/src/backend.rs (server-side auth injection). - const envArgs = [formatEnvAssignment("CHAT_UI_URL", chatUiUrl)]; + const envArgs = [ + formatEnvAssignment("CHAT_UI_URL", chatUiUrl), + formatEnvAssignment("OPENCLAW_CONFIG_OVERRIDES_FILE", "/sandbox/.openclaw-data/config-overrides.json5"), + ]; const sandboxEnv = { ...process.env }; delete sandboxEnv.NVIDIA_API_KEY; const discordToken = getCredential("DISCORD_BOT_TOKEN") || process.env.DISCORD_BOT_TOKEN; @@ -1878,10 +1921,89 @@ async function createSandbox(gpu, model, provider, preferredInferenceApi = null, gpuEnabled: !!gpu, }); + // Write config overrides file from policy defaults into writable partition. + // This enables runtime config changes via `nemoclaw config set` — overrides + // are deep-merged onto the frozen openclaw.json at load time via our shim patch. + writeConfigOverridesFromPolicy(sandboxName); + console.log(` ✓ Sandbox '${sandboxName}' created`); return sandboxName; } +/** + * Read config_overrides from the policy YAML and write the defaults + * as a JSON5 overrides file into the sandbox's writable partition. + */ +function writeConfigOverridesFromPolicy(sandboxName) { + const policyPath = path.join(ROOT, "nemoclaw-blueprint", "policies", "openclaw-sandbox.yaml"); + if (!fs.existsSync(policyPath)) return; + + const yaml = fs.readFileSync(policyPath, "utf-8"); + + // Simple YAML extraction of config_overrides section. + // For a POC we parse the defaults with a lightweight approach rather than + // pulling in a full YAML parser at this layer (pyyaml is only in Docker). + const startIdx = yaml.indexOf("\nconfig_overrides:\n"); + if (startIdx === -1) return; + const overridesBlock = yaml.slice(startIdx); + const overrides = {}; + + // Parse dotted-path keys and their default values. + // Each entry looks like: + // agents.defaults.model.primary: + // default: "inference/nvidia/nemotron-3-super-120b-a12b" + const entryPattern = /^ {2}([\w.]+):\s*\n\s+default:\s*(.*)/gm; + let match; + while ((match = entryPattern.exec(overridesBlock)) !== null) { + const keyPath = match[1]; + const rawValue = match[2].trim(); + + // Parse scalar values from YAML. + /** @type {string|boolean|number} */ + let parsed; + if (rawValue.startsWith('"') || rawValue.startsWith("'")) { + parsed = rawValue.replace(/^["']|["']$/g, ""); + } else if (rawValue === "false" || rawValue === "true") { + parsed = rawValue === "true"; + } else if (!isNaN(Number(rawValue)) && rawValue !== "") { + parsed = Number(rawValue); + } else { + parsed = rawValue; + } + // For array/object defaults (multi-line), skip for now — the Dockerfile + // bakes these. Only scalar overrides are written to the overrides file. + if (typeof parsed === "string" || typeof parsed === "boolean" || typeof parsed === "number") { + setNestedValue(overrides, keyPath, parsed); + } + } + + if (Object.keys(overrides).length === 0) return; + + const json = JSON.stringify(overrides, null, 2); + const script = `cat > /sandbox/.openclaw-data/config-overrides.json5 <<'EOF_OVERRIDES'\n${json}\nEOF_OVERRIDES\nexit\n`; + const scriptFile = writeSandboxConfigSyncFile(script); + run(`openshell sandbox connect "${sandboxName}" < ${shellQuote(scriptFile)}`, { ignoreError: true }); + try { fs.unlinkSync(scriptFile); } catch { /* cleanup best-effort */ } + console.log(" ✓ Config overrides file written to sandbox"); +} + +/** + * Set a value at a dotted path in a nested object. + * e.g. setNestedValue(obj, "agents.defaults.model.primary", "foo") + * creates { agents: { defaults: { model: { primary: "foo" } } } } + */ +function setNestedValue(obj, dottedPath, value) { + const parts = dottedPath.split("."); + let current = obj; + for (let i = 0; i < parts.length - 1; i++) { + if (!(parts[i] in current) || typeof current[parts[i]] !== "object") { + current[parts[i]] = {}; + } + current = current[parts[i]]; + } + current[parts[parts.length - 1]] = value; +} + // ── Step 4: NIM ────────────────────────────────────────────────── // eslint-disable-next-line complexity diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index 00a430b1d04..58038532fb4 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -32,6 +32,7 @@ const { const registry = require("./lib/registry"); const nim = require("./lib/nim"); const policies = require("./lib/policies"); +const { configSet, configGet } = require("./lib/config-set"); const { parseGatewayInference } = require("./lib/inference-config"); // ── Global commands ────────────────────────────────────────────── @@ -731,6 +732,10 @@ function help() { nemoclaw policy-add Add a network or filesystem policy preset nemoclaw policy-list List presets ${D}(● = applied)${R} + ${G}Runtime Config:${R} + nemoclaw config-set Set a mutable config field ${D}(--key K --value V)${R} + nemoclaw config-get Show active config overrides ${D}(--key K for one)${R} + ${G}Deploy:${R} nemoclaw deploy Deploy to a Brev VM and start services @@ -806,10 +811,12 @@ const [cmd, ...args] = process.argv.slice(2); case "logs": sandboxLogs(cmd, actionArgs.includes("--follow")); break; case "policy-add": await sandboxPolicyAdd(cmd); break; case "policy-list": sandboxPolicyList(cmd); break; + case "config-set": configSet(cmd, actionArgs); break; + case "config-get": configGet(cmd, actionArgs); break; case "destroy": await sandboxDestroy(cmd, actionArgs); break; default: console.error(` Unknown action: ${action}`); - console.error(` Valid actions: connect, status, logs, policy-add, policy-list, destroy`); + console.error(` Valid actions: connect, status, logs, policy-add, policy-list, config-set, config-get, destroy`); process.exit(1); } return; diff --git a/config-mutability-remaining-work.md b/config-mutability-remaining-work.md new file mode 100644 index 00000000000..76e42144012 --- /dev/null +++ b/config-mutability-remaining-work.md @@ -0,0 +1,59 @@ +# Config Mutability — Remaining Work + +Tracked on PR #940. These three items must be resolved before merge. + +## 1. Approved config chunks rewrite overrides file on every poll cycle + +**Problem:** After a config chunk is approved in the TUI, `apply_approved_config_chunks` in the sandbox supervisor rewrites `config-overrides.json5` every 10 seconds forever. The approved chunk stays in the gateway's draft_policy_chunks table with status `approved`, so every poll cycle finds it again and rewrites the same file. + +**Visible symptom:** The TUI logs show `Config apply: wrote approved config overrides chunks=1` repeating every 10 seconds indefinitely. + +**Root cause:** `apply_approved_config_chunks` (in `crates/openshell-sandbox/src/lib.rs`) queries for approved config chunks, merges them, and writes the file — but never marks the chunks as consumed or compares against the current file contents. + +**Fix options (pick one):** + +- **Option A — Mark consumed:** After successfully writing the overrides file, call a new gRPC method (e.g., `AcknowledgeConfigChunks`) that updates the chunk status from `approved` to `applied`. The query filters for `approved` only, so `applied` chunks won't be returned on the next poll. +- **Option B — Skip if unchanged:** Before writing, read the existing `config-overrides.json5`, compare the JSON content. If identical, skip the write and the log. Simple, no server-side changes, but the chunk stays `approved` forever (clutters the draft table). +- **Option C — Clear after apply:** Delete the approved config chunks from the draft table after writing. Clean, but loses the audit trail of what was approved. + +**Recommendation:** Option A (mark consumed) preserves the audit trail and stops the repeated writes. + +## 2. TUI has no detail view for config rule changes + +**Problem:** When a `CONFIG` chunk appears in the TUI's "Rules & Config" list, pressing Enter on it shows the standard network rule detail view — which is empty/meaningless for config chunks (no host, no port, no proposed_rule). The user can approve/reject it but can't see WHAT config change is being requested. + +**What the user needs to see:** + +- The config key (e.g., `agents.defaults.model.primary`) +- The proposed value (e.g., `inference/nvidia/nemotron-3-nano-30b-a3b`) +- The rationale field (which contains the nested JSON override) + +**Where to fix:** `crates/openshell-tui/src/ui/sandbox_draft.rs` — the detail view rendering. When `chunk.rule_name.starts_with("config:")`: + +- Show the config key (strip `config:` prefix from `rule_name`) +- Parse the `rationale` field as JSON and pretty-print the proposed override +- Hide the network-specific fields (host, port, endpoints, binary) + +## 3. E2E demo should test system prompt change, not just inference model + +**Problem:** The current E2E test and POC demo change `agents.defaults.model.primary` — an inference routing field. This proves the plumbing works but misses the actual use case: an agent changing its own system prompt at runtime through the approval flow. + +**What the test should do:** + +1. Start with a known system prompt (e.g., `"You are a helpful assistant"`) +2. The agent (or test harness simulating the agent) writes a config request to change the system prompt to something distinctive (e.g., `"You are a pirate. Always respond in pirate speak."`) +3. The scanner picks it up, submits as a CONFIG PolicyChunk +4. The TUI shows the proposed system prompt change for approval +5. After approval, the overrides file is written with the new prompt +6. The shim merges it onto the frozen config +7. A prompt is sent to the agent and the response reflects the new system prompt + +**Config key:** The system prompt lives at `agents.defaults.systemPrompt` (or the equivalent path in openclaw.json — verify against the actual schema). + +**Why this matters:** Changing the inference model is an operator concern. Changing the system prompt is an AGENT concern — the agent wants to evolve its own behavior, and the operator approves or denies that evolution. That's the core value proposition of this feature: controlled agent self-modification. + +**Files to update:** + +- `test/config-mutability-e2e.test.ts` — Phase 4 should set a system prompt, not a model +- `scripts/poc-round-trip-test.sh` — Step 4 should write a system prompt change request +- `scripts/setup-e2e-demo.sh` — no changes needed (infrastructure is the same) diff --git a/nemoclaw-blueprint/blueprint.yaml b/nemoclaw-blueprint/blueprint.yaml index f55f9f651d2..3c5da790cd3 100644 --- a/nemoclaw-blueprint/blueprint.yaml +++ b/nemoclaw-blueprint/blueprint.yaml @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 version: "0.1.0" -min_openshell_version: "0.1.0" +min_openshell_version: "0.0.15" min_openclaw_version: "2026.3.0" digest: "" # Computed at release time diff --git a/nemoclaw-blueprint/policies/openclaw-sandbox.yaml b/nemoclaw-blueprint/policies/openclaw-sandbox.yaml index 4b877aa132d..64abe078b60 100644 --- a/nemoclaw-blueprint/policies/openclaw-sandbox.yaml +++ b/nemoclaw-blueprint/policies/openclaw-sandbox.yaml @@ -43,6 +43,11 @@ process: run_as_user: sandbox run_as_group: sandbox +# Note: tls: terminate annotations removed — OpenShell >= 0.0.15 auto-detects +# TLS and terminates unconditionally for credential injection (PR #544). +# Endpoints with permissive wildcard rules (method: "*") simplified to L4-only. +# Restrictive L7 rules (GET-only, path-scoped) retained for enforcement. + network_policies: claude_code: name: claude_code @@ -51,17 +56,12 @@ network_policies: port: 443 protocol: rest enforcement: enforce - tls: terminate rules: - allow: { method: "*", path: "/**" } - host: statsig.anthropic.com port: 443 - rules: - - allow: { method: "*", path: "/**" } - host: sentry.io port: 443 - rules: - - allow: { method: "*", path: "/**" } binaries: - { path: /usr/local/bin/claude } @@ -72,14 +72,12 @@ network_policies: port: 443 protocol: rest enforcement: enforce - tls: terminate rules: - allow: { method: "*", path: "/**" } - host: inference-api.nvidia.com port: 443 protocol: rest enforcement: enforce - tls: terminate rules: - allow: { method: "*", path: "/**" } binaries: @@ -112,7 +110,6 @@ network_policies: port: 443 protocol: rest enforcement: enforce - tls: terminate rules: - allow: { method: GET, path: "/**" } - allow: { method: POST, path: "/**" } @@ -126,7 +123,6 @@ network_policies: port: 443 protocol: rest enforcement: enforce - tls: terminate rules: - allow: { method: GET, path: "/**" } - allow: { method: POST, path: "/**" } @@ -140,7 +136,6 @@ network_policies: port: 443 protocol: rest enforcement: enforce - tls: terminate rules: - allow: { method: GET, path: "/**" } binaries: @@ -167,7 +162,6 @@ network_policies: port: 443 protocol: rest enforcement: enforce - tls: terminate rules: - allow: { method: GET, path: "/bot*/**" } - allow: { method: POST, path: "/bot*/**" } @@ -181,7 +175,6 @@ network_policies: port: 443 protocol: rest enforcement: enforce - tls: terminate rules: - allow: { method: GET, path: "/**" } - allow: { method: POST, path: "/**" } @@ -189,7 +182,6 @@ network_policies: port: 443 protocol: rest enforcement: enforce - tls: terminate rules: - allow: { method: GET, path: "/**" } - allow: { method: POST, path: "/**" } @@ -197,7 +189,6 @@ network_policies: port: 443 protocol: rest enforcement: enforce - tls: terminate rules: - allow: { method: GET, path: "/**" } binaries: diff --git a/nemoclaw-blueprint/policies/presets/discord.yaml b/nemoclaw-blueprint/policies/presets/discord.yaml index 8ffd1bc63c9..555e6078808 100644 --- a/nemoclaw-blueprint/policies/presets/discord.yaml +++ b/nemoclaw-blueprint/policies/presets/discord.yaml @@ -13,7 +13,6 @@ network_policies: port: 443 protocol: rest enforcement: enforce - tls: terminate rules: - allow: { method: GET, path: "/**" } - allow: { method: POST, path: "/**" } @@ -31,7 +30,6 @@ network_policies: port: 443 protocol: rest enforcement: enforce - tls: terminate rules: - allow: { method: GET, path: "/**" } # Media/attachment access (read-only, proxied through Discord CDN) @@ -39,7 +37,6 @@ network_policies: port: 443 protocol: rest enforcement: enforce - tls: terminate rules: - allow: { method: GET, path: "/**" } binaries: diff --git a/nemoclaw-blueprint/policies/presets/docker.yaml b/nemoclaw-blueprint/policies/presets/docker.yaml index 184ca875a49..8531b4ab765 100644 --- a/nemoclaw-blueprint/policies/presets/docker.yaml +++ b/nemoclaw-blueprint/policies/presets/docker.yaml @@ -13,7 +13,6 @@ network_policies: port: 443 protocol: rest enforcement: enforce - tls: terminate rules: - allow: { method: GET, path: "/**" } - allow: { method: POST, path: "/**" } @@ -21,7 +20,6 @@ network_policies: port: 443 protocol: rest enforcement: enforce - tls: terminate rules: - allow: { method: GET, path: "/**" } - allow: { method: POST, path: "/**" } @@ -29,7 +27,6 @@ network_policies: port: 443 protocol: rest enforcement: enforce - tls: terminate rules: - allow: { method: GET, path: "/**" } - allow: { method: POST, path: "/**" } @@ -37,7 +34,6 @@ network_policies: port: 443 protocol: rest enforcement: enforce - tls: terminate rules: - allow: { method: GET, path: "/**" } - allow: { method: POST, path: "/**" } diff --git a/nemoclaw-blueprint/policies/presets/huggingface.yaml b/nemoclaw-blueprint/policies/presets/huggingface.yaml index 6462e238bca..51671be5c76 100644 --- a/nemoclaw-blueprint/policies/presets/huggingface.yaml +++ b/nemoclaw-blueprint/policies/presets/huggingface.yaml @@ -13,7 +13,6 @@ network_policies: port: 443 protocol: rest enforcement: enforce - tls: terminate rules: - allow: { method: GET, path: "/**" } - allow: { method: POST, path: "/**" } @@ -21,14 +20,12 @@ network_policies: port: 443 protocol: rest enforcement: enforce - tls: terminate rules: - allow: { method: GET, path: "/**" } - host: api-inference.huggingface.co port: 443 protocol: rest enforcement: enforce - tls: terminate rules: - allow: { method: GET, path: "/**" } - allow: { method: POST, path: "/**" } diff --git a/nemoclaw-blueprint/policies/presets/jira.yaml b/nemoclaw-blueprint/policies/presets/jira.yaml index 9e9df6741ee..af7117be85c 100644 --- a/nemoclaw-blueprint/policies/presets/jira.yaml +++ b/nemoclaw-blueprint/policies/presets/jira.yaml @@ -13,7 +13,6 @@ network_policies: port: 443 protocol: rest enforcement: enforce - tls: terminate rules: - allow: { method: GET, path: "/**" } - allow: { method: POST, path: "/**" } @@ -21,7 +20,6 @@ network_policies: port: 443 protocol: rest enforcement: enforce - tls: terminate rules: - allow: { method: GET, path: "/**" } - allow: { method: POST, path: "/**" } @@ -29,7 +27,6 @@ network_policies: port: 443 protocol: rest enforcement: enforce - tls: terminate rules: - allow: { method: GET, path: "/**" } - allow: { method: POST, path: "/**" } diff --git a/nemoclaw-blueprint/policies/presets/outlook.yaml b/nemoclaw-blueprint/policies/presets/outlook.yaml index ece3d0e0cb7..5faa22bd985 100644 --- a/nemoclaw-blueprint/policies/presets/outlook.yaml +++ b/nemoclaw-blueprint/policies/presets/outlook.yaml @@ -13,7 +13,6 @@ network_policies: port: 443 protocol: rest enforcement: enforce - tls: terminate rules: - allow: { method: GET, path: "/**" } - allow: { method: POST, path: "/**" } @@ -21,7 +20,6 @@ network_policies: port: 443 protocol: rest enforcement: enforce - tls: terminate rules: - allow: { method: GET, path: "/**" } - allow: { method: POST, path: "/**" } @@ -29,7 +27,6 @@ network_policies: port: 443 protocol: rest enforcement: enforce - tls: terminate rules: - allow: { method: GET, path: "/**" } - allow: { method: POST, path: "/**" } @@ -37,7 +34,6 @@ network_policies: port: 443 protocol: rest enforcement: enforce - tls: terminate rules: - allow: { method: GET, path: "/**" } - allow: { method: POST, path: "/**" } diff --git a/nemoclaw-blueprint/policies/presets/slack.yaml b/nemoclaw-blueprint/policies/presets/slack.yaml index e2a7c4706b9..e1fc94ac8a1 100644 --- a/nemoclaw-blueprint/policies/presets/slack.yaml +++ b/nemoclaw-blueprint/policies/presets/slack.yaml @@ -13,7 +13,6 @@ network_policies: port: 443 protocol: rest enforcement: enforce - tls: terminate rules: - allow: { method: GET, path: "/**" } - allow: { method: POST, path: "/**" } @@ -21,7 +20,6 @@ network_policies: port: 443 protocol: rest enforcement: enforce - tls: terminate rules: - allow: { method: GET, path: "/**" } - allow: { method: POST, path: "/**" } @@ -29,7 +27,6 @@ network_policies: port: 443 protocol: rest enforcement: enforce - tls: terminate rules: - allow: { method: GET, path: "/**" } - allow: { method: POST, path: "/**" } diff --git a/nemoclaw-blueprint/policies/presets/telegram.yaml b/nemoclaw-blueprint/policies/presets/telegram.yaml index b80d7b959f9..c745d0ed458 100644 --- a/nemoclaw-blueprint/policies/presets/telegram.yaml +++ b/nemoclaw-blueprint/policies/presets/telegram.yaml @@ -13,7 +13,6 @@ network_policies: port: 443 protocol: rest enforcement: enforce - tls: terminate rules: - allow: { method: GET, path: "/bot*/**" } - allow: { method: POST, path: "/bot*/**" } diff --git a/package-lock.json b/package-lock.json index 8b9e57e0b91..85832757c3d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -947,6 +947,14 @@ "scripts/actions/documentation" ] }, + "node_modules/@buape/carbon/node_modules/opusscript": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/opusscript/-/opusscript-0.0.8.tgz", + "integrity": "sha512-VSTi1aWFuCkRCVq+tx/BQ5q9fMnQ9pVZ3JU4UHKqTkf0ED3fKEPdr+gKAAl3IA2hj9rrP6iyq3hlcJq3HELtNQ==", + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/@buape/carbon/node_modules/prism-media": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/prism-media/-/prism-media-1.3.5.tgz", @@ -1339,6 +1347,14 @@ "url": "https://github.com/discordjs/discord.js?sponsor" } }, + "node_modules/@discordjs/voice/node_modules/opusscript": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/opusscript/-/opusscript-0.0.8.tgz", + "integrity": "sha512-VSTi1aWFuCkRCVq+tx/BQ5q9fMnQ9pVZ3JU4UHKqTkf0ED3fKEPdr+gKAAl3IA2hj9rrP6iyq3hlcJq3HELtNQ==", + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/@discordjs/voice/node_modules/prism-media": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/prism-media/-/prism-media-1.3.5.tgz", diff --git a/patches/apply-openclaw-shim.js b/patches/apply-openclaw-shim.js new file mode 100755 index 00000000000..9beacf041b4 --- /dev/null +++ b/patches/apply-openclaw-shim.js @@ -0,0 +1,56 @@ +#!/usr/bin/env node +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Apply the NemoClaw config overrides shim to all OpenClaw dist files. + +const fs = require("fs"); +const path = require("path"); + +const distDir = path.join(process.argv[2] || "/usr/local/lib/node_modules/openclaw", "dist"); + +const SHIM = ` +function _nemoClawMergeOverrides(cfg) { +\tvar _p = (typeof process !== "undefined" && process.env || {}).OPENCLAW_CONFIG_OVERRIDES_FILE; +\tif (!_p) return cfg; +\ttry { +\t\tvar _raw = require("node:fs").readFileSync(_p, "utf-8"); +\t\tvar _ov = JSON.parse(_raw); +\t\tif (_ov && typeof _ov === "object") { +\t\t\tdelete _ov.gateway; +\t\t\tvar _dm = function(t, s) { +\t\t\t\tif (t && s && typeof t === "object" && typeof s === "object" && !Array.isArray(t) && !Array.isArray(s)) { +\t\t\t\t\tvar r = Object.assign({}, t); +\t\t\t\t\tfor (var k in s) { if (Object.prototype.hasOwnProperty.call(s, k)) { r[k] = (k in r) ? _dm(r[k], s[k]) : s[k]; } } +\t\t\t\t\treturn r; +\t\t\t\t} +\t\t\t\treturn s; +\t\t\t}; +\t\t\treturn _dm(cfg, _ov); +\t\t} +\t} catch (e) { if (e.code !== "ENOENT") console.warn("[nemoclaw] config overrides error:", e.message); } +\treturn cfg; +} +`.trim(); + +const TARGET = "function resolveConfigForRead(resolvedIncludes, env) {"; +const REPLACEMENT = SHIM + "\n" + TARGET + "\n\tresolvedIncludes = _nemoClawMergeOverrides(resolvedIncludes);"; + +let patched = 0; +for (const file of fs.readdirSync(distDir)) { + if (!file.endsWith(".js")) continue; + const filePath = path.join(distDir, file); + const content = fs.readFileSync(filePath, "utf-8"); + if (!content.includes(TARGET)) continue; + + const newContent = content.replace(TARGET, REPLACEMENT); + fs.writeFileSync(filePath, newContent); + patched++; + console.log(`[nemoclaw-shim] Patched: ${file}`); +} + +console.log(`[nemoclaw-shim] Patched ${patched} files`); +if (patched === 0) { + console.error("[nemoclaw-shim] WARNING: No files patched!"); + process.exit(1); +} diff --git a/patches/apply-openclaw-shim.sh b/patches/apply-openclaw-shim.sh new file mode 100755 index 00000000000..eb3a28a1717 --- /dev/null +++ b/patches/apply-openclaw-shim.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Apply the NemoClaw config overrides shim to all OpenClaw dist files that +# contain resolveConfigForRead(). The bundler duplicates this function across +# multiple entry-point chunks, so a simple unified diff patch only catches one. +# +# The shim reads OPENCLAW_CONFIG_OVERRIDES_FILE, parses the JSON5 overlay, +# strips gateway.* keys, and deep-merges onto the frozen config. + +set -euo pipefail + +OPENCLAW_DIR="${1:-/usr/local/lib/node_modules/openclaw}" +DIST="${OPENCLAW_DIR}/dist" +PATCHED=0 + +# The shim function — injected before resolveConfigForRead +read -r -d '' SHIM <<'SHIMEOF' || true +function _nemoClawMergeOverrides(cfg) { + var _p = (typeof process !== "undefined" && process.env || {}).OPENCLAW_CONFIG_OVERRIDES_FILE; + if (!_p) return cfg; + try { + var _fs = require("node:fs"); + var _raw = _fs.readFileSync(_p, "utf-8"); + var _ov = JSON.parse(_raw); + if (_ov && typeof _ov === "object") { + delete _ov.gateway; + var _dm = function(t, s) { + if (t && s && typeof t === "object" && typeof s === "object" && !Array.isArray(t) && !Array.isArray(s)) { + var r = Object.assign({}, t); + for (var k in s) { if (Object.prototype.hasOwnProperty.call(s, k)) { r[k] = (k in r) ? _dm(r[k], s[k]) : s[k]; } } + return r; + } + return s; + }; + return _dm(cfg, _ov); + } + } catch (e) { if (e.code !== "ENOENT") console.warn("[nemoclaw] config overrides error:", e.message); } + return cfg; +} +SHIMEOF + +# Escape for sed replacement +SHIM_ESCAPED=$(printf '%s\n' "$SHIM" | sed 's/[&/\]/\\&/g; s/$/\\/') +SHIM_ESCAPED="${SHIM_ESCAPED%\\}" + +for f in "${DIST}"/*.js; do + if grep -q "function resolveConfigForRead" "$f"; then + # Insert shim function before resolveConfigForRead + sed -i "s/function resolveConfigForRead(resolvedIncludes, env) {/${SHIM}\nfunction resolveConfigForRead(resolvedIncludes, env) {\n\tresolvedIncludes = _nemoClawMergeOverrides(resolvedIncludes);/" "$f" + PATCHED=$((PATCHED + 1)) + echo "[nemoclaw-shim] Patched: $(basename "$f")" + fi +done + +echo "[nemoclaw-shim] Patched ${PATCHED} files" + +if [ "$PATCHED" -eq 0 ]; then + echo "[nemoclaw-shim] WARNING: No files patched! resolveConfigForRead not found." + exit 1 +fi diff --git a/patches/openclaw-config-overrides.patch b/patches/openclaw-config-overrides.patch new file mode 100644 index 00000000000..b95947d550b --- /dev/null +++ b/patches/openclaw-config-overrides.patch @@ -0,0 +1,50 @@ +--- a/dist/config-CO7zBdn8.js 2026-03-24 22:29:33 ++++ b/dist/config-CO7zBdn8.js 2026-03-25 13:54:12 +@@ -14376,8 +14376,36 @@ + }), + parseJson: (raw) => deps.json5.parse(raw) + }); ++} ++function _nemoClawMergeOverrides(cfg) { ++ const _p = (typeof process !== "undefined" && process.env || {}).OPENCLAW_CONFIG_OVERRIDES_FILE; ++ try { fs$1.appendFileSync("/sandbox/.openclaw-data/nemoclaw-shim.log", new Date().toISOString() + " shim called, OPENCLAW_CONFIG_OVERRIDES_FILE=" + (_p || "(not set)") + "\n"); } catch(_) {} ++ if (!_p) return cfg; ++ try { ++ const _raw = fs$1.readFileSync(_p, "utf-8"); ++ try { fs$1.appendFileSync("/sandbox/.openclaw-data/nemoclaw-shim.log", new Date().toISOString() + " read " + _raw.length + " bytes from " + _p + "\n"); } catch(_) {} ++ const _ov = JSON5.parse(_raw); ++ if (_ov && typeof _ov === "object") { ++ delete _ov.gateway; ++ const _dm = (t, s) => { ++ if (isPlainObject$2(t) && isPlainObject$2(s)) { ++ const r = { ...t }; ++ for (const k of Object.keys(s)) { r[k] = k in r ? _dm(r[k], s[k]) : s[k]; } ++ return r; ++ } ++ return s; ++ }; ++ try { fs$1.appendFileSync("/sandbox/.openclaw-data/nemoclaw-shim.log", new Date().toISOString() + " merged successfully\n"); } catch(_) {} ++ return _dm(cfg, _ov); ++ } ++ } catch (e) { ++ if (e.code === "ENOENT") { try { fs$1.appendFileSync("/sandbox/.openclaw-data/nemoclaw-shim.log", new Date().toISOString() + " ENOENT (no overrides file)\n"); } catch(_) {} } ++ else { console.warn("[nemoclaw] config overrides error:", e.message); } ++ } ++ return cfg; + } + function resolveConfigForRead(resolvedIncludes, env) { ++ resolvedIncludes = _nemoClawMergeOverrides(resolvedIncludes); + if (resolvedIncludes && typeof resolvedIncludes === "object" && "env" in resolvedIncludes) applyConfigEnvVars(resolvedIncludes, env); + const envWarnings = []; + return { +--- a/dist/gateway-cli-B-E8XzUM.js 2026-03-24 22:29:33 ++++ b/dist/gateway-cli-B-E8XzUM.js 2026-03-24 22:30:04 +@@ -2145,6 +2145,8 @@ + }, + usePolling: Boolean(process.env.VITEST) + }); ++ const _ncOverridesPath = process.env.OPENCLAW_CONFIG_OVERRIDES_FILE; ++ if (_ncOverridesPath) watcher.add(_ncOverridesPath); + watcher.on("add", schedule); + watcher.on("change", schedule); + watcher.on("unlink", schedule); diff --git a/patches/openshell-config-approval.patch b/patches/openshell-config-approval.patch new file mode 100644 index 00000000000..3ea80cec996 --- /dev/null +++ b/patches/openshell-config-approval.patch @@ -0,0 +1,512 @@ +diff --git a/crates/openshell-sandbox/src/grpc_client.rs b/crates/openshell-sandbox/src/grpc_client.rs +index 5503637..f932e82 100644 +--- a/crates/openshell-sandbox/src/grpc_client.rs ++++ b/crates/openshell-sandbox/src/grpc_client.rs +@@ -286,6 +286,25 @@ impl CachedOpenShellClient { + Ok(()) + } + ++ /// Fetch draft policy chunks (used to find approved config: chunks). ++ pub async fn get_draft_policy( ++ &self, ++ sandbox_name: &str, ++ status_filter: &str, ++ ) -> Result> { ++ let response = self ++ .client ++ .clone() ++ .get_draft_policy(openshell_core::proto::GetDraftPolicyRequest { ++ name: sandbox_name.to_string(), ++ status_filter: status_filter.to_string(), ++ }) ++ .await ++ .into_diagnostic()?; ++ ++ Ok(response.into_inner().chunks) ++ } ++ + /// Report policy load status back to the server. + pub async fn report_policy_status( + &self, +diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs +index 493e4d2..39a0f15 100644 +--- a/crates/openshell-sandbox/src/lib.rs ++++ b/crates/openshell-sandbox/src/lib.rs +@@ -573,6 +573,7 @@ pub async fn run_sandbox( + (&sandbox_id, &openshell_endpoint, &opa_engine) + { + let poll_id = id.clone(); ++ let poll_name = sandbox_name_for_agg.clone().unwrap_or_else(|| id.clone()); + let poll_endpoint = endpoint.clone(); + let poll_engine = engine.clone(); + let poll_interval_secs: u64 = std::env::var("OPENSHELL_POLICY_POLL_INTERVAL_SECS") +@@ -582,7 +583,7 @@ pub async fn run_sandbox( + + tokio::spawn(async move { + if let Err(e) = +- run_policy_poll_loop(&poll_endpoint, &poll_id, &poll_engine, poll_interval_secs) ++ run_policy_poll_loop(&poll_endpoint, &poll_id, &poll_name, &poll_engine, poll_interval_secs) + .await + { + warn!(error = %e, "Policy poll loop exited with error"); +@@ -617,6 +618,25 @@ pub async fn run_sandbox( + }) + .await; + }); ++ ++ // Config-request scanner: poll for request files in the writable partition. ++ let cfg_endpoint = endpoint.clone(); ++ let cfg_name = sandbox_name_for_agg.clone().unwrap_or_else(|| id.clone()); ++ tokio::spawn(async move { ++ let _ = std::fs::create_dir_all(CONFIG_REQUESTS_DIR); ++ // World-writable so the sandbox user (non-root) can create ++ // config request files from inside the sandbox. ++ use std::os::unix::fs::PermissionsExt; ++ let _ = std::fs::set_permissions( ++ CONFIG_REQUESTS_DIR, ++ std::fs::Permissions::from_mode(0o777), ++ ); ++ let interval = Duration::from_secs(5); ++ loop { ++ tokio::time::sleep(interval).await; ++ scan_config_requests(&cfg_endpoint, &cfg_name).await; ++ } ++ }); + } + } + +@@ -1300,11 +1320,209 @@ async fn flush_proposals_to_gateway( + Ok(()) + } + ++// --------------------------------------------------------------------------- ++// NemoClaw POC: config-request scanner + approved-config applier ++// --------------------------------------------------------------------------- ++ ++const CONFIG_REQUESTS_DIR: &str = "/sandbox/.openclaw-data/config-requests"; ++const CONFIG_OVERRIDES_PATH: &str = "/sandbox/.openclaw-data/config-overrides.json5"; ++ ++/// Scan for config-change request files and submit them as PolicyChunks. ++async fn scan_config_requests(endpoint: &str, sandbox_name: &str) { ++ use crate::grpc_client::CachedOpenShellClient; ++ use openshell_core::proto::PolicyChunk; ++ ++ let dir = std::path::Path::new(CONFIG_REQUESTS_DIR); ++ if !dir.exists() { ++ return; ++ } ++ let entries = match std::fs::read_dir(dir) { ++ Ok(e) => e, ++ Err(_) => return, ++ }; ++ ++ let mut chunks = Vec::new(); ++ let mut processed_files = Vec::new(); ++ ++ for entry in entries.flatten() { ++ let path = entry.path(); ++ if path.extension().and_then(|e| e.to_str()) != Some("json") { ++ continue; ++ } ++ let raw = match std::fs::read_to_string(&path) { ++ Ok(r) => r, ++ Err(e) => { ++ warn!(path = %path.display(), error = %e, "Failed to read config request"); ++ continue; ++ } ++ }; ++ let parsed: serde_json::Value = match serde_json::from_str(&raw) { ++ Ok(v) => v, ++ Err(e) => { ++ warn!(path = %path.display(), error = %e, "Invalid config request JSON"); ++ processed_files.push(path); ++ continue; ++ } ++ }; ++ let key = match parsed.get("key").and_then(|v| v.as_str()) { ++ Some(k) => k, ++ None => { ++ warn!(path = %path.display(), "Config request missing 'key' field"); ++ processed_files.push(path); ++ continue; ++ } ++ }; ++ if key.starts_with("gateway.") || key == "gateway" { ++ warn!(key = %key, "Config request for gateway.* blocked"); ++ processed_files.push(path); ++ continue; ++ } ++ let value = match parsed.get("value") { ++ Some(v) => v, ++ None => { ++ warn!(path = %path.display(), "Config request missing 'value' field"); ++ processed_files.push(path); ++ continue; ++ } ++ }; ++ let override_json = build_nested_json(key, value); ++ info!(key = %key, "Config change request detected, submitting as draft chunk"); ++ chunks.push(PolicyChunk { ++ id: String::new(), ++ status: "pending".to_string(), ++ rule_name: format!("config:{key}"), ++ proposed_rule: None, ++ rationale: override_json, ++ security_notes: String::new(), ++ confidence: 1.0, ++ denial_summary_ids: vec![], ++ created_at_ms: 0, ++ decided_at_ms: 0, ++ stage: "config".to_string(), ++ supersedes_chunk_id: String::new(), ++ hit_count: 1, ++ first_seen_ms: 0, ++ last_seen_ms: 0, ++ binary: String::new(), ++ }); ++ processed_files.push(path); ++ } ++ ++ if !chunks.is_empty() { ++ match CachedOpenShellClient::connect(endpoint).await { ++ Ok(client) => { ++ if let Err(e) = client ++ .submit_policy_analysis(sandbox_name, vec![], chunks, "config") ++ .await ++ { ++ warn!(error = %e, "Failed to submit config change requests"); ++ return; ++ } ++ } ++ Err(e) => { ++ warn!(error = %e, "Failed to connect to gateway for config submission"); ++ return; ++ } ++ } ++ } ++ ++ for path in processed_files { ++ let _ = std::fs::remove_file(&path); ++ } ++} ++ ++fn build_nested_json(key: &str, value: &serde_json::Value) -> String { ++ let parts: Vec<&str> = key.split('.').collect(); ++ let mut obj = value.clone(); ++ for part in parts.iter().rev() { ++ let mut map = serde_json::Map::new(); ++ map.insert((*part).to_string(), obj); ++ obj = serde_json::Value::Object(map); ++ } ++ serde_json::to_string_pretty(&obj).unwrap_or_default() ++} ++ ++/// Check for approved config: chunks and write to overrides file. ++async fn apply_approved_config_chunks( ++ endpoint: &str, ++ sandbox_name: &str, ++ applied_ids: &mut std::collections::HashSet, ++) { ++ use crate::grpc_client::CachedOpenShellClient; ++ ++ let client = match CachedOpenShellClient::connect(endpoint).await { ++ Ok(c) => c, ++ Err(e) => { ++ warn!(error = %e, "Config apply: failed to connect to gateway"); ++ return; ++ } ++ }; ++ let chunks = match client.get_draft_policy(sandbox_name, "approved").await { ++ Ok(c) => c, ++ Err(e) => { ++ debug!(error = %e, "Config apply: failed to fetch draft policy"); ++ return; ++ } ++ }; ++ let config_chunks: Vec<_> = chunks ++ .iter() ++ .filter(|c| { ++ c.rule_name.starts_with("config:") ++ && c.status == "approved" ++ && !applied_ids.contains(&c.id) ++ }) ++ .collect(); ++ if config_chunks.is_empty() { ++ return; ++ } ++ let mut merged = serde_json::Map::new(); ++ for chunk in &config_chunks { ++ if let Ok(val) = serde_json::from_str::(&chunk.rationale) { ++ if let serde_json::Value::Object(map) = val { ++ deep_merge_json(&mut merged, &map); ++ } ++ } ++ } ++ merged.remove("gateway"); ++ let json = serde_json::to_string_pretty(&serde_json::Value::Object(merged)) ++ .unwrap_or_default(); ++ if let Err(e) = std::fs::write(CONFIG_OVERRIDES_PATH, &json) { ++ warn!(error = %e, "Config apply: failed to write overrides file"); ++ return; ++ } ++ for chunk in &config_chunks { ++ applied_ids.insert(chunk.id.clone()); ++ } ++ info!( ++ chunks = config_chunks.len(), ++ "Config apply: wrote approved config overrides" ++ ); ++} ++ ++fn deep_merge_json( ++ target: &mut serde_json::Map, ++ source: &serde_json::Map, ++) { ++ for (key, value) in source { ++ match (target.get_mut(key), value) { ++ (Some(serde_json::Value::Object(t)), serde_json::Value::Object(s)) => { ++ deep_merge_json(t, s); ++ } ++ _ => { ++ target.insert(key.clone(), value.clone()); ++ } ++ } ++ } ++} ++ ++// --------------------------------------------------------------------------- ++ + /// `reload_from_proto()`. Reports load success/failure back to the server. + /// On failure, the previous engine is untouched (LKG behavior). + async fn run_policy_poll_loop( + endpoint: &str, + sandbox_id: &str, ++ sandbox_name: &str, + opa_engine: &Arc, + interval_secs: u64, + ) -> Result<()> { +@@ -1312,6 +1530,7 @@ async fn run_policy_poll_loop( + use openshell_core::proto::PolicySource; + + let client = CachedOpenShellClient::connect(endpoint).await?; ++ let mut applied_config_ids = std::collections::HashSet::new(); + let mut current_config_revision: u64 = 0; + let mut current_policy_hash = String::new(); + let mut current_settings: std::collections::HashMap< +@@ -1348,6 +1567,10 @@ async fn run_policy_poll_loop( + }; + + if result.config_revision == current_config_revision { ++ // Check for approved config chunks even when policy hasn't changed. ++ // Uses sandbox_name (not sandbox_id) because get_draft_policy ++ // resolves by name on the server side. ++ apply_approved_config_chunks(endpoint, sandbox_name, &mut applied_config_ids).await; + continue; + } + +diff --git a/crates/openshell-server/src/grpc.rs b/crates/openshell-server/src/grpc.rs +index de73da6..2a9f4ed 100644 +--- a/crates/openshell-server/src/grpc.rs ++++ b/crates/openshell-server/src/grpc.rs +@@ -1795,7 +1795,7 @@ impl OpenShell for OpenShellService { + rejection_reasons.push("chunk missing rule_name".to_string()); + continue; + } +- if chunk.proposed_rule.is_none() { ++ if chunk.proposed_rule.is_none() && !chunk.rule_name.starts_with("config:") { + rejected += 1; + rejection_reasons + .push(format!("chunk '{}' missing proposed_rule", chunk.rule_name)); +@@ -1990,9 +1990,32 @@ impl OpenShell for OpenShellService { + port = chunk.port, + hit_count = chunk.hit_count, + prev_status = %chunk.status, +- "ApproveDraftChunk: merging rule into active policy" ++ "ApproveDraftChunk: processing" + ); + ++ // Config-change chunks (rule_name starts with "config:") skip network ++ // policy merge — they are handled by NemoClaw's config overrides system. ++ if chunk.rule_name.starts_with("config:") { ++ info!( ++ sandbox_id = %sandbox_id, ++ chunk_id = %req.chunk_id, ++ rule_name = %chunk.rule_name, ++ "ApproveDraftChunk: config override approved" ++ ); ++ let now_ms = ++ current_time_ms().map_err(|e| Status::internal(format!("timestamp error: {e}")))?; ++ self.state ++ .store ++ .update_draft_chunk_status(&req.chunk_id, "approved", Some(now_ms)) ++ .await ++ .map_err(|e| Status::internal(format!("update chunk status failed: {e}")))?; ++ self.state.sandbox_watch_bus.notify(&sandbox_id); ++ return Ok(Response::new(ApproveDraftChunkResponse { ++ policy_version: 0, ++ policy_hash: String::new(), ++ })); ++ } ++ + // Merge the approved rule into the current policy (with optimistic retry). + let (version, hash) = + merge_chunk_into_policy(self.state.store.as_ref(), &sandbox_id, &chunk).await?; +diff --git a/crates/openshell-tui/src/ui/sandbox_draft.rs b/crates/openshell-tui/src/ui/sandbox_draft.rs +index 528d1c6..58e7a9e 100644 +--- a/crates/openshell-tui/src/ui/sandbox_draft.rs ++++ b/crates/openshell-tui/src/ui/sandbox_draft.rs +@@ -22,12 +22,12 @@ pub fn draw(frame: &mut Frame<'_>, app: &mut App, area: Rect) { + + let title = if pending_count > 0 { + Line::from(vec![ +- Span::styled(" Network Rules ", t.heading), ++ Span::styled(" Rules & Config ", t.heading), + Span::styled(format!(" {pending_count} pending "), t.badge), + Span::raw(" "), + ]) + } else { +- Line::from(Span::styled(" Network Rules ", t.heading)) ++ Line::from(Span::styled(" Rules & Config ", t.heading)) + }; + + let mut block = Block::default() +@@ -111,15 +111,27 @@ pub fn draw(frame: &mut Frame<'_>, app: &mut App, area: Rect) { + spans.push(Span::raw(" ")); + } + +- // Endpoint summary (host:port). +- let endpoint_str = chunk +- .proposed_rule +- .as_ref() +- .and_then(|r| r.endpoints.first()) +- .map(|ep| format!("{}:{}", ep.host, ep.port)) +- .unwrap_or_default(); ++ let is_config_chunk = chunk.rule_name.starts_with("config:"); + +- spans.push(Span::styled(&chunk.rule_name, name_style)); ++ let endpoint_str = if is_config_chunk { ++ String::new() ++ } else { ++ chunk ++ .proposed_rule ++ .as_ref() ++ .and_then(|r| r.endpoints.first()) ++ .map(|ep| format!("{}:{}", ep.host, ep.port)) ++ .unwrap_or_default() ++ }; ++ ++ if is_config_chunk { ++ spans.push(Span::styled("CONFIG", t.status_warn)); ++ spans.push(Span::styled(" ", t.muted)); ++ let config_key = chunk.rule_name.strip_prefix("config:").unwrap_or(&chunk.rule_name); ++ spans.push(Span::styled(config_key, name_style)); ++ } else { ++ spans.push(Span::styled(&chunk.rule_name, name_style)); ++ } + if !endpoint_str.is_empty() { + spans.push(Span::styled(" ", t.muted)); + spans.push(Span::styled(endpoint_str, t.accent)); +@@ -181,6 +193,103 @@ pub fn draw_detail_popup( + _ => t.muted, + }; + ++ // Config-change chunks get a dedicated detail view. ++ if chunk.rule_name.starts_with("config:") { ++ let config_key = chunk ++ .rule_name ++ .strip_prefix("config:") ++ .unwrap_or(&chunk.rule_name); ++ ++ let block = Block::default() ++ .title(Span::styled( ++ format!(" CONFIG: {config_key} "), ++ t.heading, ++ )) ++ .borders(Borders::ALL) ++ .border_style(t.accent) ++ .padding(Padding::new(1, 1, 0, 0)); ++ ++ let mut lines: Vec> = vec![ ++ Line::from(vec![ ++ Span::styled("Status: ", t.muted), ++ Span::styled(&chunk.status, status_style), ++ ]), ++ Line::from(vec![ ++ Span::styled("Confidence: ", t.muted), ++ Span::styled(format!("{:.0}%", chunk.confidence * 100.0), t.text), ++ ]), ++ Line::from(""), ++ Line::from(vec![ ++ Span::styled("Config Key: ", t.muted), ++ Span::styled(config_key, t.accent), ++ ]), ++ ]; ++ ++ // Proposed override (pretty-printed JSON from rationale). ++ if !chunk.rationale.is_empty() { ++ lines.push(Line::from("")); ++ lines.push(Line::from(Span::styled( ++ "Proposed Override:", ++ t.muted, ++ ))); ++ for json_line in chunk.rationale.lines() { ++ lines.push(Line::from(vec![ ++ Span::raw(" "), ++ Span::styled(json_line, t.text), ++ ])); ++ } ++ } ++ ++ // Security notes. ++ if !chunk.security_notes.is_empty() { ++ lines.push(Line::from("")); ++ lines.push(Line::from(vec![Span::styled( ++ format!("! {}", chunk.security_notes), ++ t.status_warn.add_modifier(Modifier::BOLD), ++ )])); ++ } ++ ++ // Action hints. ++ lines.push(Line::from("")); ++ let mut hint_spans: Vec> = Vec::new(); ++ match chunk.status.as_str() { ++ "pending" => { ++ hint_spans.extend([ ++ Span::styled("[a]", t.key_hint), ++ Span::styled(" Approve ", t.text), ++ Span::styled("[x]", t.key_hint), ++ Span::styled(" Reject ", t.text), ++ ]); ++ } ++ "approved" => { ++ hint_spans.extend([ ++ Span::styled("[x]", t.key_hint), ++ Span::styled(" Revoke ", t.text), ++ ]); ++ } ++ "rejected" => { ++ hint_spans.extend([ ++ Span::styled("[a]", t.key_hint), ++ Span::styled(" Approve ", t.text), ++ ]); ++ } ++ _ => {} ++ } ++ hint_spans.extend([ ++ Span::styled("[Esc]", t.muted), ++ Span::styled(" Close", t.muted), ++ ]); ++ lines.push(Line::from(hint_spans)); ++ ++ frame.render_widget( ++ Paragraph::new(lines) ++ .block(block) ++ .wrap(Wrap { trim: false }), ++ popup_area, ++ ); ++ return; ++ } ++ + let block = Block::default() + .title(Span::styled(format!(" {} ", chunk.rule_name), t.heading)) + .borders(Borders::ALL) diff --git a/scripts/install-openshell.sh b/scripts/install-openshell.sh index 2cd6934cc95..248e01bd669 100755 --- a/scripts/install-openshell.sh +++ b/scripts/install-openshell.sh @@ -33,8 +33,11 @@ esac info "Detected $OS_LABEL ($ARCH_LABEL)" -# Minimum version required for cgroup v2 fix (NVIDIA/OpenShell#329) -MIN_VERSION="0.0.7" +# Minimum version required for: +# - auto-TLS termination and credential injection (NVIDIA/OpenShell#544) +# - security hardening SEC-002–010 (NVIDIA/OpenShell#548) +# - runtime settings channel (NVIDIA/OpenShell#474) +MIN_VERSION="0.0.15" version_gte() { # Returns 0 (true) if $1 >= $2 — portable, no sort -V (BSD compat) diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index e3a3dc21116..1ba84cddd28 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -63,6 +63,11 @@ case "${1:-}" in esac NEMOCLAW_CMD=("$@") CHAT_UI_URL="${CHAT_UI_URL:-http://127.0.0.1:18789}" + +# Config overrides file: the OpenClaw shim patch reads this and deep-merges +# onto the frozen openclaw.json. Set unconditionally so the shim is active +# regardless of how the sandbox was created. +export OPENCLAW_CONFIG_OVERRIDES_FILE=/sandbox/.openclaw-data/config-overrides.json5 PUBLIC_PORT=18789 OPENCLAW="$(command -v openclaw)" # Resolve once, use absolute path everywhere @@ -297,6 +302,11 @@ if [ "$(id -u)" -ne 0 ]; then fi write_auth_profile + # Create empty config overrides file (non-root path) + if [ -n "${OPENCLAW_CONFIG_OVERRIDES_FILE:-}" ] && [ ! -f "${OPENCLAW_CONFIG_OVERRIDES_FILE}" ]; then + echo '{}' >"${OPENCLAW_CONFIG_OVERRIDES_FILE}" + fi + if [ ${#NEMOCLAW_CMD[@]} -gt 0 ]; then exec "${NEMOCLAW_CMD[@]}" fi @@ -325,6 +335,14 @@ fi # Verify config integrity before starting anything verify_config_integrity +# Create empty config overrides file so the shim has a valid target on first +# load. The file lives in the writable partition and can be updated at +# runtime via `nemoclaw config-set` or `openshell sandbox upload`. +if [ -n "${OPENCLAW_CONFIG_OVERRIDES_FILE:-}" ] && [ ! -f "${OPENCLAW_CONFIG_OVERRIDES_FILE}" ]; then + echo '{}' >"${OPENCLAW_CONFIG_OVERRIDES_FILE}" + chown sandbox:sandbox "${OPENCLAW_CONFIG_OVERRIDES_FILE}" +fi + # Write auth profile as sandbox user (needs writable .openclaw-data) gosu sandbox bash -c "$(declare -f write_auth_profile); write_auth_profile" diff --git a/scripts/poc-round-trip-test.sh b/scripts/poc-round-trip-test.sh new file mode 100755 index 00000000000..3db6ee930e5 --- /dev/null +++ b/scripts/poc-round-trip-test.sh @@ -0,0 +1,478 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Self-contained config mutability E2E demo. +# +# Builds EVERYTHING from source, then walks through the full flow: +# +# Phase A: Bootstrap +# 0. Check / install prerequisites (Docker, mise, cargo, etc.) +# 1. Clean previous state +# 2. Clone OpenShell, apply patches +# 3. Build patched OpenShell cluster from source (mise run cluster) +# 4. Build patched openshell CLI from source (cargo build) +# 5. Create NemoClaw sandbox on the patched gateway +# +# Phase B: Interactive demo +# 6. Show baseline config +# 7. Submit config change request from inside the sandbox +# (rename assistant: "Lew Alcindor" → "Kareem Abdul-Jabbar") +# 8. User approves in TUI (other terminal) +# 9. Verify the override took effect +# 10. Test gateway.* security block +# 11. Host-side direct set (comparison) +# 12. Host-side gateway.* refusal +# +# Usage: +# bash scripts/poc-round-trip-test.sh +# +# Then open a SECOND terminal and run: +# openshell term -g openshell-source +# +# The script pauses before each interactive step. + +set -euo pipefail + +DEMO_ONLY=false +if [[ "${1:-}" == "--demo-only" ]]; then + DEMO_ONLY=true + shift +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +OPENSHELL_SOURCE="/tmp/openshell-source" +SANDBOX_NAME="${NEMOCLAW_SANDBOX_NAME:-my-assistant}" +GATEWAY_NAME="openshell-source" + +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +RED='\033[0;31m' +NC='\033[0m' + +step() { + echo -e "\n${GREEN}═══════════════════════════════════════════════════${NC}" + echo -e "${GREEN}▸ $1${NC}" + echo -e "${GREEN}═══════════════════════════════════════════════════${NC}" +} +info() { echo -e " ${CYAN}$1${NC}"; } +warn() { echo -e " ${YELLOW}$1${NC}"; } +err() { + echo -e " ${RED}$1${NC}" >&2 + exit 1 +} +ok() { echo -e " ${GREEN}✓ $1${NC}"; } +wait_enter() { + echo -e "\n ${YELLOW}Press Enter to continue...${NC}" + read -r +} + +# Download a file from the sandbox to stdout +sandbox_cat() { + local sandbox="$1" remote_path="$2" + local tmpdir + tmpdir="$(mktemp -d)" + if openshell sandbox download "$sandbox" "$remote_path" "$tmpdir" 2>/dev/null; then + local bname + bname="$(basename "$remote_path")" + if [[ -f "$tmpdir/$bname" ]]; then + cat "$tmpdir/$bname" + fi + fi + rm -rf "$tmpdir" +} + +# Write a script to the sandbox via connect stdin +sandbox_exec() { + local sandbox="$1" + shift + local tmpfile + tmpfile="$(mktemp)" + for cmd in "$@"; do + printf '%s\n' "$cmd" >>"$tmpfile" + done + printf 'exit\n' >>"$tmpfile" + openshell sandbox connect "$sandbox" <"$tmpfile" 2>&1 + rm -f "$tmpfile" +} + +if [[ "$DEMO_ONLY" == "false" ]]; then + # ╔═════════════════════════════════════════════════════════════════╗ + # ║ PHASE A: Bootstrap — build everything from source ║ + # ╚═════════════════════════════════════════════════════════════════╝ + + # ══════════════════════════════════════════════════════════════════ + # Step 0: Check prerequisites + # ══════════════════════════════════════════════════════════════════ + step "0. Checking prerequisites" + + # Docker — start Colima if needed (macOS) + if ! command -v docker >/dev/null 2>&1; then + err "docker not found. Install Docker Desktop or Colima." + fi + if ! docker info >/dev/null 2>&1; then + if [[ "$(uname)" == "Darwin" ]] && command -v colima >/dev/null 2>&1; then + info "Docker not running — starting Colima..." + if ! colima start 2>&1; then + warn "Colima start failed — force-deleting stale instance and retrying..." + colima delete --force 2>/dev/null || true + colima start + fi + docker info >/dev/null 2>&1 || err "Failed to start Colima" + ok "Started Colima" + else + err "Docker is not running. Start it first." + fi + else + ok "Docker running" + fi + + # mise + if ! command -v mise >/dev/null 2>&1; then + err "mise not found. Install: curl https://mise.run | sh" + fi + ok "mise installed ($(mise --version 2>&1 | head -1))" + + # cargo + if ! command -v cargo >/dev/null 2>&1; then + err "cargo not found. Install Rust: https://rustup.rs" + fi + ok "cargo installed" + + # bash version (mapfile requires 4+) + BASH_MAJOR="${BASH_VERSINFO[0]}" + if [[ "$BASH_MAJOR" -lt 4 ]]; then + err "bash $BASH_VERSION is too old (need 4+). Install: brew install bash" + fi + ok "bash $BASH_VERSION" + + # NVIDIA_API_KEY + if [[ -z "${NVIDIA_API_KEY:-}" ]]; then + err "NVIDIA_API_KEY not set" + fi + ok "NVIDIA_API_KEY set" + + # GitHub token (for mise rate limits) + if [[ -z "${GITHUB_TOKEN:-}" ]]; then + if command -v gh >/dev/null 2>&1; then + GITHUB_TOKEN="$(gh auth token 2>/dev/null || true)" + export GITHUB_TOKEN + fi + fi + if [[ -z "${GITHUB_TOKEN:-}" ]]; then + err "GITHUB_TOKEN not set and gh CLI not authenticated. Run: gh auth login" + fi + export MISE_GITHUB_TOKEN="$GITHUB_TOKEN" + export MISE_AQUA_SKIP_VERIFY=1 + ok "GitHub token available" + + # Ensure bash 5+ is found first on PATH (macOS ships 3.2 which lacks mapfile) + export PATH="/opt/homebrew/bin:$PATH" + + # ══════════════════════════════════════════════════════════════════ + # Step 1: Clean everything from previous runs + # ══════════════════════════════════════════════════════════════════ + step "1. Cleaning previous state" + + pkill -f openshell 2>/dev/null || true + openshell forward stop 8080 2>/dev/null || true + openshell forward stop 18789 2>/dev/null || true + openshell gateway destroy -g "$GATEWAY_NAME" 2>/dev/null || true + openshell gateway destroy -g nemoclaw 2>/dev/null || true + docker rm -f "openshell-cluster-${GATEWAY_NAME}" 2>/dev/null || true + docker volume rm "openshell-cluster-${GATEWAY_NAME}" 2>/dev/null || true + docker rm -f openshell-cluster-nemoclaw 2>/dev/null || true + docker volume rm openshell-cluster-nemoclaw 2>/dev/null || true + lsof -ti :8080,:18789 2>/dev/null | xargs kill 2>/dev/null || true + docker buildx prune -af 2>/dev/null || true + docker images --format '{{.Repository}}:{{.Tag}}' | grep openshell | xargs -r docker rmi -f 2>/dev/null || true + rm -rf "$OPENSHELL_SOURCE" + ok "Clean slate" + + # ══════════════════════════════════════════════════════════════════ + # Step 2: Clone OpenShell and apply patch + # ══════════════════════════════════════════════════════════════════ + step "2. Cloning OpenShell and applying config-approval patch" + + OS_VERSION="$(sed -nE 's/^min_openshell_version:[[:space:]]*"([^"]+)".*/\1/p' "$ROOT/nemoclaw-blueprint/blueprint.yaml" | head -1)" + OS_VERSION="${OS_VERSION:-0.0.15}" + info "OpenShell version: v${OS_VERSION} (from blueprint.yaml)" + + git clone --branch "v${OS_VERSION}" --depth 1 https://github.com/NVIDIA/OpenShell.git "$OPENSHELL_SOURCE" + cd "$OPENSHELL_SOURCE" + git apply "$ROOT/patches/openshell-config-approval.patch" + ok "Patch applied" + + # ══════════════════════════════════════════════════════════════════ + # Step 3: Build patched OpenShell and deploy cluster + # ══════════════════════════════════════════════════════════════════ + step "3. Building patched OpenShell from source (mise run cluster)" + info "This builds gateway + cluster Docker images from Rust source" + info "and deploys a local k3s cluster. Takes ~10-15 min on first run." + + cd "$OPENSHELL_SOURCE" + mise trust + + # mise run cluster may fail in post-deploy steps on macOS (bash 3.2 lacks + # mapfile). The Docker images and k3s bootstrap succeed; the failure is in + # the incremental deploy wrapper. If the gateway comes up healthy, proceed. + if ! mise run cluster; then + if openshell gateway info -g "$GATEWAY_NAME" >/dev/null 2>&1; then + warn "mise run cluster had errors but gateway is healthy — proceeding" + else + err "mise run cluster failed and gateway is not healthy" + fi + fi + ok "Cluster deployed with patched OpenShell" + + # ══════════════════════════════════════════════════════════════════ + # Step 4: Build patched CLI binary + # ══════════════════════════════════════════════════════════════════ + step "4. Building patched openshell CLI" + info "Compiling openshell-cli with config approval TUI support..." + + cd "$OPENSHELL_SOURCE" + cargo build --release -p openshell-cli --features openshell-core/dev-settings + + OPENSHELL_BIN="$(command -v openshell 2>/dev/null || echo "$HOME/.local/bin/openshell")" + mkdir -p "$(dirname "$OPENSHELL_BIN")" + cp "$OPENSHELL_SOURCE/target/release/openshell" "$OPENSHELL_BIN" + ok "Installed patched CLI: $(openshell --version 2>&1)" + + # ══════════════════════════════════════════════════════════════════ + # Step 5: Create NemoClaw sandbox on the patched gateway + # ══════════════════════════════════════════════════════════════════ + step "5. Creating NemoClaw sandbox" + info "Staging build context and building sandbox Docker image..." + + cd "$ROOT" + BUILDCTX="$(mktemp -d)" + cp Dockerfile "$BUILDCTX/" + cp -r nemoclaw "$BUILDCTX/nemoclaw" + cp -r nemoclaw-blueprint "$BUILDCTX/nemoclaw-blueprint" + cp -r scripts "$BUILDCTX/scripts" + cp -r patches "$BUILDCTX/patches" + rm -rf "$BUILDCTX/nemoclaw/node_modules" + + openshell sandbox create \ + --from "$BUILDCTX/Dockerfile" \ + --name "$SANDBOX_NAME" \ + --policy nemoclaw-blueprint/policies/openclaw-sandbox.yaml \ + -g "$GATEWAY_NAME" \ + -- echo ready + + rm -rf "$BUILDCTX" + + # Wait for sandbox to be Ready + info "Waiting for sandbox to be ready..." + SANDBOX_READY=false + for _ in $(seq 1 30); do + if openshell sandbox list -g "$GATEWAY_NAME" 2>/dev/null | grep -q "$SANDBOX_NAME.*Ready"; then + SANDBOX_READY=true + break + fi + sleep 2 + done + if [[ "$SANDBOX_READY" != "true" ]]; then + err "Sandbox '$SANDBOX_NAME' did not become ready within 60 seconds" + fi + openshell sandbox list -g "$GATEWAY_NAME" + ok "Sandbox '$SANDBOX_NAME' is ready" + + # Register in NemoClaw registry so nemoclaw CLI commands work + mkdir -p "$HOME/.nemoclaw" + REGISTRY="$HOME/.nemoclaw/sandboxes.json" + if [[ -f "$REGISTRY" ]]; then + node -e " + const fs = require('fs'); + const r = JSON.parse(fs.readFileSync('$REGISTRY', 'utf8')); + r.sandboxes = r.sandboxes || {}; + r.sandboxes['$SANDBOX_NAME'] = { + name: '$SANDBOX_NAME', + createdAt: new Date().toISOString(), + model: null, nimContainer: null, provider: null, gpuEnabled: false, policies: [] + }; + fs.writeFileSync('$REGISTRY', JSON.stringify(r, null, 2)); + " + else + node -e " + const fs = require('fs'); + fs.writeFileSync('$REGISTRY', JSON.stringify({ + sandboxes: { + '$SANDBOX_NAME': { + name: '$SANDBOX_NAME', + createdAt: new Date().toISOString(), + model: null, nimContainer: null, provider: null, gpuEnabled: false, policies: [] + } + }, + defaultSandbox: '$SANDBOX_NAME' + }, null, 2)); + " + fi + ok "Registered in NemoClaw registry" + +fi # end DEMO_ONLY check + +# ╔═════════════════════════════════════════════════════════════════╗ +# ║ PHASE B: Interactive demo ║ +# ╚═════════════════════════════════════════════════════════════════╝ + +export OPENSHELL_GATEWAY="$GATEWAY_NAME" +export NEMOCLAW_SANDBOX_NAME="$SANDBOX_NAME" + +echo "" +echo -e " ${GREEN}╔═══════════════════════════════════════════════════════╗${NC}" +echo -e " ${GREEN}║ Bootstrap complete. Starting interactive demo. ║${NC}" +echo -e " ${GREEN}║ ║${NC}" +echo -e " ${GREEN}║ Sandbox: ${SANDBOX_NAME}$(printf '%*s' $((28 - ${#SANDBOX_NAME})) '')║${NC}" +echo -e " ${GREEN}║ Gateway: ${GATEWAY_NAME}$(printf '%*s' $((28 - ${#GATEWAY_NAME})) '')║${NC}" +echo -e " ${GREEN}║ ║${NC}" +echo -e " ${GREEN}║ NOW open a second terminal and run: ║${NC}" +echo -e " ${GREEN}║ openshell term -g ${GATEWAY_NAME}$(printf '%*s' $((18 - ${#GATEWAY_NAME})) '')║${NC}" +echo -e " ${GREEN}╚═══════════════════════════════════════════════════════╝${NC}" +echo "" +wait_enter + +# ══════════════════════════════════════════════════════════════════ +# Step 6: Show baseline config +# ══════════════════════════════════════════════════════════════════ +step "6. Show current config (baseline)" +nemoclaw "$SANDBOX_NAME" config-get + +info "Check config-overrides.json5 in sandbox..." +overrides_content="$(sandbox_cat "$SANDBOX_NAME" /sandbox/.openclaw-data/config-overrides.json5 2>/dev/null || true)" +if [[ -n "$overrides_content" ]]; then + echo "$overrides_content" +else + info "(file not found or empty — that's OK for a fresh sandbox)" +fi +wait_enter + +# ══════════════════════════════════════════════════════════════════ +# Step 7: Submit config change request FROM INSIDE the sandbox +# ══════════════════════════════════════════════════════════════════ +step "7. Submit a config change request from inside the sandbox" +info "Writing a config request file to /sandbox/.openclaw-data/config-requests/" +info "This simulates what an agent would do when it wants to change its own config." +info "" +info "Scenario: The assistant's display name is 'Lew Alcindor'." +info "The agent requests a name change to 'Kareem Abdul-Jabbar'." +echo "" + +REQUEST_TMPDIR="$(mktemp -d)" +printf '{"key": "ui.assistant.name", "value": "Kareem Abdul-Jabbar"}\n' \ + >"$REQUEST_TMPDIR/test-name-change.json" +openshell sandbox upload "$SANDBOX_NAME" "$REQUEST_TMPDIR/test-name-change.json" /sandbox/.openclaw-data/config-requests/ +rm -rf "$REQUEST_TMPDIR" + +info "Request file uploaded. Verifying:" +sandbox_exec "$SANDBOX_NAME" \ + 'ls -la /sandbox/.openclaw-data/config-requests/' \ + 'cat /sandbox/.openclaw-data/config-requests/test-name-change.json' + +echo "" +info "The sandbox scanner polls every 5 seconds." +info "It will detect this file and submit a CONFIG PolicyChunk to the gateway." +echo "" +echo -e " ${YELLOW}════════════════════════════════════════════════════${NC}" +echo -e " ${YELLOW} NOW: Switch to Terminal 2 (openshell term)${NC}" +echo -e " ${YELLOW}${NC}" +echo -e " ${YELLOW} You should see a pending chunk:${NC}" +echo -e " ${YELLOW} CONFIG ui.assistant.name [pending]${NC}" +echo -e " ${YELLOW}${NC}" +echo -e " ${YELLOW} Press Enter to view the detail popup — you should${NC}" +echo -e " ${YELLOW} see the proposed name change to 'Kareem Abdul-Jabbar'.${NC}" +echo -e " ${YELLOW}${NC}" +echo -e " ${YELLOW} Press [a] to approve it, then come back here.${NC}" +echo -e " ${YELLOW}════════════════════════════════════════════════════${NC}" +wait_enter + +# ══════════════════════════════════════════════════════════════════ +# Step 8: Verify the approval took effect +# ══════════════════════════════════════════════════════════════════ +step "8. Verify the config change was applied" +info "After approval, the sandbox poll loop writes the overrides file." +info "Waiting 15 seconds for the poll loop..." +sleep 15 + +info "Current overrides file:" +overrides_after="$(sandbox_cat "$SANDBOX_NAME" /sandbox/.openclaw-data/config-overrides.json5 2>/dev/null || true)" +if [[ -n "$overrides_after" ]]; then + echo "$overrides_after" + if echo "$overrides_after" | grep -q "Kareem Abdul-Jabbar"; then + echo -e "\n ${GREEN}✓ Override applied! Assistant name changed to 'Kareem Abdul-Jabbar'${NC}" + echo -e " ${GREEN} Open the OpenClaw chat UI — the assistant name should now show the new name.${NC}" + else + warn "Override file exists but doesn't contain the expected name." + warn "The poll loop may not have run yet. Try waiting longer." + fi +else + warn "Overrides file not found. The approval may not have propagated yet." + warn "Check the TUI — is the chunk still pending?" +fi +echo "" + +info "Config-get view:" +nemoclaw "$SANDBOX_NAME" config-get +wait_enter + +# ══════════════════════════════════════════════════════════════════ +# Step 9: Security — gateway.* blocked +# ══════════════════════════════════════════════════════════════════ +step "9. Test security: gateway.* should be blocked" +info "Writing a gateway.auth.token change request (should be blocked by scanner)..." + +EVIL_TMPDIR="$(mktemp -d)" +printf '{"key": "gateway.auth.token", "value": "stolen-token"}\n' \ + >"$EVIL_TMPDIR/evil.json" +openshell sandbox upload "$SANDBOX_NAME" "$EVIL_TMPDIR/evil.json" /sandbox/.openclaw-data/config-requests/ +rm -rf "$EVIL_TMPDIR" +info "Evil request file uploaded." + +info "Waiting 10 seconds for the scanner to process..." +sleep 10 +info "Check sandbox logs — you should see 'gateway.* blocked' message:" +nemoclaw "$SANDBOX_NAME" logs 2>/dev/null | grep -i "gateway.*blocked" | tail -3 || warn "No 'blocked' message found in recent logs (may have scrolled past)" +wait_enter + +# ══════════════════════════════════════════════════════════════════ +# Step 10: Host-side direct set (comparison) +# ══════════════════════════════════════════════════════════════════ +step "10. Host-side direct config-set (bypasses TUI approval)" +info "This writes directly to the overrides file — no TUI approval needed." +info "This is the operator path, not the agent path." +echo "" +nemoclaw "$SANDBOX_NAME" config-set --key channels.defaults.configWrites --value false +nemoclaw "$SANDBOX_NAME" config-get +wait_enter + +# ══════════════════════════════════════════════════════════════════ +# Step 11: Host-side gateway.* refusal +# ══════════════════════════════════════════════════════════════════ +step "11. Host-side gateway.* refusal" +info "Even from the host, gateway.* is blocked:" +nemoclaw "$SANDBOX_NAME" config-set --key gateway.auth.token --value evil 2>&1 || true + +# ══════════════════════════════════════════════════════════════════ +# Done +# ══════════════════════════════════════════════════════════════════ +echo "" +echo -e " ${GREEN}╔═══════════════════════════════════════════════════════╗${NC}" +echo -e " ${GREEN}║ Round-trip test complete! ║${NC}" +echo -e " ${GREEN}║ ║${NC}" +echo -e " ${GREEN}║ What you just verified: ║${NC}" +echo -e " ${GREEN}║ ✓ Built patched OpenShell from source ║${NC}" +echo -e " ${GREEN}║ ✓ Created sandbox with frozen config ║${NC}" +echo -e " ${GREEN}║ ✓ Agent writes config request inside sandbox ║${NC}" +echo -e " ${GREEN}║ ✓ Scanner submits it as a CONFIG PolicyChunk ║${NC}" +echo -e " ${GREEN}║ ✓ TUI shows config detail view with proposed JSON ║${NC}" +echo -e " ${GREEN}║ ✓ Approval triggers override file write ║${NC}" +echo -e " ${GREEN}║ ✓ Assistant name changed (Lew Alcindor → Kareem) ║${NC}" +echo -e " ${GREEN}║ ✓ gateway.* blocked at scanner level ║${NC}" +echo -e " ${GREEN}║ ✓ Host-side direct set works (operator path) ║${NC}" +echo -e " ${GREEN}║ ✓ Host-side gateway.* also blocked ║${NC}" +echo -e " ${GREEN}╚═══════════════════════════════════════════════════════╝${NC}" +echo "" +info "Clean up with: nemoclaw $SANDBOX_NAME destroy --yes" diff --git a/scripts/setup-e2e-demo.sh b/scripts/setup-e2e-demo.sh new file mode 100755 index 00000000000..4d7a48daa5b --- /dev/null +++ b/scripts/setup-e2e-demo.sh @@ -0,0 +1,234 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Set up the config mutability E2E demo from scratch. +# +# Assumes NOTHING is running. Builds everything from source. +# At the end, prints instructions for the two-terminal interactive demo. +# +# Prerequisites (will error if missing): +# - Docker running (Colima or Docker Desktop) +# - mise (https://mise.jdx.dev) +# - cargo (Rust toolchain) +# - bash 4+ (macOS ships 3.2; install via: brew install bash) +# - NVIDIA_API_KEY set +# - GITHUB_TOKEN set (or gh auth login) +# +# Usage: +# bash scripts/setup-e2e-demo.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +OPENSHELL_SOURCE="/tmp/openshell-source" +SANDBOX_NAME="${NEMOCLAW_SANDBOX_NAME:-my-assistant}" +GATEWAY_NAME="openshell-source" + +GREEN='\033[0;32m' +CYAN='\033[0;36m' +RED='\033[0;31m' +NC='\033[0m' + +step() { + echo -e "\n${GREEN}═══════════════════════════════════════════════════${NC}" + echo -e "${GREEN}▸ $1${NC}" + echo -e "${GREEN}═══════════════════════════════════════════════════${NC}" +} +info() { echo -e " ${CYAN}$1${NC}"; } +err() { + echo -e " ${RED}$1${NC}" >&2 + exit 1 +} +ok() { echo -e " ${GREEN}✓ $1${NC}"; } + +# ══════════════════════════════════════════════════════════════════ +# Step 0: Check prerequisites +# ══════════════════════════════════════════════════════════════════ +step "0. Checking prerequisites" + +command -v docker >/dev/null 2>&1 || err "docker not found. Install Docker Desktop or Colima." +docker info >/dev/null 2>&1 || err "Docker is not running. Start it first." +ok "Docker running" + +command -v mise >/dev/null 2>&1 || err "mise not found. Install: curl https://mise.run | sh" +ok "mise installed ($(mise --version 2>&1 | head -1))" + +command -v cargo >/dev/null 2>&1 || err "cargo not found. Install Rust: https://rustup.rs" +ok "cargo installed" + +# Check bash version (mapfile requires bash 4+) +BASH_MAJOR="${BASH_VERSINFO[0]}" +if [[ "$BASH_MAJOR" -lt 4 ]]; then + err "bash $BASH_VERSION is too old (need 4+). Install: brew install bash" +fi +ok "bash $BASH_VERSION" + +if [[ -z "${NVIDIA_API_KEY:-}" ]]; then + err "NVIDIA_API_KEY not set" +fi +ok "NVIDIA_API_KEY set" + +# Resolve GitHub token for mise (avoids API rate limits) +if [[ -z "${GITHUB_TOKEN:-}" ]]; then + if command -v gh >/dev/null 2>&1; then + GITHUB_TOKEN="$(gh auth token 2>/dev/null || true)" + export GITHUB_TOKEN + fi +fi +if [[ -z "${GITHUB_TOKEN:-}" ]]; then + err "GITHUB_TOKEN not set and gh CLI not authenticated. Run: gh auth login" +fi +export MISE_GITHUB_TOKEN="$GITHUB_TOKEN" +export MISE_AQUA_SKIP_VERIFY=1 +ok "GitHub token available" + +# ══════════════════════════════════════════════════════════════════ +# Step 1: Clean everything from previous runs +# ══════════════════════════════════════════════════════════════════ +step "1. Cleaning previous state" + +pkill -f openshell 2>/dev/null || true +openshell gateway destroy -g "$GATEWAY_NAME" 2>/dev/null || true +openshell gateway destroy -g nemoclaw 2>/dev/null || true +docker rm -f "openshell-cluster-${GATEWAY_NAME}" 2>/dev/null || true +docker volume rm "openshell-cluster-${GATEWAY_NAME}" 2>/dev/null || true +docker rm -f openshell-cluster-nemoclaw 2>/dev/null || true +docker volume rm openshell-cluster-nemoclaw 2>/dev/null || true +lsof -ti :8080,:18789 2>/dev/null | xargs kill 2>/dev/null || true +docker buildx prune -af 2>/dev/null || true +docker images --format '{{.Repository}}:{{.Tag}}' | grep openshell | xargs -r docker rmi -f 2>/dev/null || true +rm -rf "$OPENSHELL_SOURCE" +ok "Clean slate" + +# ══════════════════════════════════════════════════════════════════ +# Step 2: Clone OpenShell and apply patch +# ══════════════════════════════════════════════════════════════════ +step "2. Cloning OpenShell and applying config-approval patch" + +# Read min_openshell_version from blueprint +OS_VERSION="$(sed -nE 's/^min_openshell_version:[[:space:]]*"([^"]+)".*/\1/p' "$ROOT/nemoclaw-blueprint/blueprint.yaml" | head -1)" +OS_VERSION="${OS_VERSION:-0.0.15}" +info "OpenShell version: v${OS_VERSION} (from blueprint.yaml)" + +git clone --branch "v${OS_VERSION}" --depth 1 https://github.com/NVIDIA/OpenShell.git "$OPENSHELL_SOURCE" +cd "$OPENSHELL_SOURCE" +git apply "$ROOT/patches/openshell-config-approval.patch" +ok "Patch applied" + +# ══════════════════════════════════════════════════════════════════ +# Step 3: Build patched OpenShell and deploy cluster +# ══════════════════════════════════════════════════════════════════ +step "3. Building patched OpenShell from source (mise run cluster)" +info "This builds gateway + cluster Docker images from Rust source" +info "and deploys a local k3s cluster. Takes ~10-15 min on first run." + +cd "$OPENSHELL_SOURCE" +mise trust +mise run cluster +ok "Cluster deployed with patched OpenShell" + +# ══════════════════════════════════════════════════════════════════ +# Step 4: Build patched CLI binary +# ══════════════════════════════════════════════════════════════════ +step "4. Building patched openshell CLI" +info "Compiling openshell-cli with config approval TUI support..." + +cd "$OPENSHELL_SOURCE" +cargo build --release -p openshell-cli --features openshell-core/dev-settings + +OPENSHELL_BIN="$(command -v openshell 2>/dev/null || echo "$HOME/.local/bin/openshell")" +mkdir -p "$(dirname "$OPENSHELL_BIN")" +cp "$OPENSHELL_SOURCE/target/release/openshell" "$OPENSHELL_BIN" +ok "Installed patched CLI: $(openshell --version 2>&1)" + +# ══════════════════════════════════════════════════════════════════ +# Step 5: Create NemoClaw sandbox on the patched gateway +# ══════════════════════════════════════════════════════════════════ +step "5. Creating NemoClaw sandbox" +info "Staging build context and building sandbox Docker image..." + +cd "$ROOT" +BUILDCTX="$(mktemp -d)" +cp Dockerfile "$BUILDCTX/" +cp -r nemoclaw "$BUILDCTX/nemoclaw" +cp -r nemoclaw-blueprint "$BUILDCTX/nemoclaw-blueprint" +cp -r scripts "$BUILDCTX/scripts" +cp -r patches "$BUILDCTX/patches" +rm -rf "$BUILDCTX/nemoclaw/node_modules" + +openshell sandbox create \ + --from "$BUILDCTX/Dockerfile" \ + --name "$SANDBOX_NAME" \ + --policy nemoclaw-blueprint/policies/openclaw-sandbox.yaml \ + -g "$GATEWAY_NAME" \ + -- echo ready + +rm -rf "$BUILDCTX" + +# Wait for Ready +info "Waiting for sandbox to be ready..." +for _ in $(seq 1 30); do + if openshell sandbox list -g "$GATEWAY_NAME" 2>/dev/null | grep -q "$SANDBOX_NAME.*Ready"; then + break + fi + sleep 2 +done +openshell sandbox list -g "$GATEWAY_NAME" +ok "Sandbox '$SANDBOX_NAME' is ready" + +# Register in NemoClaw registry so nemoclaw CLI commands work +mkdir -p "$HOME/.nemoclaw" +REGISTRY="$HOME/.nemoclaw/sandboxes.json" +if [[ -f "$REGISTRY" ]]; then + node -e " + const fs = require('fs'); + const r = JSON.parse(fs.readFileSync('$REGISTRY', 'utf8')); + r.sandboxes = r.sandboxes || {}; + r.sandboxes['$SANDBOX_NAME'] = { + name: '$SANDBOX_NAME', + createdAt: new Date().toISOString(), + model: null, nimContainer: null, provider: null, gpuEnabled: false, policies: [] + }; + fs.writeFileSync('$REGISTRY', JSON.stringify(r, null, 2)); + " +else + node -e " + const fs = require('fs'); + fs.writeFileSync('$REGISTRY', JSON.stringify({ + sandboxes: { + '$SANDBOX_NAME': { + name: '$SANDBOX_NAME', + createdAt: new Date().toISOString(), + model: null, nimContainer: null, provider: null, gpuEnabled: false, policies: [] + } + }, + defaultSandbox: '$SANDBOX_NAME' + }, null, 2)); + " +fi +ok "Registered in NemoClaw registry" + +# ══════════════════════════════════════════════════════════════════ +# Done — print instructions +# ══════════════════════════════════════════════════════════════════ +echo "" +echo -e "${GREEN}╔═══════════════════════════════════════════════════════════╗${NC}" +echo -e "${GREEN}║ Setup complete. Ready for the interactive demo. ║${NC}" +echo -e "${GREEN}║ ║${NC}" +echo -e "${GREEN}║ Open TWO terminals: ║${NC}" +echo -e "${GREEN}║ ║${NC}" +echo -e "${GREEN}║ Terminal 1 (TUI): ║${NC}" +echo -e "${GREEN}║ openshell term -g ${GATEWAY_NAME}$(printf '%*s' $((23 - ${#GATEWAY_NAME})) '')║${NC}" +echo -e "${GREEN}║ ║${NC}" +echo -e "${GREEN}║ Terminal 2 (demo): ║${NC}" +echo -e "${GREEN}║ NEMOCLAW_SANDBOX_NAME=${SANDBOX_NAME} \\${NC}" +echo -e "${GREEN}║ OPENSHELL_GATEWAY=${GATEWAY_NAME} \\${NC}" +echo -e "${GREEN}║ bash scripts/poc-round-trip-test.sh ║${NC}" +echo -e "${GREEN}║ ║${NC}" +echo -e "${GREEN}║ The demo script pauses at each step. When it says ║${NC}" +echo -e "${GREEN}║ 'Switch to Terminal 1', look for the CONFIG chunk ║${NC}" +echo -e "${GREEN}║ in the TUI and press [a] to approve. ║${NC}" +echo -e "${GREEN}╚═══════════════════════════════════════════════════════════╝${NC}" +echo "" diff --git a/test/config-mutability-e2e.test.ts b/test/config-mutability-e2e.test.ts new file mode 100644 index 00000000000..18ea8e407f9 --- /dev/null +++ b/test/config-mutability-e2e.test.ts @@ -0,0 +1,711 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// E2E test for runtime config mutability feature. +// +// Full flow — builds everything from source, no pre-built images: +// 1. Clone OpenShell, apply patches/openshell-config-approval.patch +// 2. Build patched OpenShell via `mise run cluster` (per CONTRIBUTING.md) +// 3. Stage build context and create NemoClaw sandbox on the patched gateway +// 4. Test direct config-set path (host → overrides file → shim reads) +// 5. Test TUI approval path (sandbox → config-request file → scanner → +// PolicyChunk submitted to gateway → verify via logs) +// 6. Test security (gateway.* blocked at CLI, scanner, and shim levels) +// 7. Cleanup +// +// Requires: Docker, mise, NVIDIA_API_KEY, GITHUB_TOKEN (for mise rate limits) +// Run: npx vitest run --project cli test/config-mutability-e2e.test.ts + +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { execFileSync, execSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; +import { createRequire } from "module"; + +const require = createRequire(import.meta.url); +const ROOT = path.resolve(import.meta.dirname, ".."); +const NEMOCLAW = path.join(ROOT, "bin", "nemoclaw.js"); +const SANDBOX_NAME = `e2e-config-${Date.now()}`; +const OPENSHELL_SOURCE = "/tmp/openshell-source"; +const TIMEOUT_LONG = 1_800_000; // 30 min — Rust compile + cluster bootstrap + sandbox build +const TIMEOUT_MED = 60_000; + +// Gateway name is derived from the OpenShell source directory name by +// the cluster bootstrap script. +const GATEWAY_NAME = "openshell-source"; + +// ── Docker socket detection ────────────────────────────────────────── +function detectDockerHost(): string | undefined { + if (process.env.DOCKER_HOST) return process.env.DOCKER_HOST; + try { + const endpoint = execSync("docker context inspect --format '{{.Endpoints.docker.Host}}'", { + encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"], + }).trim(); + if (endpoint && endpoint !== "unix:///var/run/docker.sock") return endpoint; + } catch { /* fallback to default */ } + return undefined; +} + +const DOCKER_HOST = detectDockerHost(); + +// Resolve a GitHub token for mise tool installs. Without auth, GitHub's +// API rate limit (60 req/hr) is exhausted in minutes. +function resolveGitHubToken(): string { + if (process.env.GITHUB_TOKEN) return process.env.GITHUB_TOKEN; + try { + return execSync("gh auth token", { encoding: "utf-8", timeout: 5000, stdio: "pipe" }).trim(); + } catch { return ""; } +} +const GITHUB_TOKEN = resolveGitHubToken(); + +const baseEnv: Record = { + ...process.env as Record, + NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_SANDBOX_NAME: SANDBOX_NAME, + OPENSHELL_GATEWAY: GATEWAY_NAME, + ...(DOCKER_HOST ? { DOCKER_HOST } : {}), + ...(GITHUB_TOKEN ? { GITHUB_TOKEN, MISE_GITHUB_TOKEN: GITHUB_TOKEN } : {}), + MISE_AQUA_SKIP_VERIFY: "1", + // Ensure bash 5+ is found first (macOS ships bash 3.2 which lacks mapfile) + PATH: `/opt/homebrew/bin:${process.env.PATH}`, +}; + +// ── Helpers ────────────────────────────────────────────────────────── + +function nem(...args: string[]): string { + return execFileSync("node", [NEMOCLAW, ...args], { + encoding: "utf-8", + timeout: TIMEOUT_MED, + env: baseEnv, + }).trim(); +} + +function nemFail(...args: string[]): { status: number; stderr: string; stdout: string } { + try { + const stdout = execFileSync("node", [NEMOCLAW, ...args], { + encoding: "utf-8", + timeout: TIMEOUT_MED, + stdio: ["pipe", "pipe", "pipe"], + env: baseEnv, + }); + return { status: 0, stderr: "", stdout }; + } catch (err: unknown) { + const e = err as { status: number; stderr: string; stdout: string }; + return { status: e.status, stderr: e.stderr ?? "", stdout: e.stdout ?? "" }; + } +} + +function osh(...args: string[]): string { + return execSync(`openshell ${args.map((a) => `'${a}'`).join(" ")}`, { + encoding: "utf-8", + timeout: TIMEOUT_MED, + env: baseEnv, + }).trim(); +} + +function sandboxDownload(sandboxPath: string): string { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-e2e-dl-")); + try { + execSync( + `openshell sandbox download '${SANDBOX_NAME}' '${sandboxPath}' '${tmpDir}'`, + { encoding: "utf-8", timeout: TIMEOUT_MED, env: baseEnv }, + ); + const basename = path.basename(sandboxPath); + const localFile = path.join(tmpDir, basename); + if (!fs.existsSync(localFile)) return ""; + return fs.readFileSync(localFile, "utf-8"); + } catch { + return ""; + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +} + +function sandboxUploadFile(localPath: string, remoteDirPath: string): void { + execSync( + `openshell sandbox upload '${SANDBOX_NAME}' '${localPath}' '${remoteDirPath}'`, + { encoding: "utf-8", timeout: TIMEOUT_MED, env: baseEnv }, + ); +} + +function dockerRunning(): boolean { + try { + execSync("docker info", { stdio: "pipe", timeout: 10_000, env: baseEnv }); + return true; + } catch { + // On macOS, try starting Colima if it's installed but not running. + if (process.platform === "darwin") { + try { + execSync("command -v colima", { stdio: "pipe", timeout: 5000 }); + console.log("[e2e] Docker not running — starting Colima..."); + execSync("colima start", { stdio: "inherit", timeout: 120_000 }); + execSync("docker info", { stdio: "pipe", timeout: 10_000 }); + return true; + } catch { /* Colima not available or failed to start */ } + } + return false; + } +} + +function miseInstalled(): boolean { + try { + execSync("mise --version", { stdio: "pipe", timeout: 5000 }); + return true; + } catch { + return false; + } +} + +/** Stage a clean build context like onboard.js does (lines 1510-1518). */ +function stageBuildContext(): string { + const ctx = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-e2e-build-")); + fs.copyFileSync(path.join(ROOT, "Dockerfile"), path.join(ctx, "Dockerfile")); + execSync(`cp -r '${path.join(ROOT, "nemoclaw")}' '${ctx}/nemoclaw'`, { stdio: "inherit" }); + execSync(`cp -r '${path.join(ROOT, "nemoclaw-blueprint")}' '${ctx}/nemoclaw-blueprint'`, { stdio: "inherit" }); + execSync(`cp -r '${path.join(ROOT, "scripts")}' '${ctx}/scripts'`, { stdio: "inherit" }); + execSync(`cp -r '${path.join(ROOT, "patches")}' '${ctx}/patches'`, { stdio: "inherit" }); + execSync(`rm -rf '${ctx}/nemoclaw/node_modules'`, { stdio: "inherit" }); + return ctx; +} + +// ═══════════════════════════════════════════════════════════════════ +// Preflight: skip entire suite if prerequisites missing +// ═══════════════════════════════════════════════════════════════════ + +const HAS_DOCKER = dockerRunning(); +const HAS_MISE = miseInstalled(); +const HAS_API_KEY = !!process.env.NVIDIA_API_KEY?.startsWith("nvapi-"); + +const describeE2E = HAS_DOCKER && HAS_MISE && HAS_API_KEY ? describe : describe.skip; + +describeE2E("config mutability E2E", () => { + + // ═══════════════════════════════════════════════════════════════════ + // Phase 0: Build patched OpenShell from source + create sandbox + // ═══════════════════════════════════════════════════════════════════ + + beforeAll(() => { + // ── Clean slate: destroy EVERYTHING from previous runs ───────── + // Gateways, sandboxes, containers, volumes, port forwards, buildx + // cache, local registry images — all of it. A stale image in the + // local registry means k3s pulls old unpatched binaries. + try { execSync("openshell forward stop 8080", { env: baseEnv, stdio: "inherit" }); } catch { /* */ } + try { execSync("openshell forward stop 18789", { env: baseEnv, stdio: "inherit" }); } catch { /* */ } + try { osh("gateway", "destroy", "-g", GATEWAY_NAME); } catch { /* */ } + try { osh("gateway", "destroy", "-g", "nemoclaw"); } catch { /* */ } + try { execSync(`docker rm -f openshell-cluster-${GATEWAY_NAME}`, { env: baseEnv, stdio: "inherit" }); } catch { /* */ } + try { execSync(`docker volume rm openshell-cluster-${GATEWAY_NAME}`, { env: baseEnv, stdio: "inherit" }); } catch { /* */ } + try { execSync("docker rm -f openshell-cluster-nemoclaw", { env: baseEnv, stdio: "inherit" }); } catch { /* */ } + try { execSync("docker volume rm openshell-cluster-nemoclaw", { env: baseEnv, stdio: "inherit" }); } catch { /* */ } + // Kill ALL openshell processes (port forwards, stale gateways, etc) + try { execSync("pkill -f openshell", { stdio: "inherit" }); } catch { /* */ } + try { + const lsof = execSync("lsof -ti :8080,:18789", { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim(); + if (lsof) execSync(`kill ${lsof.split("\n").join(" ")}`, { stdio: "inherit" }); + } catch { /* */ } + // Purge ALL Docker buildx cache — stale Rust compilation produces + // unpatched binaries even when the source has the patch applied. + try { execSync("docker buildx prune -af", { env: baseEnv, stdio: "inherit", timeout: 30_000 }); } catch { /* */ } + // Remove all openshell images so mise run cluster builds fresh + try { + execSync("docker images --format '{{.Repository}}:{{.Tag}}' | grep openshell | xargs -r docker rmi -f", + { env: baseEnv, stdio: "inherit", shell: "/bin/bash", timeout: 30_000 }); + } catch { /* */ } + + // ── Clone OpenShell and apply our patch ────────────────────────── + if (fs.existsSync(OPENSHELL_SOURCE)) { + fs.rmSync(OPENSHELL_SOURCE, { recursive: true, force: true }); + } + // Clone at the version matching blueprint min_openshell_version + const blueprintRaw = fs.readFileSync( + path.join(ROOT, "nemoclaw-blueprint", "blueprint.yaml"), "utf-8", + ); + const minMatch = blueprintRaw.match(/min_openshell_version:\s*"([^"]+)"/); + const osVersion = minMatch ? minMatch[1] : "0.0.15"; + + console.log("[e2e] Cloning OpenShell v%s...", osVersion); + execSync( + `git clone --branch v${osVersion} --depth 1 https://github.com/NVIDIA/OpenShell.git '${OPENSHELL_SOURCE}'`, + { encoding: "utf-8", timeout: TIMEOUT_MED, stdio: "inherit" }, + ); + console.log("[e2e] Applying openshell-config-approval.patch..."); + execSync( + `cd '${OPENSHELL_SOURCE}' && git apply '${path.join(ROOT, "patches", "openshell-config-approval.patch")}'`, + { encoding: "utf-8", timeout: 10_000, stdio: "inherit" }, + ); + + // ── Build patched OpenShell and deploy cluster ─────────────────── + // `mise run cluster` per OpenShell CONTRIBUTING.md: builds all images + // from source and deploys a local k3s cluster. No external registry pulls + // for OpenShell components. + execSync(`cd '${OPENSHELL_SOURCE}' && mise trust`, { stdio: "inherit", timeout: 5000 }); + + // mise run cluster may fail in post-deploy steps on macOS (bash 3.2 + // lacks mapfile). The Docker images and k3s bootstrap succeed; the + // failure is in the incremental deploy wrapper. If the gateway comes + // up healthy, we proceed. + try { + execSync( + `cd '${OPENSHELL_SOURCE}' && mise run cluster`, + { + encoding: "utf-8", + timeout: TIMEOUT_LONG, + env: baseEnv, + stdio: "inherit", + }, + ); + } catch { + // Check if the gateway came up despite the script error + try { + execSync(`openshell gateway info -g '${GATEWAY_NAME}'`, { + env: baseEnv, stdio: "inherit", timeout: 10_000, + }); + console.log("[e2e] mise run cluster had errors but gateway is healthy — proceeding"); + } catch { + throw new Error("mise run cluster failed and gateway is not healthy"); + } + } + + // ── Build the patched CLI binary and install it ────────────────── + execSync( + `cd '${OPENSHELL_SOURCE}' && cargo build --release -p openshell-cli --features openshell-core/dev-settings`, + { encoding: "utf-8", timeout: TIMEOUT_LONG, stdio: "inherit" }, + ); + const openshellBin = execSync("which openshell", { encoding: "utf-8" }).trim(); + fs.copyFileSync(path.join(OPENSHELL_SOURCE, "target", "release", "openshell"), openshellBin); + + // ── Create NemoClaw sandbox on the patched gateway ─────────────── + // Stage a clean build context (like onboard.js lines 1510-1518) + // to avoid sending .claude/worktrees to Docker. + const buildCtx = stageBuildContext(); + const policyPath = path.join(ROOT, "nemoclaw-blueprint", "policies", "openclaw-sandbox.yaml"); + try { + execSync( + [ + "openshell sandbox create", + `--from '${buildCtx}/Dockerfile'`, + `--name '${SANDBOX_NAME}'`, + `--policy '${policyPath}'`, + `-g '${GATEWAY_NAME}'`, + "-- echo ready", + ].join(" "), + { + encoding: "utf-8", + timeout: TIMEOUT_LONG, + env: baseEnv, + stdio: "inherit", + }, + ); + } finally { + fs.rmSync(buildCtx, { recursive: true, force: true }); + } + + // Register sandbox in NemoClaw registry so nemoclaw CLI commands work + const registryPath = path.join(os.homedir(), ".nemoclaw", "sandboxes.json"); + let registry: Record = { sandboxes: {}, defaultSandbox: "" }; + try { registry = JSON.parse(fs.readFileSync(registryPath, "utf-8")); } catch { /* */ } + (registry.sandboxes as Record)[SANDBOX_NAME] = { + name: SANDBOX_NAME, + createdAt: new Date().toISOString(), + model: null, nimContainer: null, provider: null, gpuEnabled: false, policies: [], + }; + fs.mkdirSync(path.dirname(registryPath), { recursive: true }); + fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2)); + + // Wait for sandbox to be ready + let ready = false; + for (let i = 0; i < 30; i++) { + try { + const list = osh("sandbox", "list"); + if (list.includes(SANDBOX_NAME) && list.includes("Ready")) { + ready = true; + break; + } + } catch { /* retry */ } + execSync("sleep 2"); + } + expect(ready).toBe(true); + }, TIMEOUT_LONG); + + afterAll(() => { + try { osh("sandbox", "delete", SANDBOX_NAME); } catch { /* */ } + // Don't destroy the gateway — it's expensive to rebuild and other + // tests may want it. The sandbox is the only thing we clean up. + }, TIMEOUT_MED); + + // ═══════════════════════════════════════════════════════════════════ + // Phase 1: Verify baseline — no overrides active + // ═══════════════════════════════════════════════════════════════════ + + describe("Phase 1: baseline state", () => { + it("sandbox exists and is ready", () => { + const list = osh("sandbox", "list"); + expect(list).toContain(SANDBOX_NAME); + }); + + it("config-get shows no overrides initially", () => { + const output = nem(SANDBOX_NAME, "config-get"); + expect(output).toBeTruthy(); + }); + }); + + // ═══════════════════════════════════════════════════════════════════ + // Phase 2: config-set security — gateway.* refused + // ═══════════════════════════════════════════════════════════════════ + + describe("Phase 2: security enforcement", () => { + for (const key of ["gateway.auth.token", "gateway.port", "gateway"]) { + it(`refuses ${key}`, () => { + const result = nemFail(SANDBOX_NAME, "config-set", "--key", key, "--value", "evil"); + expect(result.status).not.toBe(0); + expect(result.stderr).toMatch(/gateway\.\* fields are immutable/i); + }); + } + + it("refuses missing --key/--value", () => { + const result = nemFail(SANDBOX_NAME, "config-set"); + expect(result.status).not.toBe(0); + expect(result.stderr).toMatch(/Usage:/); + }); + }); + + // ═══════════════════════════════════════════════════════════════════ + // Phase 3: Direct path — config-set writes overrides + // ═══════════════════════════════════════════════════════════════════ + + describe("Phase 3: config-set writes overrides", () => { + const TEST_MODEL = "inference/E2E-DIRECT-PATH-TEST"; + + it("config-set succeeds for a valid key", () => { + const output = nem( + SANDBOX_NAME, "config-set", + "--key", "agents.defaults.model.primary", + "--value", TEST_MODEL, + ); + expect(output).toContain("Set agents.defaults.model.primary"); + }); + + it("config-get reads back the value", () => { + const output = nem( + SANDBOX_NAME, "config-get", + "--key", "agents.defaults.model.primary", + ); + expect(output).toContain(TEST_MODEL); + }); + + it("overrides file exists in sandbox writable partition", () => { + const content = sandboxDownload("/sandbox/.openclaw-data/config-overrides.json5"); + expect(content).toBeTruthy(); + const parsed = JSON.parse(content); + expect(parsed.agents.defaults.model.primary).toBe(TEST_MODEL); + }); + + it("multiple overrides accumulate", () => { + nem(SANDBOX_NAME, "config-set", "--key", "agents.defaults.temperature", "--value", "0.42"); + const content = sandboxDownload("/sandbox/.openclaw-data/config-overrides.json5"); + const parsed = JSON.parse(content); + expect(parsed.agents.defaults.model.primary).toBe(TEST_MODEL); + expect(parsed.agents.defaults.temperature).toBe(0.42); + }); + }); + + // ═══════════════════════════════════════════════════════════════════ + // Phase 4: TUI approval path — scanner detects config request + // ═══════════════════════════════════════════════════════════════════ + + describe("Phase 4: TUI approval path (scanner)", () => { + it("scanner detects config request file and submits PolicyChunk", () => { + // Upload a config request file into the sandbox's config-requests dir. + // The patched supervisor scanner polls every 5s and submits it as a + // PolicyChunk with rule_name "config:". + // + // Scenario: rename the assistant from "Lew Alcindor" to "Kareem Abdul-Jabbar" + // via ui.assistant.name — a non-inference user-preference field. + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-e2e-req-")); + const reqFile = path.join(tmpDir, "test-name-change.json"); + fs.writeFileSync(reqFile, JSON.stringify({ + key: "ui.assistant.name", + value: "Kareem Abdul-Jabbar", + }) + "\n"); + + sandboxUploadFile(reqFile, "/sandbox/.openclaw-data/config-requests/"); + fs.rmSync(tmpDir, { recursive: true, force: true }); + + // Wait for the scanner to poll (5s interval) + submit + execSync("sleep 15"); + + // Verify the scanner detected and submitted the chunk via logs + const logs = nem(SANDBOX_NAME, "logs"); + expect(logs).toContain("Config change request detected, submitting as draft chunk"); + + // Verify the gateway persisted it + expect(logs).toContain("SubmitPolicyAnalysis: persisted draft chunks"); + }); + + it("scanner blocks gateway.* config requests", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-e2e-evil-")); + const evilFile = path.join(tmpDir, "evil.json"); + fs.writeFileSync(evilFile, JSON.stringify({ + key: "gateway.auth.token", + value: "stolen-token", + }) + "\n"); + + sandboxUploadFile(evilFile, "/sandbox/.openclaw-data/config-requests/"); + fs.rmSync(tmpDir, { recursive: true, force: true }); + + execSync("sleep 10"); + + const logs = nem(SANDBOX_NAME, "logs"); + expect(logs).toContain("gateway.* blocked"); + }); + }); + + // ═══════════════════════════════════════════════════════════════════ + // Phase 5: Shim defense-in-depth + // ═══════════════════════════════════════════════════════════════════ + + describe("Phase 5: shim defense-in-depth", () => { + it("gateway.* in overrides file is stripped by shim", () => { + const poisoned = JSON.stringify({ + gateway: { auth: { token: "HACKED" } }, + agents: { defaults: { model: { primary: "inference/SHIM-DEFENSE-TEST" } } }, + }, null, 2); + const tmpFile = path.join(os.tmpdir(), "config-overrides.json5"); + fs.writeFileSync(tmpFile, poisoned); + try { + sandboxUploadFile(tmpFile, "/sandbox/.openclaw-data/"); + } finally { + fs.unlinkSync(tmpFile); + } + + const raw = sandboxDownload("/sandbox/.openclaw-data/config-overrides.json5"); + const parsed = JSON.parse(raw); + // File HAS gateway.* but the shim will strip it at load time + expect(parsed.gateway).toBeDefined(); + // Logs should never contain the stolen token + try { + const logs = nem(SANDBOX_NAME, "logs"); + expect(logs).not.toContain("HACKED"); + } catch { /* logs may be unavailable */ } + }); + }); + + // ═══════════════════════════════════════════════════════════════════ + // Phase 6: Cleanup + // ═══════════════════════════════════════════════════════════════════ + + describe("Phase 6: cleanup", () => { + it("sandbox can be destroyed", () => { + osh("sandbox", "delete", SANDBOX_NAME); + const list = osh("sandbox", "list"); + expect(list).not.toContain(SANDBOX_NAME); + }); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// Unit-level shim verification (always runs, no Docker needed) +// ═══════════════════════════════════════════════════════════════════ + +describe("shim unit verification", () => { + let tmpDir: string; + let patchedModPath: string; + let overridesFile: string; + + const TARGET_FN = "function resolveConfigForRead(resolvedIncludes, env) {"; + const MOCK_DIST = ` +"use strict"; +${TARGET_FN} + return resolvedIncludes; +} +module.exports = { resolveConfigForRead }; +`; + + beforeAll(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-shim-unit-")); + const pkgDir = path.join(tmpDir, "pkg"); + const distDir = path.join(pkgDir, "dist"); + fs.mkdirSync(distDir, { recursive: true }); + fs.writeFileSync(path.join(distDir, "shim-test.js"), MOCK_DIST); + + const shimScript = path.join(ROOT, "patches", "apply-openclaw-shim.js"); + execFileSync("node", [shimScript, pkgDir], { encoding: "utf-8" }); + + patchedModPath = path.join(distDir, "shim-test.js"); + overridesFile = path.join(tmpDir, "config-overrides.json5"); + }); + + afterAll(() => { + delete process.env.OPENCLAW_CONFIG_OVERRIDES_FILE; + delete require.cache[require.resolve(patchedModPath)]; + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function loadShim(): { resolveConfigForRead: (cfg: Record) => Record } { + delete require.cache[require.resolve(patchedModPath)]; + return require(patchedModPath); + } + + it("shim injection patches the dist file", () => { + const content = fs.readFileSync(patchedModPath, "utf-8"); + expect(content).toContain("function _nemoClawMergeOverrides(cfg)"); + expect(content).toContain("resolvedIncludes = _nemoClawMergeOverrides(resolvedIncludes);"); + expect(content).toContain("delete _ov.gateway"); + }); + + it("returns config unchanged when no overrides file", () => { + process.env.OPENCLAW_CONFIG_OVERRIDES_FILE = "/nonexistent/path.json"; + const { resolveConfigForRead } = loadShim(); + const original = { agents: { defaults: { model: { primary: "original" } } } }; + expect(resolveConfigForRead(original)).toEqual(original); + }); + + it("deep-merges overrides onto frozen config", () => { + fs.writeFileSync(overridesFile, JSON.stringify({ + agents: { defaults: { model: { primary: "inference/MERGED" } } }, + })); + process.env.OPENCLAW_CONFIG_OVERRIDES_FILE = overridesFile; + const { resolveConfigForRead } = loadShim(); + + const result = resolveConfigForRead({ + agents: { defaults: { model: { primary: "original", fallback: "fb" }, temperature: 0.7 } }, + version: 1, + }); + + expect((result as any).agents.defaults.model.primary).toBe("inference/MERGED"); + expect((result as any).agents.defaults.model.fallback).toBe("fb"); + expect((result as any).agents.defaults.temperature).toBe(0.7); + expect((result as any).version).toBe(1); + }); + + it("strips gateway.* from overrides (defense in depth)", () => { + fs.writeFileSync(overridesFile, JSON.stringify({ + gateway: { auth: { token: "STOLEN" } }, + agents: { defaults: { model: { primary: "inference/legit" } } }, + })); + process.env.OPENCLAW_CONFIG_OVERRIDES_FILE = overridesFile; + const { resolveConfigForRead } = loadShim(); + + const result = resolveConfigForRead({ + gateway: { auth: { token: "REAL" }, port: 8080 }, + agents: { defaults: { model: { primary: "original" } } }, + }); + + expect((result as any).gateway.auth.token).toBe("REAL"); + expect((result as any).gateway.port).toBe(8080); + expect((result as any).agents.defaults.model.primary).toBe("inference/legit"); + }); + + it("handles malformed JSON gracefully", () => { + fs.writeFileSync(overridesFile, "NOT JSON {{{"); + process.env.OPENCLAW_CONFIG_OVERRIDES_FILE = overridesFile; + const { resolveConfigForRead } = loadShim(); + expect(resolveConfigForRead({ foo: "bar" })).toEqual({ foo: "bar" }); + }); + + it("replaces arrays instead of merging them", () => { + fs.writeFileSync(overridesFile, JSON.stringify({ + agents: { defaults: { tools: ["new-a", "new-b"] } }, + })); + process.env.OPENCLAW_CONFIG_OVERRIDES_FILE = overridesFile; + const { resolveConfigForRead } = loadShim(); + + const result = resolveConfigForRead({ + agents: { defaults: { tools: ["old"], model: { primary: "orig" } } }, + }); + + expect((result as any).agents.defaults.tools).toEqual(["new-a", "new-b"]); + expect((result as any).agents.defaults.model.primary).toBe("orig"); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// config-set CLI security (always runs, no Docker needed) +// ═══════════════════════════════════════════════════════════════════ + +describe("config-set security", () => { + const configSetPath = path.join(ROOT, "bin", "lib", "config-set").replace(/\\/g, "\\\\"); + + function runConfigSet(...args: string[]): { status: number; stderr: string; stdout: string } { + const argsStr = args.map((a) => `"${a}"`).join(", "); + try { + const stdout = execFileSync("node", ["-e", ` + const { configSet } = require("${configSetPath}"); + configSet("fake-sandbox", [${argsStr}]); + `], { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }); + return { status: 0, stderr: "", stdout }; + } catch (err: unknown) { + const e = err as { status: number; stderr: string; stdout: string }; + return { status: e.status, stderr: e.stderr ?? "", stdout: e.stdout ?? "" }; + } + } + + it("refuses gateway.auth.token", () => { + const r = runConfigSet("--key", "gateway.auth.token", "--value", "evil"); + expect(r.status).not.toBe(0); + expect(r.stderr).toMatch(/gateway\.\* fields are immutable/i); + }); + + it("refuses gateway.port", () => { + const r = runConfigSet("--key", "gateway.port", "--value", "9999"); + expect(r.status).not.toBe(0); + expect(r.stderr).toMatch(/gateway\.\* fields are immutable/i); + }); + + it("refuses bare gateway", () => { + const r = runConfigSet("--key", "gateway", "--value", "{}"); + expect(r.status).not.toBe(0); + expect(r.stderr).toMatch(/gateway\.\* fields are immutable/i); + }); + + it("refuses missing --key/--value", () => { + const r = runConfigSet(); + expect(r.status).not.toBe(0); + expect(r.stderr).toMatch(/Usage:/); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// apply-openclaw-shim.js (always runs, no Docker needed) +// ═══════════════════════════════════════════════════════════════════ + +describe("apply-openclaw-shim.js", () => { + it("patches multiple dist files", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-shim-multi-")); + const distDir = path.join(tmpDir, "dist"); + fs.mkdirSync(distDir, { recursive: true }); + const target = "function resolveConfigForRead(resolvedIncludes, env) {"; + for (const name of ["a.js", "b.js", "c.js"]) { + fs.writeFileSync(path.join(distDir, name), `"use strict";\n${target}\n return resolvedIncludes;\n}`); + } + fs.writeFileSync(path.join(distDir, "unrelated.js"), "module.exports = {};"); + + const output = execFileSync("node", [path.join(ROOT, "patches", "apply-openclaw-shim.js"), tmpDir], { + encoding: "utf-8", + }); + expect(output).toContain("Patched 3 files"); + expect(fs.readFileSync(path.join(distDir, "unrelated.js"), "utf-8")).toBe("module.exports = {};"); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("exits non-zero when no files match", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-shim-none-")); + const distDir = path.join(tmpDir, "dist"); + fs.mkdirSync(distDir, { recursive: true }); + fs.writeFileSync(path.join(distDir, "nope.js"), "// nothing"); + + try { + execFileSync("node", [path.join(ROOT, "patches", "apply-openclaw-shim.js"), tmpDir], { + encoding: "utf-8", + }); + expect.unreachable("should have thrown"); + } catch (err: unknown) { + const e = err as { status: number; stderr: string }; + expect(e.status).toBe(1); + expect(e.stderr).toMatch(/WARNING: No files patched/); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); diff --git a/test/config-set.test.js b/test/config-set.test.js new file mode 100644 index 00000000000..8a939e46c2b --- /dev/null +++ b/test/config-set.test.js @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from "assert"; +import { describe, it } from "vitest"; +import { createRequire } from "module"; + +const require = createRequire(import.meta.url); +const { loadAllowList, OVERRIDES_PATH } = require("../bin/lib/config-set"); + +describe("config-set", () => { + describe("loadAllowList", () => { + it("returns a Set", () => { + const allowList = loadAllowList(); + assert.ok(allowList instanceof Set); + }); + + it("does NOT include gateway paths", () => { + const allowList = loadAllowList(); + for (const key of allowList) { + assert.ok(!key.startsWith("gateway."), `allow-list must not contain gateway.* keys, found: ${key}`); + } + }); + }); + + describe("OVERRIDES_PATH", () => { + it("points to writable partition", () => { + assert.ok(OVERRIDES_PATH.startsWith("/sandbox/.openclaw-data/")); + }); + + it("is a json5 file", () => { + assert.ok(OVERRIDES_PATH.endsWith(".json5")); + }); + }); +}); diff --git a/test/policies.test.js b/test/policies.test.js index 1671b77265c..a9794e3c094 100644 --- a/test/policies.test.js +++ b/test/policies.test.js @@ -2,6 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; import { describe, it, expect } from "vitest"; import policies from "../bin/lib/policies"; @@ -136,6 +138,35 @@ describe("policies", () => { }); }); + describe("base policy", () => { + const basePolicyPath = path.join(import.meta.dirname, "..", "nemoclaw-blueprint", "policies", "openclaw-sandbox.yaml"); + const basePolicy = fs.readFileSync(basePolicyPath, "utf-8"); + + it("does not contain tls: terminate (deprecated in OpenShell >= 0.0.15)", () => { + const lines = basePolicy.split("\n").filter(l => !l.trim().startsWith("#")); + for (const line of lines) { + expect(line.includes("tls: terminate")).toBe(false); + } + }); + + }); + + describe("no preset contains tls: terminate", () => { + it("all presets are free of deprecated tls: terminate", () => { + for (const p of policies.listPresets()) { + const content = policies.loadPreset(p.name); + const lines = content.split("\n").filter(l => !l.trim().startsWith("#")); + for (let i = 0; i < lines.length; i++) { + if (lines[i].includes("tls: terminate")) { + expect.unreachable( + `${p.name} line ${i + 1}: contains deprecated tls: terminate` + ); + } + } + } + }); + }); + describe("preset YAML schema", () => { it("no preset has rules at NetworkPolicyRuleDef level", () => { // rules must be inside endpoints, not as sibling of endpoints/binaries