From 816efb7ccf6b6347dcbf4cc4153a4de93da860d4 Mon Sep 17 00:00:00 2001 From: Dongni Yang Date: Tue, 21 Apr 2026 15:32:01 +0800 Subject: [PATCH 1/7] fix(onboard): inject Slack tokens as --env in sandbox create (#2085) SLACK_BOT_TOKEN and SLACK_APP_TOKEN were stored as openshell provider credentials but never forwarded as --env to the sandbox create command. The baked openclaw.json uses openshell:resolve:env: placeholders for both tokens; without the real values in the container env those placeholders stay unresolved and Bolt Socket Mode crashes on boot with invalid_auth. Mirrors the existing BRAVE_API_KEY pattern: real token values are injected as positional --env args (visible in the command string) while remaining absent from the openshell subprocess spawn env, which is governed by the separate blockedSandboxEnvNames allowlist. Fixes #2085 Signed-off-by: Dongni Yang --- src/lib/onboard.ts | 10 ++++++++++ test/onboard.test.ts | 20 +++++++++++++++++--- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 56755ce447e..dbe1a45596c 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -3428,6 +3428,16 @@ async function createSandbox( envArgs.push(formatEnvAssignment(webSearch.BRAVE_API_KEY_ENV, braveKey)); } } + // Slack Socket Mode requires both tokens in the container env so the baked + // openshell:resolve:env: placeholders in openclaw.json are substituted. + // The provider registration above handles L7 proxy auth header rewriting; + // the --env args here ensure the container env vars hold the real values. + if (tokensByEnvKey["SLACK_BOT_TOKEN"]) { + envArgs.push(formatEnvAssignment("SLACK_BOT_TOKEN", tokensByEnvKey["SLACK_BOT_TOKEN"])); + if (tokensByEnvKey["SLACK_APP_TOKEN"]) { + envArgs.push(formatEnvAssignment("SLACK_APP_TOKEN", tokensByEnvKey["SLACK_APP_TOKEN"])); + } + } const sandboxEnv = buildSubprocessEnv(); // Remove host-infrastructure credentials that the generic allowlist // permits for host-side processes but that must not enter the sandbox. diff --git a/test/onboard.test.ts b/test/onboard.test.ts index 0cced605456..2a7ee8f1185 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -2724,6 +2724,7 @@ const { createSandbox } = require(${onboardPath}); process.env.OPENSHELL_GATEWAY = "nemoclaw"; process.env.DISCORD_BOT_TOKEN = "test-discord-token-value"; process.env.SLACK_BOT_TOKEN = "xoxb-test-slack-token-value"; + process.env.SLACK_APP_TOKEN = "xapp-test-slack-app-token-value"; process.env.TELEGRAM_BOT_TOKEN = "123456:ABC-test-telegram-token"; const sandboxName = await createSandbox(null, "gpt-5.4"); console.log(JSON.stringify({ sandboxName, commands })); @@ -2784,10 +2785,14 @@ const { createSandbox } = require(${onboardPath}); assert.match(createCommand.command, /--provider my-assistant-slack-bridge/); assert.match(createCommand.command, /--provider my-assistant-telegram-bridge/); - // Verify real token values are NOT in the sandbox create command + // Discord and Telegram tokens must NOT appear in the sandbox create command + // (they flow exclusively through the openshell provider credential system). assert.doesNotMatch(createCommand.command, /test-discord-token-value/); - assert.doesNotMatch(createCommand.command, /xoxb-test-slack-token-value/); assert.doesNotMatch(createCommand.command, /123456:ABC-test-telegram-token/); + // Slack tokens ARE injected as --env args so the baked openclaw.json + // openshell:resolve:env: placeholders resolve inside the container. + assert.match(createCommand.command, /SLACK_BOT_TOKEN=xoxb-test-slack-token-value/); + assert.match(createCommand.command, /SLACK_APP_TOKEN=xapp-test-slack-app-token-value/); // Verify blocked credentials are NOT in the sandbox spawn environment assert.ok(createCommand.env, "expected env to be captured from spawn call"); @@ -2801,6 +2806,11 @@ const { createSandbox } = require(${onboardPath}); undefined, "SLACK_BOT_TOKEN must not be in sandbox env", ); + assert.equal( + createCommand.env.SLACK_APP_TOKEN, + undefined, + "SLACK_APP_TOKEN must not be in sandbox env", + ); assert.equal( createCommand.env.TELEGRAM_BOT_TOKEN, undefined, @@ -2820,7 +2830,11 @@ const { createSandbox } = require(${onboardPath}); ); assert.ok( !envString.includes("xoxb-test-slack-token-value"), - "Slack token value must not leak into sandbox env", + "Slack bot token value must not leak into sandbox spawn env", + ); + assert.ok( + !envString.includes("xapp-test-slack-app-token-value"), + "Slack app token value must not leak into sandbox spawn env", ); assert.ok( !envString.includes("123456:ABC-test-telegram-token"), From b32f8af640bb626806c000f71ffcb9fd3ed9ae3a Mon Sep 17 00:00:00 2001 From: Dongni Yang Date: Tue, 21 Apr 2026 17:44:41 +0800 Subject: [PATCH 2/7] fix(sandbox): resolve Slack token placeholders in openclaw.json at startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add apply_slack_token_override to nemoclaw-start.sh (after apply_cors_override, before chattr +i) using the same root-only, symlink-guarded, hash-recomputed pattern as apply_model_override. The function substitutes openshell:resolve:env: placeholders in openclaw.json directly, so Bolt's in-process appToken validation (xapp- prefix check) passes before any network connection is opened — without waiting for the L7 proxy. Both SLACK_BOT_TOKEN and SLACK_APP_TOKEN are unset from the process env after patching in the root path so tokens are not visible to the gateway or sandbox user processes. Token format (xoxb-/xapp-) is validated before patching. Signed-off-by: Dongni Yang --- scripts/nemoclaw-start.sh | 94 ++++++++++++++++++++++++++++++++++--- test/nemoclaw-start.test.ts | 86 ++++++++++++++++++++++++++++++++- 2 files changed, 172 insertions(+), 8 deletions(-) diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 32191542496..8e559845594 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -401,6 +401,78 @@ PYCORS printf '[config] Config hash recomputed after CORS override\n' >&2 } +# ── Slack token placeholder resolution ──────────────────────────── +# Resolves openshell:resolve:env:SLACK_* placeholders in openclaw.json at +# container startup, before chattr +i locks the file. This ensures Bolt's +# in-process token validation (appToken must start with xapp-) succeeds even +# before the L7 proxy can intercept HTTP calls. +# Same trust model as apply_model_override: host-set env vars, root-only, +# applied before Landlock/chattr +i, hash recomputed. Tokens are unset from +# the process env after patching so they are not visible inside the sandbox. +# Ref: https://github.com/NVIDIA/NemoClaw/issues/2085 + +apply_slack_token_override() { + [ -n "${SLACK_BOT_TOKEN:-}" ] || return 0 + + # SECURITY: Only root can write to /sandbox/.openclaw (root:root 444). + if [ "$(id -u)" -ne 0 ]; then + printf '[SECURITY] Slack token override ignored — requires root (non-root mode cannot write to config)\n' >&2 + return 0 + fi + + local config_file="/sandbox/.openclaw/openclaw.json" + local hash_file="/sandbox/.openclaw/.config-hash" + + # SECURITY: Refuse to write through symlinks to prevent symlink-following attacks. + if [ -L "$config_file" ] || [ -L "$hash_file" ]; then + printf '[SECURITY] Refusing Slack token override — config or hash path is a symlink\n' >&2 + return 1 + fi + + # SECURITY: Validate token prefixes — reject anything that doesn't look like a real Slack token. + case "${SLACK_BOT_TOKEN}" in + xoxb-*) ;; + *) printf '[channels] SLACK_BOT_TOKEN does not start with xoxb- — skipping Slack placeholder resolution\n' >&2; return 0 ;; + esac + + if [ -n "${SLACK_APP_TOKEN:-}" ]; then + case "$SLACK_APP_TOKEN" in + xapp-*) ;; + *) printf '[channels] SLACK_APP_TOKEN does not start with xapp- — skipping Slack placeholder resolution\n' >&2; return 0 ;; + esac + fi + + printf '[channels] Resolving Slack token placeholders in openclaw.json\n' >&2 + + SLACK_BOT_TOKEN="$SLACK_BOT_TOKEN" \ + SLACK_APP_TOKEN="${SLACK_APP_TOKEN:-}" \ + python3 - "$config_file" <<'PYSLACK' +import json, os, sys + +config_file = sys.argv[1] +bot_token = os.environ["SLACK_BOT_TOKEN"] +app_token = os.environ.get("SLACK_APP_TOKEN", "") +placeholder_prefix = "openshell:resolve:env:" + +with open(config_file) as f: + cfg = json.load(f) + +slack = cfg.get("channels", {}).get("slack", {}) +default_acct = slack.get("accounts", {}).get("default", {}) + +if default_acct.get("botToken", "").startswith(placeholder_prefix): + default_acct["botToken"] = bot_token +if app_token and default_acct.get("appToken", "").startswith(placeholder_prefix): + default_acct["appToken"] = app_token + +with open(config_file, "w") as f: + json.dump(cfg, f, indent=2) +PYSLACK + + (cd /sandbox/.openclaw && sha256sum openclaw.json >"$hash_file") + printf '[channels] Config hash recomputed after Slack token override\n' >&2 +} + _read_gateway_token() { python3 - <<'PYTOKEN' import json @@ -635,14 +707,16 @@ harden_auth_profiles() { configure_messaging_channels() { # Channel entries are baked into openclaw.json at image build time via - # NEMOCLAW_MESSAGING_CHANNELS_B64 (see Dockerfile). Placeholder tokens - # (openshell:resolve:env:*) flow through to API calls where the L7 proxy - # rewrites them with real secrets at egress. Real tokens are never visible - # inside the sandbox. + # NEMOCLAW_MESSAGING_CHANNELS_B64 (see Dockerfile). # - # Runtime patching of /sandbox/.openclaw/openclaw.json is not possible: - # Landlock enforces read-only on /sandbox/.openclaw/ at the kernel level, - # regardless of DAC (file ownership/chmod). Writes fail with EPERM. + # Telegram/Discord: placeholder tokens (openshell:resolve:env:*) flow through + # to API calls where the L7 proxy rewrites them with real secrets at egress. + # Real tokens are never visible inside the sandbox for these channels. + # + # Slack: apply_slack_token_override (runs before this function) resolves + # SLACK_BOT_TOKEN/SLACK_APP_TOKEN placeholders directly into openclaw.json so + # Bolt's in-process token validation passes. Both env vars are unset before the + # gateway starts (root path) so they do not leak into the sandbox process env. [ -n "${TELEGRAM_BOT_TOKEN:-}" ] || [ -n "${DISCORD_BOT_TOKEN:-}" ] || [ -n "${SLACK_BOT_TOKEN:-}" ] || return 0 echo "[channels] Messaging channels active (baked at build time):" >&2 @@ -858,6 +932,7 @@ if [ "$(id -u)" -ne 0 ]; then fi apply_model_override apply_cors_override + apply_slack_token_override export_gateway_token install_configure_guard configure_messaging_channels @@ -957,6 +1032,7 @@ fi verify_config_integrity apply_model_override apply_cors_override +apply_slack_token_override export_gateway_token install_configure_guard @@ -995,6 +1071,10 @@ validate_openclaw_symlinks # Ref: https://github.com/NVIDIA/NemoClaw/issues/1019 harden_openclaw_symlinks +# SECURITY: Slack tokens were resolved into openclaw.json by apply_slack_token_override. +# Clear them from the process env so neither the gateway nor the sandbox user inherits them. +unset SLACK_BOT_TOKEN SLACK_APP_TOKEN + # Start the gateway as the 'gateway' user. # SECURITY: The sandbox user cannot kill this process because it runs # under a different UID. The fake-HOME attack no longer works because diff --git a/test/nemoclaw-start.test.ts b/test/nemoclaw-start.test.ts index 51f1ae6962b..f60fbfb913a 100644 --- a/test/nemoclaw-start.test.ts +++ b/test/nemoclaw-start.test.ts @@ -423,7 +423,7 @@ describe("runtime CORS origin override (#719)", () => { ); const rootBlock = src.match( - /# ── Root path[\s\S]*?apply_model_override\n\s*apply_cors_override\n\s*export_gateway_token/, + /# ── Root path[\s\S]*?apply_model_override\n\s*apply_cors_override\n\s*apply_slack_token_override\n\s*export_gateway_token/, ); expect(rootBlock).toBeTruthy(); }); @@ -468,6 +468,90 @@ describe("runtime CORS origin override (#719)", () => { }); }); +describe("Slack token placeholder resolution (#2085)", () => { + const src = fs.readFileSync(START_SCRIPT, "utf-8"); + + it("defines apply_slack_token_override function", () => { + expect(src).toContain("apply_slack_token_override()"); + expect(src).toContain("SLACK_BOT_TOKEN"); + expect(src).toContain("SLACK_APP_TOKEN"); + }); + + it("calls apply_slack_token_override after apply_cors_override in both paths", () => { + const nonRootBlock = src.match(/if \[ "\$\(id -u\)" -ne 0 \]; then([\s\S]*?)# ── Root path/); + expect(nonRootBlock).toBeTruthy(); + expect(nonRootBlock[1]).toMatch( + /apply_cors_override[\s\S]*?apply_slack_token_override[\s\S]*?export_gateway_token/, + ); + + const rootBlock = src.match( + /# ── Root path[\s\S]*?apply_cors_override\n\s*apply_slack_token_override\n\s*export_gateway_token/, + ); + expect(rootBlock).toBeTruthy(); + }); + + it("is a no-op when SLACK_BOT_TOKEN is not set", () => { + const fn = src.match(/apply_slack_token_override\(\) \{([\s\S]*?)^}/m); + expect(fn).toBeTruthy(); + expect(fn[1]).toMatch(/\[ -n "\$\{SLACK_BOT_TOKEN:-\}" \] \|\| return 0/); + }); + + it("only applies override in root mode", () => { + const fn = src.match(/apply_slack_token_override\(\) \{([\s\S]*?)^}/m); + expect(fn).toBeTruthy(); + expect(fn[1]).toMatch(/id -u.*-ne 0/); + expect(fn[1]).toContain("requires root"); + }); + + it("guards against symlink attacks", () => { + const fn = src.match(/apply_slack_token_override\(\) \{([\s\S]*?)^}/m); + expect(fn).toBeTruthy(); + expect(fn[1]).toContain('-L "$config_file"'); + expect(fn[1]).toContain("Refusing Slack token override"); + }); + + it("validates botToken prefix is xoxb-", () => { + const fn = src.match(/apply_slack_token_override\(\) \{([\s\S]*?)^}/m); + expect(fn).toBeTruthy(); + expect(fn[1]).toContain("xoxb-"); + expect(fn[1]).toContain("does not start with xoxb-"); + }); + + it("validates appToken prefix is xapp-", () => { + const fn = src.match(/apply_slack_token_override\(\) \{([\s\S]*?)^}/m); + expect(fn).toBeTruthy(); + expect(fn[1]).toContain("xapp-"); + expect(fn[1]).toContain("does not start with xapp-"); + }); + + it("recomputes config hash after override", () => { + const fn = src.match(/apply_slack_token_override\(\) \{([\s\S]*?)^}/m); + expect(fn).toBeTruthy(); + expect(fn[1]).toContain("sha256sum openclaw.json"); + expect(fn[1]).toContain("config-hash"); + }); + + it("resolves openshell:resolve:env: placeholders via Python", () => { + const fn = src.match(/apply_slack_token_override\(\) \{([\s\S]*?)^}/m); + expect(fn).toBeTruthy(); + expect(fn[1]).toContain("openshell:resolve:env:"); + expect(fn[1]).toContain("botToken"); + expect(fn[1]).toContain("appToken"); + }); + + it("unsets SLACK_BOT_TOKEN and SLACK_APP_TOKEN before gateway starts in root path", () => { + const gatewayLaunch = src.match(/harden_openclaw_symlinks([\s\S]*?)nohup gosu gateway/); + expect(gatewayLaunch).toBeTruthy(); + expect(gatewayLaunch[1]).toContain("unset SLACK_BOT_TOKEN SLACK_APP_TOKEN"); + }); + + it("passes tokens via env prefix, not as positional args", () => { + const fn = src.match(/apply_slack_token_override\(\) \{([\s\S]*?)^}/m); + expect(fn).toBeTruthy(); + expect(fn[1]).toMatch(/SLACK_BOT_TOKEN="\$SLACK_BOT_TOKEN" \\/); + }); +}); + describe("nemoclaw-start auto-pair client whitelisting (#117)", () => { const src = fs.readFileSync(START_SCRIPT, "utf-8"); From 5659948726a35d62c7b67331603bbdfcf1452328 Mon Sep 17 00:00:00 2001 From: Dongni Yang Date: Tue, 21 Apr 2026 18:30:46 +0800 Subject: [PATCH 3/7] fix(sandbox): address CodeRabbit feedback on Slack token scrub (#2085) - Move `unset SLACK_BOT_TOKEN SLACK_APP_TOKEN` to before the first `gosu sandbox` child in the root path, so sandbox-side processes cannot inherit real token values - Add fail-fast in the non-root path: exit 1 with a clear error when SLACK_BOT_TOKEN is set but placeholder resolution needs root - Apply shfmt and Prettier formatting (case-statement indentation, line-continuation style) to pass CI pre-push hook Signed-off-by: Dongni Yang --- scripts/nemoclaw-start.sh | 114 ++++++++++++---------- src/lib/onboard.ts | 183 ++++++++++++++++++++++-------------- test/nemoclaw-start.test.ts | 26 +++-- test/onboard.test.ts | 26 +++-- 4 files changed, 218 insertions(+), 131 deletions(-) diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 8e559845594..152dcab5aa4 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -130,14 +130,14 @@ if [ "${1:-}" = "env" ]; then _self_wrapper_index="" for ((i = 1; i < ${#_raw_args[@]}; i += 1)); do case "${_raw_args[$i]}" in - *=*) ;; - nemoclaw-start | /usr/local/bin/nemoclaw-start) - _self_wrapper_index="$i" - break - ;; - *) - break - ;; + *=*) ;; + nemoclaw-start | /usr/local/bin/nemoclaw-start) + _self_wrapper_index="$i" + break + ;; + *) + break + ;; esac done if [ -n "$_self_wrapper_index" ]; then @@ -152,7 +152,7 @@ fi # receiving our own name as $1 would otherwise recurse via the NEMOCLAW_CMD # exec path. Only strip from $1 — later args with this name are legitimate. case "${1:-}" in - nemoclaw-start | /usr/local/bin/nemoclaw-start) shift ;; +nemoclaw-start | /usr/local/bin/nemoclaw-start) shift ;; esac NEMOCLAW_CMD=("$@") # Validate NEMOCLAW_DASHBOARD_PORT if set (same behavior as ports.js: fail fast). @@ -162,10 +162,10 @@ if [ -z "$_DASHBOARD_PORT_RAW" ]; then else _DASHBOARD_PORT="$(printf '%s' "$_DASHBOARD_PORT_RAW" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" case "$_DASHBOARD_PORT" in - *[!0-9]* | '') - echo "[SECURITY] Invalid NEMOCLAW_DASHBOARD_PORT='${NEMOCLAW_DASHBOARD_PORT}' — must be an integer between 1024 and 65535" >&2 - exit 1 - ;; + *[!0-9]* | '') + echo "[SECURITY] Invalid NEMOCLAW_DASHBOARD_PORT='${NEMOCLAW_DASHBOARD_PORT}' — must be an integer between 1024 and 65535" >&2 + exit 1 + ;; esac if [ "$_DASHBOARD_PORT" -lt 1024 ] || [ "$_DASHBOARD_PORT" -gt 65535 ]; then echo "[SECURITY] Invalid NEMOCLAW_DASHBOARD_PORT='${NEMOCLAW_DASHBOARD_PORT}' — must be an integer between 1024 and 65535" >&2 @@ -218,12 +218,12 @@ verify_config_integrity() { apply_model_override() { # Any of these env vars trigger a config patch - [ -n "${NEMOCLAW_MODEL_OVERRIDE:-}" ] \ - || [ -n "${NEMOCLAW_INFERENCE_API_OVERRIDE:-}" ] \ - || [ -n "${NEMOCLAW_CONTEXT_WINDOW:-}" ] \ - || [ -n "${NEMOCLAW_MAX_TOKENS:-}" ] \ - || [ -n "${NEMOCLAW_REASONING:-}" ] \ - || return 0 + [ -n "${NEMOCLAW_MODEL_OVERRIDE:-}" ] || + [ -n "${NEMOCLAW_INFERENCE_API_OVERRIDE:-}" ] || + [ -n "${NEMOCLAW_CONTEXT_WINDOW:-}" ] || + [ -n "${NEMOCLAW_MAX_TOKENS:-}" ] || + [ -n "${NEMOCLAW_REASONING:-}" ] || + return 0 # SECURITY: Only root can write to /sandbox/.openclaw (root:root 444). # In non-root mode the sandbox user cannot modify the config. @@ -258,11 +258,11 @@ apply_model_override() { # SECURITY: Allowlist inference API types to prevent unexpected routing. if [ -n "$api_override" ]; then case "$api_override" in - openai-completions | anthropic-messages) ;; - *) - printf '[SECURITY] NEMOCLAW_INFERENCE_API_OVERRIDE must be "openai-completions" or "anthropic-messages", got "%s"\n' "$api_override" >&2 - return 1 - ;; + openai-completions | anthropic-messages) ;; + *) + printf '[SECURITY] NEMOCLAW_INFERENCE_API_OVERRIDE must be "openai-completions" or "anthropic-messages", got "%s"\n' "$api_override" >&2 + return 1 + ;; esac fi @@ -282,11 +282,11 @@ apply_model_override() { # Validate reasoning is true/false if [ -n "$reasoning" ]; then case "$reasoning" in - true | false) ;; - *) - printf '[SECURITY] NEMOCLAW_REASONING must be "true" or "false", got "%s"\n' "$reasoning" >&2 - return 1 - ;; + true | false) ;; + *) + printf '[SECURITY] NEMOCLAW_REASONING must be "true" or "false", got "%s"\n' "$reasoning" >&2 + return 1 + ;; esac fi @@ -431,22 +431,28 @@ apply_slack_token_override() { # SECURITY: Validate token prefixes — reject anything that doesn't look like a real Slack token. case "${SLACK_BOT_TOKEN}" in - xoxb-*) ;; - *) printf '[channels] SLACK_BOT_TOKEN does not start with xoxb- — skipping Slack placeholder resolution\n' >&2; return 0 ;; + xoxb-*) ;; + *) + printf '[channels] SLACK_BOT_TOKEN does not start with xoxb- — skipping Slack placeholder resolution\n' >&2 + return 0 + ;; esac if [ -n "${SLACK_APP_TOKEN:-}" ]; then case "$SLACK_APP_TOKEN" in - xapp-*) ;; - *) printf '[channels] SLACK_APP_TOKEN does not start with xapp- — skipping Slack placeholder resolution\n' >&2; return 0 ;; + xapp-*) ;; + *) + printf '[channels] SLACK_APP_TOKEN does not start with xapp- — skipping Slack placeholder resolution\n' >&2 + return 0 + ;; esac fi printf '[channels] Resolving Slack token placeholders in openclaw.json\n' >&2 SLACK_BOT_TOKEN="$SLACK_BOT_TOKEN" \ - SLACK_APP_TOKEN="${SLACK_APP_TOKEN:-}" \ - python3 - "$config_file" <<'PYSLACK' + SLACK_APP_TOKEN="${SLACK_APP_TOKEN:-}" \ + python3 - "$config_file" <<'PYSLACK' import json, os, sys config_file = sys.argv[1] @@ -933,6 +939,13 @@ if [ "$(id -u)" -ne 0 ]; then apply_model_override apply_cors_override apply_slack_token_override + # SECURITY: apply_slack_token_override is a no-op when non-root. + # If SLACK_BOT_TOKEN is still set here the placeholder was never resolved — + # Bolt will crash with invalid_auth at startup. Fail fast with a clear message. + if [ -n "${SLACK_BOT_TOKEN:-}" ]; then + printf '[SECURITY] Slack Socket Mode requires a root container — SLACK_BOT_TOKEN is set but token placeholder resolution needs root. Run the container as root or remove SLACK_BOT_TOKEN.\n' >&2 + exit 1 + fi export_gateway_token install_configure_guard configure_messaging_channels @@ -957,25 +970,25 @@ if [ "$(id -u)" -ne 0 ]; then current="$(readlink -f "$link_path" 2>/dev/null || true)" expected="$(readlink -f "$target" 2>/dev/null || true)" [ "$current" != "$expected" ] || return 0 - ln -snf "$target" "$link_path" 2>/dev/null \ - && echo "[setup] repaired identity symlink" >&2 \ - || echo "[setup] could not repair identity symlink" >&2 + ln -snf "$target" "$link_path" 2>/dev/null && + echo "[setup] repaired identity symlink" >&2 || + echo "[setup] could not repair identity symlink" >&2 return 0 fi # Nothing exists yet — create the symlink. if [ ! -e "$link_path" ]; then - ln -snf "$target" "$link_path" 2>/dev/null \ - && echo "[setup] created identity symlink" >&2 \ - || echo "[setup] could not create identity symlink" >&2 + ln -snf "$target" "$link_path" 2>/dev/null && + echo "[setup] created identity symlink" >&2 || + echo "[setup] could not create identity symlink" >&2 return 0 fi # A non-symlink entry exists — back it up, then replace. local backup backup="${link_path}.bak.$(date +%s)" - if mv "$link_path" "$backup" 2>/dev/null \ - && ln -snf "$target" "$link_path" 2>/dev/null; then + if mv "$link_path" "$backup" 2>/dev/null && + ln -snf "$target" "$link_path" 2>/dev/null; then echo "[setup] replaced non-symlink identity path (backup: ${backup})" >&2 else echo "[setup] could not replace ${link_path}; writes may fail" >&2 @@ -991,9 +1004,9 @@ if [ "$(id -u)" -ne 0 ]; then mkdir -p "${data_dir}/${sub}" 2>/dev/null || true done if find "$data_dir" ! -uid "$(id -u)" -print -quit 2>/dev/null | grep -q .; then - chown -R "$(id -u):$(id -g)" "$data_dir" 2>/dev/null \ - && echo "[setup] fixed ownership on ${data_dir}" >&2 \ - || echo "[setup] could not fix ownership on ${data_dir}; writes may fail" >&2 + chown -R "$(id -u):$(id -g)" "$data_dir" 2>/dev/null && + echo "[setup] fixed ownership on ${data_dir}" >&2 || + echo "[setup] could not fix ownership on ${data_dir}; writes may fail" >&2 fi ensure_identity_symlink "$data_dir" "$openclaw_dir" } @@ -1041,6 +1054,11 @@ install_configure_guard # BEFORE chattr +i (which locks the config permanently). configure_messaging_channels +# SECURITY: Slack tokens were resolved into openclaw.json by apply_slack_token_override. +# Unset here — before any gosu sandbox child — so neither the sandbox user nor +# the gateway inherits them from the process environment. +unset SLACK_BOT_TOKEN SLACK_APP_TOKEN + # Write auth profile as sandbox user (needs writable .openclaw-data) # and recursively re-tighten any auth-profiles.json files under ~/.openclaw. gosu sandbox bash -c "$(declare -f write_auth_profile harden_auth_profiles); write_auth_profile; harden_auth_profiles" @@ -1071,10 +1089,6 @@ validate_openclaw_symlinks # Ref: https://github.com/NVIDIA/NemoClaw/issues/1019 harden_openclaw_symlinks -# SECURITY: Slack tokens were resolved into openclaw.json by apply_slack_token_override. -# Clear them from the process env so neither the gateway nor the sandbox user inherits them. -unset SLACK_BOT_TOKEN SLACK_APP_TOKEN - # Start the gateway as the 'gateway' user. # SECURITY: The sandbox user cannot kill this process because it runs # under a different UID. The fake-HOME attack no longer works because diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index dbe1a45596c..2844c352fdb 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -26,10 +26,25 @@ const LOCAL_INFERENCE_TIMEOUT_SECS = envInt("NEMOCLAW_LOCAL_INFERENCE_TIMEOUT", /** Strip ANSI escape sequences before printing process output to the terminal. * Covers CSI (color, erase, cursor), OSC, and C1 two-byte escapes per ECMA-48. */ const ANSI_RE = /\x1B(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\)|[@-_])/g; -const { ROOT, SCRIPTS, redact, run, runCapture, runFile, shellQuote, validateName } = require("./runner"); +const { + ROOT, + SCRIPTS, + redact, + run, + runCapture, + runFile, + shellQuote, + validateName, +} = require("./runner"); const { stageOptimizedSandboxBuildContext } = require("./sandbox-build-context"); const { buildSubprocessEnv } = require("./subprocess-env"); -const { DASHBOARD_PORT, GATEWAY_PORT, VLLM_PORT, OLLAMA_PORT, OLLAMA_PROXY_PORT } = require("./ports"); +const { + DASHBOARD_PORT, + GATEWAY_PORT, + VLLM_PORT, + OLLAMA_PORT, + OLLAMA_PROXY_PORT, +} = require("./ports"); const { getDefaultOllamaModel, getBootstrapOllamaModelOptions, @@ -1083,8 +1098,7 @@ async function ensureValidatedBraveSearchCredential(nonInteractive = isNonIntera if (nonInteractive) { throw new Error( - validation.message || - "Brave Search API key validation failed in non-interactive mode.", + validation.message || "Brave Search API key validation failed in non-interactive mode.", ); } @@ -1206,7 +1220,10 @@ function patchStagedDockerfile( if (baseImageRef) { dockerfile = dockerfile.replace(/^ARG BASE_IMAGE=(.*)$/m, (line, currentValue) => { const trimmed = String(currentValue).trim(); - if (trimmed.startsWith(`${SANDBOX_BASE_IMAGE}:`) || trimmed.startsWith(`${SANDBOX_BASE_IMAGE}@`)) { + if ( + trimmed.startsWith(`${SANDBOX_BASE_IMAGE}:`) || + trimmed.startsWith(`${SANDBOX_BASE_IMAGE}@`) + ) { return `ARG BASE_IMAGE=${baseImageRef}`; } return line; @@ -1375,12 +1392,12 @@ function probeResponsesToolCalling(endpointUrl, model, apiKey, options = {}) { const useQueryParam = options.authMode === "query-param"; const normalizedKey = apiKey ? normalizeCredentialValue(apiKey) : ""; const baseUrl = String(endpointUrl).replace(/\/+$/, ""); - const authHeader = !useQueryParam && normalizedKey - ? ["-H", `Authorization: Bearer ${normalizedKey}`] - : []; - const url = useQueryParam && normalizedKey - ? `${baseUrl}/responses?key=${encodeURIComponent(normalizedKey)}` - : `${baseUrl}/responses`; + const authHeader = + !useQueryParam && normalizedKey ? ["-H", `Authorization: Bearer ${normalizedKey}`] : []; + const url = + useQueryParam && normalizedKey + ? `${baseUrl}/responses?key=${encodeURIComponent(normalizedKey)}` + : `${baseUrl}/responses`; const result = runCurlProbe([ "-sS", ...getValidationProbeCurlArgs(), @@ -1431,18 +1448,20 @@ function probeOpenAiLikeEndpoint(endpointUrl, model, apiKey, options = {}) { const useQueryParam = options.authMode === "query-param"; const normalizedKey = apiKey ? normalizeCredentialValue(apiKey) : ""; const baseUrl = String(endpointUrl).replace(/\/+$/, ""); - const authHeader = !useQueryParam && normalizedKey - ? ["-H", `Authorization: Bearer ${normalizedKey}`] - : []; + const authHeader = + !useQueryParam && normalizedKey ? ["-H", `Authorization: Bearer ${normalizedKey}`] : []; const appendKey = (path) => - useQueryParam && normalizedKey ? `${baseUrl}${path}?key=${encodeURIComponent(normalizedKey)}` : `${baseUrl}${path}`; + useQueryParam && normalizedKey + ? `${baseUrl}${path}?key=${encodeURIComponent(normalizedKey)}` + : `${baseUrl}${path}`; const responsesProbe = options.requireResponsesToolCalling === true ? { name: "Responses API with tool calling", api: "openai-responses", - execute: () => probeResponsesToolCalling(endpointUrl, model, apiKey, { authMode: options.authMode }), + execute: () => + probeResponsesToolCalling(endpointUrl, model, apiKey, { authMode: options.authMode }), } : { name: "Responses API", @@ -1566,9 +1585,7 @@ function probeOpenAiLikeEndpoint(endpointUrl, model, apiKey, options = {}) { if (failures.length > 0 && isTimeoutOrConnFailure(failures[0].curlStatus)) { retriedAfterTimeout = true; const baseArgs = getValidationProbeCurlArgs(); - const doubledArgs = baseArgs.map((arg) => - /^\d+$/.test(arg) ? String(Number(arg) * 2) : arg, - ); + const doubledArgs = baseArgs.map((arg) => (/^\d+$/.test(arg) ? String(Number(arg) * 2) : arg)); const retryResult = runCurlProbe([ "-sS", ...doubledArgs, @@ -1916,7 +1933,9 @@ function startOllamaAuthProxy(): boolean { if (!isOllamaProxyProcess(pid)) { console.error(` Error: Ollama auth proxy failed to start on :${OLLAMA_PROXY_PORT}`); console.error(` Containers will not be able to reach Ollama without the proxy.`); - console.error(` Check if port ${OLLAMA_PROXY_PORT} is already in use: lsof -ti :${OLLAMA_PROXY_PORT}`); + console.error( + ` Check if port ${OLLAMA_PROXY_PORT} is already in use: lsof -ti :${OLLAMA_PROXY_PORT}`, + ); return false; } return true; @@ -2458,7 +2477,9 @@ async function preflight() { gatewayReuseState = "missing"; console.log(" ✓ Stale gateway metadata cleaned up"); } else if (containerState === "unknown") { - console.log(" Warning: could not verify gateway container state (Docker may be unavailable). Proceeding with cached health status."); + console.log( + " Warning: could not verify gateway container state (Docker may be unavailable). Proceeding with cached health status.", + ); } } @@ -2495,13 +2516,10 @@ async function preflight() { ignoreError: true, suppressOutput: true, }); - const postInspectResult = run( - ["docker", "inspect", "--type", "container", containerName], - { - ignoreError: true, - suppressOutput: true, - }, - ); + const postInspectResult = run(["docker", "inspect", "--type", "container", containerName], { + ignoreError: true, + suppressOutput: true, + }); if (postInspectResult.status !== 0) { run( `docker volume ls -q --filter "name=openshell-cluster-${GATEWAY_NAME}" | grep . && docker volume ls -q --filter "name=openshell-cluster-${GATEWAY_NAME}" | xargs docker volume rm 2>/dev/null || true`, @@ -2533,12 +2551,13 @@ async function preflight() { // tunnels the user may have set up on the same port. (#1950) if (port === DASHBOARD_PORT && portCheck.process === "ssh" && portCheck.pid) { // Use `ps` to get the command line — works on Linux, macOS, and WSL. - const cmdline = runCapture( - `ps -p ${portCheck.pid} -o args= 2>/dev/null`, - { ignoreError: true }, - ).trim(); + const cmdline = runCapture(`ps -p ${portCheck.pid} -o args= 2>/dev/null`, { + ignoreError: true, + }).trim(); if (cmdline.includes("openshell")) { - console.log(` Cleaning up orphaned SSH port-forward on port ${port} (PID ${portCheck.pid})...`); + console.log( + ` Cleaning up orphaned SSH port-forward on port ${port} (PID ${portCheck.pid})...`, + ); run(`kill ${portCheck.pid} 2>/dev/null || true`, { ignoreError: true }); sleep(1); portCheck = await checkPortAvailable(port); @@ -2991,10 +3010,7 @@ async function createSandbox( // (Socket Mode) enforce one consumer per bot token. Two sandboxes sharing // a token silently break both bridges (see #1953). Warn before we commit. if (conflictCheckChannels.length > 0) { - const { - backfillMessagingChannels, - findChannelConflicts, - } = require("./messaging-conflict"); + const { backfillMessagingChannels, findChannelConflicts } = require("./messaging-conflict"); backfillMessagingChannels(registry, makeConflictProbe()); const conflicts = findChannelConflicts(sandboxName, conflictCheckChannels, registry); if (conflicts.length > 0) { @@ -3376,7 +3392,9 @@ async function createSandbox( if (localCheck) { console.warn(" Warning: could not pull base image from registry; using cached :latest."); } else { - console.warn(` Warning: base image ${SANDBOX_BASE_IMAGE}:${SANDBOX_BASE_TAG} is not available locally.`); + console.warn( + ` Warning: base image ${SANDBOX_BASE_IMAGE}:${SANDBOX_BASE_TAG} is not available locally.`, + ); console.warn(" The build will fail unless Docker can pull the image during build."); console.warn(" If offline, pull the image manually first:"); console.warn(` docker pull ${SANDBOX_BASE_IMAGE}:${SANDBOX_BASE_TAG}`); @@ -3607,7 +3625,9 @@ async function createSandbox( try { if (process.platform === "darwin") { - const vmKernel = runCapture(["docker", "info", "--format", "{{.KernelVersion}}"], { ignoreError: true }).trim(); + const vmKernel = runCapture(["docker", "info", "--format", "{{.KernelVersion}}"], { + ignoreError: true, + }).trim(); if (vmKernel) { const parts = vmKernel.split("."); const major = parseInt(parts[0], 10); @@ -3916,7 +3936,11 @@ async function setupNim(gpu) { // is universally supported. // See: https://github.com/NVIDIA/NemoClaw/issues/1932 const explicitApi = (process.env.NEMOCLAW_PREFERRED_API || "").trim().toLowerCase(); - if (explicitApi && explicitApi !== "openai-completions" && explicitApi !== "chat-completions") { + if ( + explicitApi && + explicitApi !== "openai-completions" && + explicitApi !== "chat-completions" + ) { preferredInferenceApi = validation.api; } else { if (validation.api !== "openai-completions") { @@ -4085,7 +4109,9 @@ async function setupNim(gpu) { console.log(" NGC API Key required to pull NIM images."); console.log(" Get one from: https://org.ngc.nvidia.com/setup/api-key"); console.log(""); - let ngcKey = normalizeCredentialValue(await prompt(" NGC API Key: ", { secret: true })); + let ngcKey = normalizeCredentialValue( + await prompt(" NGC API Key: ", { secret: true }), + ); if (!ngcKey) { console.error(" NGC API Key is required for Local NIM."); process.exit(1); @@ -4163,7 +4189,9 @@ async function setupNim(gpu) { if (!startOllamaAuthProxy()) { process.exit(1); } - console.log(` ✓ Using Ollama on localhost:${OLLAMA_PORT} (proxy on :${OLLAMA_PROXY_PORT})`); + console.log( + ` ✓ Using Ollama on localhost:${OLLAMA_PORT} (proxy on :${OLLAMA_PROXY_PORT})`, + ); } provider = "ollama-local"; credentialEnv = "OPENAI_API_KEY"; @@ -4220,12 +4248,16 @@ async function setupNim(gpu) { run(["brew", "install", "ollama"], { ignoreError: true }); console.log(" Starting Ollama..."); // Shell required: backgrounding (&), env var prefix, output redirection. - run(`OLLAMA_HOST=0.0.0.0:${OLLAMA_PORT} ollama serve > /dev/null 2>&1 &`, { ignoreError: true }); + run(`OLLAMA_HOST=0.0.0.0:${OLLAMA_PORT} ollama serve > /dev/null 2>&1 &`, { + ignoreError: true, + }); sleep(2); if (!startOllamaAuthProxy()) { process.exit(1); } - console.log(` ✓ Using Ollama on localhost:${OLLAMA_PORT} (proxy on :${OLLAMA_PROXY_PORT})`); + console.log( + ` ✓ Using Ollama on localhost:${OLLAMA_PORT} (proxy on :${OLLAMA_PROXY_PORT})`, + ); provider = "ollama-local"; credentialEnv = "OPENAI_API_KEY"; endpointUrl = getLocalProviderBaseUrl(provider); @@ -4281,9 +4313,12 @@ async function setupNim(gpu) { credentialEnv = "OPENAI_API_KEY"; endpointUrl = getLocalProviderBaseUrl(provider); // Query vLLM for the actual model ID - const vllmModelsRaw = runCapture(["curl", "-sf", `http://127.0.0.1:${VLLM_PORT}/v1/models`], { - ignoreError: true, - }); + const vllmModelsRaw = runCapture( + ["curl", "-sf", `http://127.0.0.1:${VLLM_PORT}/v1/models`], + { + ignoreError: true, + }, + ); try { const vllmModels = JSON.parse(vllmModelsRaw); if (vllmModels.data && vllmModels.data.length > 0) { @@ -4457,7 +4492,9 @@ async function setupInference( if (!validation.ok) { console.error(` ${validation.message}`); if (process.platform === "darwin") { - console.error(" On macOS, local inference also depends on OpenShell host routing support."); + console.error( + " On macOS, local inference also depends on OpenShell host routing support.", + ); } process.exit(1); } @@ -4467,7 +4504,9 @@ async function setupInference( ensureOllamaAuthProxy(); const proxyToken = getOllamaProxyToken(); if (!proxyToken) { - console.error(" Ollama auth proxy token is not set. Re-run onboard to initialize the proxy."); + console.error( + " Ollama auth proxy token is not set. Re-run onboard to initialize the proxy.", + ); process.exit(1); } ollamaCredential = proxyToken; @@ -4519,8 +4558,10 @@ const TELEGRAM_NETWORK_CURL_CODES = new Set([6, 7, 28, 35, 52, 56]); async function checkTelegramReachability(token: string) { const result = runCurlProbe([ "-sS", - "--connect-timeout", "5", - "--max-time", "10", + "--connect-timeout", + "5", + "--max-time", + "10", `https://api.telegram.org/bot${token}/getMe`, ]); @@ -4529,9 +4570,7 @@ async function checkTelegramReachability(token: string) { // HTTP 401 or 404 — token was rejected by Telegram (not a network issue). if (result.httpStatus === 401 || result.httpStatus === 404) { - console.log( - " ⚠ Bot token was rejected by Telegram — verify the token is correct.", - ); + console.log(" ⚠ Bot token was rejected by Telegram — verify the token is correct."); return; } @@ -4543,7 +4582,9 @@ async function checkTelegramReachability(token: string) { console.log(" This is commonly blocked by corporate network proxies."); if (isNonInteractive()) { - console.error(" Aborting onboarding in non-interactive mode due to Telegram network reachability failure."); + console.error( + " Aborting onboarding in non-interactive mode due to Telegram network reachability failure.", + ); process.exit(1); } else { const answer = (await promptOrDefault(" Continue anyway? [y/N]: ", null, "n")) @@ -4763,18 +4804,18 @@ async function setupMessagingChannels() { // The non-interactive branch above already ran this probe and returned early, // so this second call only fires on the interactive path — guard explicitly // to make the no-double-probe invariant visible at the call site. - if ( - !isNonInteractive() && - enabled.has("telegram") && - getMessagingToken("TELEGRAM_BOT_TOKEN") - ) { + if (!isNonInteractive() && enabled.has("telegram") && getMessagingToken("TELEGRAM_BOT_TOKEN")) { await checkTelegramReachability(getMessagingToken("TELEGRAM_BOT_TOKEN")); } return Array.from(enabled); } -function getSuggestedPolicyPresets({ enabledChannels = null, webSearchConfig = null, provider = null } = {}) { +function getSuggestedPolicyPresets({ + enabledChannels = null, + webSearchConfig = null, + provider = null, +} = {}) { const suggestions = ["pypi", "npm"]; // Auto-suggest local-inference preset when a local provider is selected @@ -4820,10 +4861,10 @@ async function setupOpenclaw(sandboxName, model, provider) { const scriptFile = writeSandboxConfigSyncFile(script); try { const scriptContent = fs.readFileSync(scriptFile, "utf-8"); - run( - openshellArgv(["sandbox", "connect", sandboxName]), - { stdio: ["pipe", "ignore", "inherit"], input: scriptContent }, - ); + run(openshellArgv(["sandbox", "connect", sandboxName]), { + stdio: ["pipe", "ignore", "inherit"], + input: scriptContent, + }); } finally { cleanupTempDir(scriptFile, "nemoclaw-sync"); } @@ -5605,8 +5646,12 @@ function ensureDashboardForward(sandboxName, chatUiUrl = `http://127.0.0.1:${CON // Docker test container with -p PORT:PORT). The error is otherwise swallowed by // ignoreError + stdio:ignore, leaving the dashboard URL silently unreachable (#1925). if (fwdResult && fwdResult.status !== 0) { - console.warn(`! Port ${portToStop} forward did not start — port may be in use by another process.`); - console.warn(` Check: docker ps --format 'table {{.Names}}\\t{{.Ports}}' | grep ${portToStop}`); + console.warn( + `! Port ${portToStop} forward did not start — port may be in use by another process.`, + ); + console.warn( + ` Check: docker ps --format 'table {{.Names}}\\t{{.Ports}}' | grep ${portToStop}`, + ); console.warn(` Free the port, then reconnect: nemoclaw ${sandboxName} connect`); } } @@ -6049,7 +6094,9 @@ async function onboard(opts = {}) { gatewayReuseState = "missing"; console.log(" ✓ Stale gateway metadata cleaned up"); } else if (containerState === "unknown") { - console.log(" Warning: could not verify gateway container state (Docker may be unavailable). Proceeding with cached health status."); + console.log( + " Warning: could not verify gateway container state (Docker may be unavailable). Proceeding with cached health status.", + ); } } diff --git a/test/nemoclaw-start.test.ts b/test/nemoclaw-start.test.ts index f60fbfb913a..f21ae6098a0 100644 --- a/test/nemoclaw-start.test.ts +++ b/test/nemoclaw-start.test.ts @@ -14,7 +14,9 @@ describe("nemoclaw-start non-root fallback", () => { expect(src).toMatch(/if \[ "\$\(id -u\)" -ne 0 \]; then/); expect(src).toMatch(/touch \/tmp\/gateway\.log/); - expect(src).toMatch(/nohup "\$OPENCLAW" gateway run --port "\$\{_DASHBOARD_PORT\}" >\/tmp\/gateway\.log 2>&1 &/); + expect(src).toMatch( + /nohup "\$OPENCLAW" gateway run --port "\$\{_DASHBOARD_PORT\}" >\/tmp\/gateway\.log 2>&1 &/, + ); }); it("exits on config integrity failure in non-root mode", () => { @@ -320,7 +322,8 @@ describe("runtime model override (#759)", () => { expect(fn).toBeTruthy(); // Guard checks all override env vars before returning early expect(fn[1]).toContain("NEMOCLAW_MODEL_OVERRIDE"); - expect(fn[1]).toContain("|| return 0"); + expect(fn[1]).toContain("NEMOCLAW_REASONING"); + expect(fn[1]).toContain("return 0"); }); it("supports optional NEMOCLAW_INFERENCE_API_OVERRIDE for cross-provider switches", () => { @@ -539,10 +542,21 @@ describe("Slack token placeholder resolution (#2085)", () => { expect(fn[1]).toContain("appToken"); }); - it("unsets SLACK_BOT_TOKEN and SLACK_APP_TOKEN before gateway starts in root path", () => { - const gatewayLaunch = src.match(/harden_openclaw_symlinks([\s\S]*?)nohup gosu gateway/); - expect(gatewayLaunch).toBeTruthy(); - expect(gatewayLaunch[1]).toContain("unset SLACK_BOT_TOKEN SLACK_APP_TOKEN"); + it("unsets SLACK_BOT_TOKEN and SLACK_APP_TOKEN before first gosu sandbox call in root path", () => { + // unset must appear after configure_messaging_channels and before the first gosu sandbox child + const block = src.match(/configure_messaging_channels\n([\s\S]*?)gosu sandbox bash/); + expect(block).toBeTruthy(); + expect(block[1]).toContain("unset SLACK_BOT_TOKEN SLACK_APP_TOKEN"); + }); + + it("fails fast when SLACK_BOT_TOKEN is set in non-root mode", () => { + const nonRootBlock = src.match(/if \[ "\$\(id -u\)" -ne 0 \]; then([\s\S]*?)# ── Root path/); + expect(nonRootBlock).toBeTruthy(); + // After apply_slack_token_override (no-op without root) the non-root path must exit 1 + expect(nonRootBlock[1]).toMatch( + /apply_slack_token_override[\s\S]*?SLACK_BOT_TOKEN[\s\S]*?exit 1/, + ); + expect(nonRootBlock[1]).toContain("requires a root container"); }); it("passes tokens via env prefix, not as positional args", () => { diff --git a/test/onboard.test.ts b/test/onboard.test.ts index 2a7ee8f1185..e0e27b3725b 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -161,8 +161,16 @@ describe("onboard helpers", () => { describe("computeSetupPresetSuggestions", () => { const known = [ - "npm", "pypi", "huggingface", "brew", "brave", - "slack", "discord", "telegram", "jira", "outlook", + "npm", + "pypi", + "huggingface", + "brew", + "brave", + "slack", + "discord", + "telegram", + "jira", + "outlook", "local-inference", ]; @@ -3206,9 +3214,7 @@ const { createSandbox } = require(${onboardPath}); { timeout: 60_000 }, async () => { const repoRoot = path.join(import.meta.dirname, ".."); - const tmpDir = fs.mkdtempSync( - path.join(os.tmpdir(), "nemoclaw-onboard-recreate-preserves-"), - ); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-recreate-preserves-")); const fakeBin = path.join(tmpDir, "bin"); const scriptPath = path.join(tmpDir, "recreate-preserves.js"); const onboardPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "onboard.js")); @@ -4992,7 +4998,10 @@ const { createSandbox } = require(${onboardPath}); fakeRef, ); const patched = fs.readFileSync(dockerfilePath, "utf8"); - assert.match(patched, /^ARG BASE_IMAGE=ghcr\.io\/nvidia\/nemoclaw\/sandbox-base@sha256:a{64}$/m); + assert.match( + patched, + /^ARG BASE_IMAGE=ghcr\.io\/nvidia\/nemoclaw\/sandbox-base@sha256:a{64}$/m, + ); // Model patching still works alongside base image pinning assert.match(patched, /^ARG NEMOCLAW_MODEL=gpt-5\.4$/m); } finally { @@ -5076,7 +5085,10 @@ const { createSandbox } = require(${onboardPath}); ); const patched = fs.readFileSync(dockerfilePath, "utf8"); // No ARG BASE_IMAGE in original, so the ref should not appear - assert.ok(!patched.includes("ARG BASE_IMAGE="), "Should not inject BASE_IMAGE when line is absent"); + assert.ok( + !patched.includes("ARG BASE_IMAGE="), + "Should not inject BASE_IMAGE when line is absent", + ); // Other patching should still work assert.match(patched, /^ARG NEMOCLAW_MODEL=gpt-5\.4$/m); } finally { From 0a178d96574405caedee347304ff2559a884299e Mon Sep 17 00:00:00 2001 From: Dongni Yang Date: Tue, 21 Apr 2026 18:56:39 +0800 Subject: [PATCH 4/7] fix(sandbox): guard NEMOCLAW_MODEL_OVERRIDE against unbound variable under set -u MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NEMOCLAW_CONTEXT_WINDOW, NEMOCLAW_MAX_TOKENS, and NEMOCLAW_REASONING are baked into the image ENV and are always non-empty, so apply_model_override's guard always fires. The previous `local model_override="$NEMOCLAW_MODEL_OVERRIDE"` would abort the entrypoint under set -euo pipefail whenever an operator did not pass NEMOCLAW_MODEL_OVERRIDE — causing apply_slack_token_override to never run. Signed-off-by: Dongni Yang --- scripts/nemoclaw-start.sh | 2 +- test/nemoclaw-start.test.ts | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 152dcab5aa4..a8f88e531d3 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -242,7 +242,7 @@ apply_model_override() { return 1 fi - local model_override="$NEMOCLAW_MODEL_OVERRIDE" + local model_override="${NEMOCLAW_MODEL_OVERRIDE:-}" local api_override="${NEMOCLAW_INFERENCE_API_OVERRIDE:-}" # SECURITY: Validate inputs — reject control characters and enforce length limit. diff --git a/test/nemoclaw-start.test.ts b/test/nemoclaw-start.test.ts index f21ae6098a0..6540216dd01 100644 --- a/test/nemoclaw-start.test.ts +++ b/test/nemoclaw-start.test.ts @@ -408,6 +408,16 @@ describe("runtime model override (#759)", () => { expect(guard).toContain("NEMOCLAW_MAX_TOKENS"); expect(guard).toContain("NEMOCLAW_REASONING"); }); + + it("accesses NEMOCLAW_MODEL_OVERRIDE with :- fallback to avoid unbound variable under set -u", () => { + // NEMOCLAW_CONTEXT_WINDOW/MAX_TOKENS/REASONING are baked into the image ENV and are always + // non-empty, so the guard fires even when the operator never passes NEMOCLAW_MODEL_OVERRIDE. + // Without the :- fallback, set -euo pipefail would abort the entrypoint on every container + // start where only a context-window or reasoning override was intended. + const fn = src.match(/apply_model_override\(\) \{([\s\S]*?)^}/m); + expect(fn).toBeTruthy(); + expect(fn[1]).toContain('${NEMOCLAW_MODEL_OVERRIDE:-}'); + }); }); describe("runtime CORS origin override (#719)", () => { From d4111d006b6267037989b16c3d839d43f6bb0fa9 Mon Sep 17 00:00:00 2001 From: Dongni Yang Date: Tue, 21 Apr 2026 19:09:41 +0800 Subject: [PATCH 5/7] fix(onboard): prompt for SLACK_APP_TOKEN during interactive channel setup (#2085) setupMessagingChannels() only prompted for ch.envKey (SLACK_BOT_TOKEN) but never for ch.appTokenEnvKey (SLACK_APP_TOKEN). A fresh interactive Slack onboard left the app token unconfigured, so the --env injection added in the previous commit had nothing to forward. Follows the same pattern as serverIdEnvKey handling (Discord guild ID) and uses the appTokenHelp / appTokenLabel already defined in KNOWN_CHANNELS.slack. Skips the channel if the app token is omitted since Socket Mode requires both tokens. Signed-off-by: Dongni Yang --- src/lib/onboard.ts | 40 +++++++++++++++++++++++++++++----------- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 3df7d49a7f6..611c05b5ce8 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -2293,8 +2293,9 @@ function getGatewayLocalEndpoint() { function getGatewayBootstrapRepairPlan(missingSecrets = []) { const allowed = new Set(GATEWAY_BOOTSTRAP_SECRET_NAMES); - const normalized = [...new Set((missingSecrets || []).map((name) => String(name).trim()).filter(Boolean))] - .filter((name) => allowed.has(name)); + const normalized = [ + ...new Set((missingSecrets || []).map((name) => String(name).trim()).filter(Boolean)), + ].filter((name) => allowed.has(name)); const missing = new Set(normalized); const needsClientBundle = missing.has("openshell-server-client-ca") || missing.has("openshell-client-tls"); @@ -2349,18 +2350,12 @@ fi function runGatewayClusterCapture(script, opts = {}) { const containerName = getGatewayClusterContainerName(); - return runCapture( - `docker exec ${shellQuote(containerName)} sh -lc ${shellQuote(script)}`, - opts, - ); + return runCapture(`docker exec ${shellQuote(containerName)} sh -lc ${shellQuote(script)}`, opts); } function runGatewayCluster(script, opts = {}) { const containerName = getGatewayClusterContainerName(); - return run( - `docker exec ${shellQuote(containerName)} sh -lc ${shellQuote(script)}`, - opts, - ); + return run(`docker exec ${shellQuote(containerName)} sh -lc ${shellQuote(script)}`, opts); } function listMissingGatewayBootstrapSecrets() { @@ -2411,7 +2406,9 @@ function repairGatewayBootstrapSecrets() { } function attachGatewayMetadataIfNeeded({ forceRefresh = false } = {}) { - const gwInfo = runCaptureOpenshell(["gateway", "info", "-g", GATEWAY_NAME], { ignoreError: true }); + const gwInfo = runCaptureOpenshell(["gateway", "info", "-g", GATEWAY_NAME], { + ignoreError: true, + }); // runCaptureOpenshell may return stale-but-present gateway metadata. When // hasStaleGateway(gwInfo) is truthy we skip runOpenshell unless a repair // flow explicitly forces a refresh after recreating bootstrap secrets. @@ -4951,6 +4948,27 @@ async function setupMessagingChannels() { continue; } } + if (ch.appTokenEnvKey) { + const existingAppToken = getMessagingToken(ch.appTokenEnvKey); + if (existingAppToken) { + console.log(` ✓ ${ch.name} app token — already configured`); + } else { + console.log(""); + console.log(` ${ch.appTokenHelp}`); + const appToken = normalizeCredentialValue( + await prompt(` ${ch.appTokenLabel}: `, { secret: true }), + ); + if (appToken) { + saveCredential(ch.appTokenEnvKey, appToken); + process.env[ch.appTokenEnvKey] = appToken; + console.log(` ✓ ${ch.name} app token saved`); + } else { + console.log(` Skipped ${ch.name} app token (Socket Mode requires both tokens)`); + enabled.delete(ch.name); + continue; + } + } + } if (ch.serverIdEnvKey) { const existingServerIds = process.env[ch.serverIdEnvKey] || ""; if (existingServerIds) { From 1c1357c8a74eedf11b8341653155af5977840fd7 Mon Sep 17 00:00:00 2001 From: Dongni Yang Date: Tue, 21 Apr 2026 19:46:11 +0800 Subject: [PATCH 6/7] style(nemoclaw-start): apply Prettier double-quote formatting to test Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Dongni Yang --- test/nemoclaw-start.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/nemoclaw-start.test.ts b/test/nemoclaw-start.test.ts index 6540216dd01..52a60ad4883 100644 --- a/test/nemoclaw-start.test.ts +++ b/test/nemoclaw-start.test.ts @@ -416,7 +416,7 @@ describe("runtime model override (#759)", () => { // start where only a context-window or reasoning override was intended. const fn = src.match(/apply_model_override\(\) \{([\s\S]*?)^}/m); expect(fn).toBeTruthy(); - expect(fn[1]).toContain('${NEMOCLAW_MODEL_OVERRIDE:-}'); + expect(fn[1]).toContain("${NEMOCLAW_MODEL_OVERRIDE:-}"); }); }); From 74fbd7e9d1f70be5e2ec2a23496cdf4f22164a27 Mon Sep 17 00:00:00 2001 From: Dongni Yang Date: Tue, 21 Apr 2026 20:09:11 +0800 Subject: [PATCH 7/7] style(nemoclaw-start): apply shfmt -bn formatting Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Dongni Yang --- scripts/nemoclaw-start.sh | 100 +++++++++++++++++++------------------- 1 file changed, 50 insertions(+), 50 deletions(-) diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index a8f88e531d3..b0088897db0 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -130,14 +130,14 @@ if [ "${1:-}" = "env" ]; then _self_wrapper_index="" for ((i = 1; i < ${#_raw_args[@]}; i += 1)); do case "${_raw_args[$i]}" in - *=*) ;; - nemoclaw-start | /usr/local/bin/nemoclaw-start) - _self_wrapper_index="$i" - break - ;; - *) - break - ;; + *=*) ;; + nemoclaw-start | /usr/local/bin/nemoclaw-start) + _self_wrapper_index="$i" + break + ;; + *) + break + ;; esac done if [ -n "$_self_wrapper_index" ]; then @@ -152,7 +152,7 @@ fi # receiving our own name as $1 would otherwise recurse via the NEMOCLAW_CMD # exec path. Only strip from $1 — later args with this name are legitimate. case "${1:-}" in -nemoclaw-start | /usr/local/bin/nemoclaw-start) shift ;; + nemoclaw-start | /usr/local/bin/nemoclaw-start) shift ;; esac NEMOCLAW_CMD=("$@") # Validate NEMOCLAW_DASHBOARD_PORT if set (same behavior as ports.js: fail fast). @@ -162,10 +162,10 @@ if [ -z "$_DASHBOARD_PORT_RAW" ]; then else _DASHBOARD_PORT="$(printf '%s' "$_DASHBOARD_PORT_RAW" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" case "$_DASHBOARD_PORT" in - *[!0-9]* | '') - echo "[SECURITY] Invalid NEMOCLAW_DASHBOARD_PORT='${NEMOCLAW_DASHBOARD_PORT}' — must be an integer between 1024 and 65535" >&2 - exit 1 - ;; + *[!0-9]* | '') + echo "[SECURITY] Invalid NEMOCLAW_DASHBOARD_PORT='${NEMOCLAW_DASHBOARD_PORT}' — must be an integer between 1024 and 65535" >&2 + exit 1 + ;; esac if [ "$_DASHBOARD_PORT" -lt 1024 ] || [ "$_DASHBOARD_PORT" -gt 65535 ]; then echo "[SECURITY] Invalid NEMOCLAW_DASHBOARD_PORT='${NEMOCLAW_DASHBOARD_PORT}' — must be an integer between 1024 and 65535" >&2 @@ -218,12 +218,12 @@ verify_config_integrity() { apply_model_override() { # Any of these env vars trigger a config patch - [ -n "${NEMOCLAW_MODEL_OVERRIDE:-}" ] || - [ -n "${NEMOCLAW_INFERENCE_API_OVERRIDE:-}" ] || - [ -n "${NEMOCLAW_CONTEXT_WINDOW:-}" ] || - [ -n "${NEMOCLAW_MAX_TOKENS:-}" ] || - [ -n "${NEMOCLAW_REASONING:-}" ] || - return 0 + [ -n "${NEMOCLAW_MODEL_OVERRIDE:-}" ] \ + || [ -n "${NEMOCLAW_INFERENCE_API_OVERRIDE:-}" ] \ + || [ -n "${NEMOCLAW_CONTEXT_WINDOW:-}" ] \ + || [ -n "${NEMOCLAW_MAX_TOKENS:-}" ] \ + || [ -n "${NEMOCLAW_REASONING:-}" ] \ + || return 0 # SECURITY: Only root can write to /sandbox/.openclaw (root:root 444). # In non-root mode the sandbox user cannot modify the config. @@ -258,11 +258,11 @@ apply_model_override() { # SECURITY: Allowlist inference API types to prevent unexpected routing. if [ -n "$api_override" ]; then case "$api_override" in - openai-completions | anthropic-messages) ;; - *) - printf '[SECURITY] NEMOCLAW_INFERENCE_API_OVERRIDE must be "openai-completions" or "anthropic-messages", got "%s"\n' "$api_override" >&2 - return 1 - ;; + openai-completions | anthropic-messages) ;; + *) + printf '[SECURITY] NEMOCLAW_INFERENCE_API_OVERRIDE must be "openai-completions" or "anthropic-messages", got "%s"\n' "$api_override" >&2 + return 1 + ;; esac fi @@ -282,11 +282,11 @@ apply_model_override() { # Validate reasoning is true/false if [ -n "$reasoning" ]; then case "$reasoning" in - true | false) ;; - *) - printf '[SECURITY] NEMOCLAW_REASONING must be "true" or "false", got "%s"\n' "$reasoning" >&2 - return 1 - ;; + true | false) ;; + *) + printf '[SECURITY] NEMOCLAW_REASONING must be "true" or "false", got "%s"\n' "$reasoning" >&2 + return 1 + ;; esac fi @@ -431,20 +431,20 @@ apply_slack_token_override() { # SECURITY: Validate token prefixes — reject anything that doesn't look like a real Slack token. case "${SLACK_BOT_TOKEN}" in - xoxb-*) ;; - *) - printf '[channels] SLACK_BOT_TOKEN does not start with xoxb- — skipping Slack placeholder resolution\n' >&2 - return 0 - ;; + xoxb-*) ;; + *) + printf '[channels] SLACK_BOT_TOKEN does not start with xoxb- — skipping Slack placeholder resolution\n' >&2 + return 0 + ;; esac if [ -n "${SLACK_APP_TOKEN:-}" ]; then case "$SLACK_APP_TOKEN" in - xapp-*) ;; - *) - printf '[channels] SLACK_APP_TOKEN does not start with xapp- — skipping Slack placeholder resolution\n' >&2 - return 0 - ;; + xapp-*) ;; + *) + printf '[channels] SLACK_APP_TOKEN does not start with xapp- — skipping Slack placeholder resolution\n' >&2 + return 0 + ;; esac fi @@ -970,25 +970,25 @@ if [ "$(id -u)" -ne 0 ]; then current="$(readlink -f "$link_path" 2>/dev/null || true)" expected="$(readlink -f "$target" 2>/dev/null || true)" [ "$current" != "$expected" ] || return 0 - ln -snf "$target" "$link_path" 2>/dev/null && - echo "[setup] repaired identity symlink" >&2 || - echo "[setup] could not repair identity symlink" >&2 + ln -snf "$target" "$link_path" 2>/dev/null \ + && echo "[setup] repaired identity symlink" >&2 \ + || echo "[setup] could not repair identity symlink" >&2 return 0 fi # Nothing exists yet — create the symlink. if [ ! -e "$link_path" ]; then - ln -snf "$target" "$link_path" 2>/dev/null && - echo "[setup] created identity symlink" >&2 || - echo "[setup] could not create identity symlink" >&2 + ln -snf "$target" "$link_path" 2>/dev/null \ + && echo "[setup] created identity symlink" >&2 \ + || echo "[setup] could not create identity symlink" >&2 return 0 fi # A non-symlink entry exists — back it up, then replace. local backup backup="${link_path}.bak.$(date +%s)" - if mv "$link_path" "$backup" 2>/dev/null && - ln -snf "$target" "$link_path" 2>/dev/null; then + if mv "$link_path" "$backup" 2>/dev/null \ + && ln -snf "$target" "$link_path" 2>/dev/null; then echo "[setup] replaced non-symlink identity path (backup: ${backup})" >&2 else echo "[setup] could not replace ${link_path}; writes may fail" >&2 @@ -1004,9 +1004,9 @@ if [ "$(id -u)" -ne 0 ]; then mkdir -p "${data_dir}/${sub}" 2>/dev/null || true done if find "$data_dir" ! -uid "$(id -u)" -print -quit 2>/dev/null | grep -q .; then - chown -R "$(id -u):$(id -g)" "$data_dir" 2>/dev/null && - echo "[setup] fixed ownership on ${data_dir}" >&2 || - echo "[setup] could not fix ownership on ${data_dir}; writes may fail" >&2 + chown -R "$(id -u):$(id -g)" "$data_dir" 2>/dev/null \ + && echo "[setup] fixed ownership on ${data_dir}" >&2 \ + || echo "[setup] could not fix ownership on ${data_dir}; writes may fail" >&2 fi ensure_identity_symlink "$data_dir" "$openclaw_dir" }