From 0b1930a456ac7f9d86edd6cb932ac68693b714da Mon Sep 17 00:00:00 2001 From: Frank Ruiz Date: Sat, 21 Mar 2026 03:05:01 -0700 Subject: [PATCH 1/3] fix: resolve openclaw.json permissions conflict and scope Dockerfile lockdown - Bake both 'nvidia' and 'inference' providers into openclaw.json at image build time; remove runtime Python config-patching from buildSandboxConfigSyncScript (writes to locked root:root 444 file) - Use `openclaw models set` for runtime model selection (writes to writable agent config in .openclaw-data/) - Add identity/, devices/, canvas/, cron/ to .openclaw-data symlinks so the gateway can write device-auth.json at runtime - Remove dead `openclaw doctor --fix` and `openclaw plugins install` calls from nemoclaw-start.sh (already ran at build time, fail with EPERM at runtime) Caused-by: 2d3f84e (fix: lock gateway config via Landlock filesystem policy) Fixes #514 --- Dockerfile | 32 ++++++++++++++++++++++------ bin/lib/onboard.js | 45 ++++++--------------------------------- scripts/nemoclaw-start.sh | 5 +++-- test/onboard.test.js | 13 ++++++----- 4 files changed, 42 insertions(+), 53 deletions(-) diff --git a/Dockerfile b/Dockerfile index b0e4e62d22d..2d945e3c36d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,12 +33,22 @@ RUN mkdir -p /sandbox/.openclaw-data/agents/main/agent \ /sandbox/.openclaw-data/workspace \ /sandbox/.openclaw-data/skills \ /sandbox/.openclaw-data/hooks \ + /sandbox/.openclaw-data/identity \ + /sandbox/.openclaw-data/devices \ + /sandbox/.openclaw-data/canvas \ + /sandbox/.openclaw-data/cron \ && mkdir -p /sandbox/.openclaw \ && ln -s /sandbox/.openclaw-data/agents /sandbox/.openclaw/agents \ && ln -s /sandbox/.openclaw-data/extensions /sandbox/.openclaw/extensions \ && ln -s /sandbox/.openclaw-data/workspace /sandbox/.openclaw/workspace \ && ln -s /sandbox/.openclaw-data/skills /sandbox/.openclaw/skills \ && ln -s /sandbox/.openclaw-data/hooks /sandbox/.openclaw/hooks \ + && ln -s /sandbox/.openclaw-data/identity /sandbox/.openclaw/identity \ + && ln -s /sandbox/.openclaw-data/devices /sandbox/.openclaw/devices \ + && ln -s /sandbox/.openclaw-data/canvas /sandbox/.openclaw/canvas \ + && ln -s /sandbox/.openclaw-data/cron /sandbox/.openclaw/cron \ + && touch /sandbox/.openclaw-data/update-check.json \ + && ln -s /sandbox/.openclaw-data/update-check.json /sandbox/.openclaw/update-check.json \ && chown -R sandbox:sandbox /sandbox/.openclaw /sandbox/.openclaw-data # Install OpenClaw CLI @@ -91,13 +101,21 @@ chat_origin = f'{parsed.scheme}://{parsed.netloc}' if parsed.scheme and parsed.n origins = ['http://127.0.0.1:18789']; \ origins = list(dict.fromkeys(origins + [chat_origin])); \ config = { \ - 'agents': {'defaults': {'model': {'primary': model}}}, \ - 'models': {'mode': 'merge', 'providers': {'nvidia': { \ - 'baseUrl': 'https://inference.local/v1', \ - 'apiKey': 'openshell-managed', \ - 'api': 'openai-completions', \ - 'models': [{'id': model.split('/')[-1], 'name': model, 'reasoning': False, 'input': ['text'], 'cost': {'input': 0, 'output': 0, 'cacheRead': 0, 'cacheWrite': 0}, 'contextWindow': 131072, 'maxTokens': 4096}] \ - }}}, \ + 'agents': {'defaults': {'model': {'primary': f'inference/{model}'}}}, \ + 'models': {'mode': 'merge', 'providers': { \ + 'nvidia': { \ + 'baseUrl': 'https://inference.local/v1', \ + 'apiKey': 'openshell-managed', # pragma: allowlist secret \ + 'api': 'openai-completions', \ + 'models': [{'id': model.split('/')[-1], 'name': model, 'reasoning': False, 'input': ['text'], 'cost': {'input': 0, 'output': 0, 'cacheRead': 0, 'cacheWrite': 0}, 'contextWindow': 131072, 'maxTokens': 4096}] \ + }, \ + 'inference': { \ + 'baseUrl': 'https://inference.local/v1', \ + 'apiKey': 'unused', # pragma: allowlist secret \ + 'api': 'openai-completions', \ + 'models': [{'id': model, 'name': model, 'reasoning': False, 'input': ['text'], 'cost': {'input': 0, 'output': 0, 'cacheRead': 0, 'cacheWrite': 0}, 'contextWindow': 131072, 'maxTokens': 4096}] \ + } \ + }}, \ 'gateway': { \ 'mode': 'local', \ 'controlUi': { \ diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index ccb13577eb7..20cc6984b34 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -116,50 +116,17 @@ function buildSandboxConfigSyncScript(selectionConfig) { ? "vllm-local" : "nvidia-nim"; const primaryModel = getOpenClawPrimaryModel(providerType, selectionConfig.model); - const providerKey = "inference"; - const providerConfig = { - baseUrl: selectionConfig.endpointUrl, - apiKey: "unused", - api: "openai-completions", - models: [ - { - id: selectionConfig.model, - name: selectionConfig.model, - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 131072, - maxTokens: 4096, - }, - ], - }; + // openclaw.json is immutable (root:root 444, Landlock read-only) — never + // write to it at runtime. The inference provider and default model are baked + // at image build time. We only write the NemoClaw selection config (writable + // ~/.nemoclaw/) and override the active model via `openclaw models set`, + // which writes to the agent-level config in ~/.openclaw-data/ (writable). return ` set -euo pipefail -mkdir -p ~/.nemoclaw ~/.openclaw +mkdir -p ~/.nemoclaw cat > ~/.nemoclaw/config.json <<'EOF_NEMOCLAW_CFG' ${JSON.stringify(selectionConfig, null, 2)} EOF_NEMOCLAW_CFG -python3 - <<'PYCFG' -import json -import os - -cfg_path = os.path.expanduser('~/.openclaw/openclaw.json') -cfg = {} -if os.path.exists(cfg_path): - with open(cfg_path) as f: - cfg = json.load(f) - -cfg.setdefault('agents', {}).setdefault('defaults', {}).setdefault('model', {})['primary'] = ${JSON.stringify(primaryModel)} -models_cfg = cfg.setdefault('models', {}) -models_cfg.setdefault('mode', 'merge') -providers_cfg = models_cfg.setdefault('providers', {}) -providers_cfg[${JSON.stringify(providerKey)}] = json.loads(${pythonLiteralJson(providerConfig)}) - -with open(cfg_path, 'w') as f: - json.dump(cfg, f, indent=2) - -os.chmod(cfg_path, 0o600) -PYCFG openclaw models set ${shellQuote(primaryModel)} > /dev/null 2>&1 || true exit `.trim(); diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 3b78c54618b..d28b9637498 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -127,9 +127,10 @@ PYAUTOPAIR } echo 'Setting up NemoClaw...' -openclaw doctor --fix > /dev/null 2>&1 || true +# openclaw doctor --fix and openclaw plugins install already ran at build time +# (Dockerfile Step 28). At runtime they fail with EPERM against the locked +# /sandbox/.openclaw directory and accomplish nothing. write_auth_profile -openclaw plugins install /opt/nemoclaw > /dev/null 2>&1 || true if [ ${#NEMOCLAW_CMD[@]} -gt 0 ]; then exec "${NEMOCLAW_CMD[@]}" diff --git a/test/onboard.test.js b/test/onboard.test.js index 1efc60edf65..7bd62728f53 100644 --- a/test/onboard.test.js +++ b/test/onboard.test.js @@ -11,7 +11,7 @@ const { } = require("../bin/lib/onboard"); describe("onboard helpers", () => { - it("builds a sandbox sync script that writes config and updates the selected model", () => { + it("builds a sandbox sync script that writes config and sets the model", () => { const script = buildSandboxConfigSyncScript({ endpointType: "custom", endpointUrl: "https://inference.local/v1", @@ -22,14 +22,17 @@ describe("onboard helpers", () => { onboardedAt: "2026-03-18T12:00:00.000Z", }); + // Writes NemoClaw selection config to writable ~/.nemoclaw/ assert.match(script, /cat > ~\/\.nemoclaw\/config\.json/); assert.match(script, /"model": "nemotron-3-nano:30b"/); assert.match(script, /"credentialEnv": "OPENAI_API_KEY"/); + + // Sets the active model via openclaw CLI (writes to agent config, not openclaw.json) assert.match(script, /openclaw models set 'inference\/nemotron-3-nano:30b'/); - assert.match(script, /cfg\.setdefault\('agents', \{\}\)\.setdefault\('defaults', \{\}\)\.setdefault\('model', \{\}\)\['primary'\]/); - assert.match(script, /providers_cfg\["inference"\]/); - assert.match(script, /json\.loads\("\{\\\"baseUrl\\\":\\\"https:\/\/inference\.local\/v1\\\",\\\"apiKey\\\":\\\"unused\\\"/); - assert.match(script, /inference\/nemotron-3-nano:30b/); + + // Must NOT write to openclaw.json — it is immutable (root:root 444) + assert.doesNotMatch(script, /openclaw\.json/); + assert.match(script, /^exit$/m); }); From 116c893840d4d9f7781626974dcffd0675a88a0e Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sat, 21 Mar 2026 08:18:16 -0700 Subject: [PATCH 2/3] fix: remove pragma comments that break inline Python in Dockerfile The # pragma: allowlist secret comments inside the multi-line python3 -c string cause Python to treat everything after # as a comment, swallowing the \ line continuation and closing braces. This results in: SyntaxError: '{' was never closed Reported by DanTup in PR #570. --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 2d945e3c36d..d6df8b46bb3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -105,13 +105,13 @@ config = { \ 'models': {'mode': 'merge', 'providers': { \ 'nvidia': { \ 'baseUrl': 'https://inference.local/v1', \ - 'apiKey': 'openshell-managed', # pragma: allowlist secret \ + 'apiKey': 'openshell-managed', \ 'api': 'openai-completions', \ 'models': [{'id': model.split('/')[-1], 'name': model, 'reasoning': False, 'input': ['text'], 'cost': {'input': 0, 'output': 0, 'cacheRead': 0, 'cacheWrite': 0}, 'contextWindow': 131072, 'maxTokens': 4096}] \ }, \ 'inference': { \ 'baseUrl': 'https://inference.local/v1', \ - 'apiKey': 'unused', # pragma: allowlist secret \ + 'apiKey': 'unused', \ 'api': 'openai-completions', \ 'models': [{'id': model, 'name': model, 'reasoning': False, 'input': ['text'], 'cost': {'input': 0, 'output': 0, 'cacheRead': 0, 'cacheWrite': 0}, 'contextWindow': 131072, 'maxTokens': 4096}] \ } \ From 5695b9317c13b2053fdd19de67da0be744629370 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sat, 21 Mar 2026 08:45:26 -0700 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20remove=20openclaw=20models=20set=20f?= =?UTF-8?q?rom=20sync=20script=20=E2=80=94=20config=20stays=20on=20host?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit openclaw models set writes to openclaw.json, which is correctly locked (root:root 444 + Landlock read-only). Model routing is handled by the host-side gateway via openshell inference set (Step 5), not from inside the sandbox. The sync script should only write NemoClaw's own selection config to ~/.nemoclaw/config.json. Remove openclaw models set call, dead pythonLiteralJson helper, and unused getOpenClawPrimaryModel/DEFAULT_OLLAMA_MODEL imports. --- bin/lib/onboard.js | 23 +++-------------------- test/onboard.test.js | 9 ++++----- 2 files changed, 7 insertions(+), 25 deletions(-) diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 20cc6984b34..b7330ea6105 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -19,8 +19,6 @@ const { const { CLOUD_MODEL_OPTIONS, DEFAULT_CLOUD_MODEL, - DEFAULT_OLLAMA_MODEL, - getOpenClawPrimaryModel, getProviderSelectionConfig, } = require("./inference-config"); const { @@ -102,32 +100,17 @@ function getStableGatewayImageRef(versionOutput = null) { return `ghcr.io/nvidia/openshell/cluster:${version}`; } -function pythonLiteralJson(value) { - return JSON.stringify(JSON.stringify(value)); -} - function buildSandboxConfigSyncScript(selectionConfig) { - const providerType = - selectionConfig.profile === "inference-local" - ? selectionConfig.model === DEFAULT_OLLAMA_MODEL - ? "ollama-local" - : "nvidia-nim" - : selectionConfig.endpointType === "vllm" - ? "vllm-local" - : "nvidia-nim"; - const primaryModel = getOpenClawPrimaryModel(providerType, selectionConfig.model); // openclaw.json is immutable (root:root 444, Landlock read-only) — never - // write to it at runtime. The inference provider and default model are baked - // at image build time. We only write the NemoClaw selection config (writable - // ~/.nemoclaw/) and override the active model via `openclaw models set`, - // which writes to the agent-level config in ~/.openclaw-data/ (writable). + // write to it at runtime. Model routing is handled by the host-side + // gateway (`openshell inference set` in Step 5), not from inside the + // sandbox. We only write the NemoClaw selection config (~/.nemoclaw/). return ` set -euo pipefail mkdir -p ~/.nemoclaw cat > ~/.nemoclaw/config.json <<'EOF_NEMOCLAW_CFG' ${JSON.stringify(selectionConfig, null, 2)} EOF_NEMOCLAW_CFG -openclaw models set ${shellQuote(primaryModel)} > /dev/null 2>&1 || true exit `.trim(); } diff --git a/test/onboard.test.js b/test/onboard.test.js index 7bd62728f53..76843b765a9 100644 --- a/test/onboard.test.js +++ b/test/onboard.test.js @@ -11,7 +11,7 @@ const { } = require("../bin/lib/onboard"); describe("onboard helpers", () => { - it("builds a sandbox sync script that writes config and sets the model", () => { + it("builds a sandbox sync script that only writes nemoclaw config", () => { const script = buildSandboxConfigSyncScript({ endpointType: "custom", endpointUrl: "https://inference.local/v1", @@ -27,11 +27,10 @@ describe("onboard helpers", () => { assert.match(script, /"model": "nemotron-3-nano:30b"/); assert.match(script, /"credentialEnv": "OPENAI_API_KEY"/); - // Sets the active model via openclaw CLI (writes to agent config, not openclaw.json) - assert.match(script, /openclaw models set 'inference\/nemotron-3-nano:30b'/); - - // Must NOT write to openclaw.json — it is immutable (root:root 444) + // Must NOT modify openclaw config from inside the sandbox — model routing + // is handled by the host-side gateway (openshell inference set) assert.doesNotMatch(script, /openclaw\.json/); + assert.doesNotMatch(script, /openclaw models set/); assert.match(script, /^exit$/m); });