diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 6ea2f892bf..f671032508 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -1013,6 +1013,7 @@ refresh_openclaw_provider_placeholders() { python3 - "$config_file" <<'PYPLACEHOLDERS' import json import os +import re import sys config_file = sys.argv[1] @@ -1081,6 +1082,45 @@ if isinstance(channels, dict): f"[channels] {label} placeholder does not match the OpenShell runtime placeholder for {env_key}" ) +# Slack stores Bolt-compatible aliases (xoxb-/xapp-OPENSHELL-RESOLVE-ENV-*) on +# disk rather than the canonical "openshell:resolve:env:*" placeholder, so the +# loop above (which keys on the canonical prefix) never inspects it. Diagnose +# the alias-vs-runtime-env consistency separately. The aliases themselves are +# never rewritten on disk — the L7 egress proxy resolves them at request time — +# so we only warn, never mutate. Ref: NVIDIA/NemoClaw#4274. +slack_aliases = { + "botToken": ("SLACK_BOT_TOKEN", "xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN", "xoxb-"), + "appToken": ("SLACK_APP_TOKEN", "xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN", "xapp-"), + } +if isinstance(channels, dict): + slack_cfg = channels.get("slack", {}) + slack_accounts = slack_cfg.get("accounts", {}) if isinstance(slack_cfg, dict) else {} + if isinstance(slack_accounts, dict): + for account_id, account in slack_accounts.items(): + if not isinstance(account, dict): + continue + for field, (env_key, alias, token_scheme) in slack_aliases.items(): + if account.get(field) != alias: + continue + label = f"slack.{account_id}.{field}" + env_value = os.environ.get(env_key, "") + # A valid runtime placeholder is the canonical self-referential + # form or its revision-scoped variant for *this* key; a + # placeholder for a different key (or a suffix collision) is not + # accepted and must be surfaced. A genuine xoxb-/xapp- token is + # accepted by Bolt as-is. + placeholder_re = re.compile( + rf"^{re.escape(prefix)}(v[0-9]+_)?{re.escape(env_key)}$" + ) + if not env_value: + warnings.append( + f"[channels] {label} expects the {env_key} provider placeholder but it is missing from the runtime environment" + ) + elif not placeholder_re.match(env_value) and not env_value.startswith(token_scheme): + warnings.append( + f"[channels] {label} runtime {env_key} is neither the {env_key} OpenShell placeholder nor a {token_scheme} Slack token; Slack Bolt may reject it" + ) + if updated != config: with open(config_file, "w", encoding="utf-8") as f: json.dump(updated, f, indent=2) @@ -1112,6 +1152,46 @@ PYPLACEHOLDERS [ "$_write_rc" -eq 0 ] || return "$_write_rc" } +# ── Slack runtime env normalization (Bolt-compatible placeholder) ── +# OpenShell injects messaging-provider credentials into the sandbox process +# environment as canonical resolve placeholders, e.g. +# SLACK_BOT_TOKEN=openshell:resolve:env:v51_SLACK_BOT_TOKEN +# Unlike the canonical OpenClaw config values (handled by +# refresh_openclaw_provider_placeholders), Slack Bolt validates token *shape* +# at startup and rejects anything that does not begin with xoxb-/xapp-. After a +# messaging-provider rebuild the gateway therefore inherits a placeholder it +# cannot parse and Slack auth fails even though the provider attached +# successfully (NVIDIA/NemoClaw#4274). The L7 egress proxy rewrites the +# Bolt-aliased form (xoxb-/xapp-OPENSHELL-RESOLVE-ENV-*) at request time — the +# same alias the config generator bakes into openclaw.json — so normalize the +# runtime env to that alias before launching OpenClaw. +# +# This runs in the *main* shell (never a subshell / command substitution) so +# the exported values are inherited by the gateway and any one-shot +# "${NEMOCLAW_CMD[@]}" child. Real xoxb-/xapp- tokens and already-aliased values +# are left untouched, so it is safe to call unconditionally and is idempotent. +# +# OpenShell injects self-referential placeholders (the SLACK_BOT_TOKEN env var +# resolves to "openshell:resolve:env:SLACK_BOT_TOKEN" or its revision-scoped +# form "openshell:resolve:env:v_SLACK_BOT_TOKEN"). The match is anchored to +# exactly those two shapes so a placeholder that resolves some *other* key +# (including a suffix collision like ...v1_NOT_SLACK_BOT_TOKEN) is left alone +# rather than silently rebound to the Slack secret. +normalize_slack_runtime_env() { + local bot_re='^openshell:resolve:env:(v[0-9]+_)?SLACK_BOT_TOKEN$' + local app_re='^openshell:resolve:env:(v[0-9]+_)?SLACK_APP_TOKEN$' + + if [[ "${SLACK_BOT_TOKEN-}" =~ $bot_re ]]; then + export SLACK_BOT_TOKEN="xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN" + printf '[channels] Normalized SLACK_BOT_TOKEN runtime placeholder to the Bolt-compatible alias\n' >&2 + fi + + if [[ "${SLACK_APP_TOKEN-}" =~ $app_re ]]; then + export SLACK_APP_TOKEN="xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN" + printf '[channels] Normalized SLACK_APP_TOKEN runtime placeholder to the Bolt-compatible alias\n' >&2 + fi +} + # ── Slack secrets-on-disk tripwire ──────────────────────────────── # Defense-in-depth: refuse to serve if a real Slack token (anything # starting with xoxb- or xapp- that is NOT the OPENSHELL-RESOLVE-ENV- @@ -2664,6 +2744,9 @@ if [ "$(id -u)" -ne 0 ]; then write_runtime_shell_env ensure_runtime_shell_env_shim lock_rc_files "$_SANDBOX_HOME" || true + # Normalize Slack provider placeholders before any child inherits the env — + # covers both the one-shot "${NEMOCLAW_CMD[@]}" exec and the gateway launch. + normalize_slack_runtime_env if [ ${#NEMOCLAW_CMD[@]} -gt 0 ]; then exec "${NEMOCLAW_CMD[@]}" @@ -2806,6 +2889,10 @@ export_gateway_token write_runtime_shell_env ensure_runtime_shell_env_shim lock_rc_files "$_SANDBOX_HOME" +# Normalize Slack provider placeholders before any child (the one-shot +# "${NEMOCLAW_CMD[@]}" exec or the stepped-down gateway) inherits the env. +# gosu/setpriv preserve the environment, so the export reaches the gateway user. +normalize_slack_runtime_env # Messaging channel config was announced before placeholder refresh so the # baseline captures the same provider placeholders the gateway will use. diff --git a/test/nemoclaw-start.test.ts b/test/nemoclaw-start.test.ts index b91060992d..7baf1bb394 100644 --- a/test/nemoclaw-start.test.ts +++ b/test/nemoclaw-start.test.ts @@ -381,6 +381,7 @@ describe("nemoclaw-start non-root fallback", () => { 'write_runtime_shell_env() { :; }', 'ensure_runtime_shell_env_shim() { :; }', 'lock_rc_files() { :; }', + 'normalize_slack_runtime_env() { :; }', 'configure_messaging_channels() { echo "SHOULD_NOT_CONFIGURE"; exit 70; }', 'install_telegram_diagnostics() { echo "SHOULD_NOT_INSTALL"; exit 71; }', 'install_slack_channel_guard() { echo "SHOULD_NOT_INSTALL"; exit 73; }', @@ -2885,6 +2886,267 @@ describe("provider placeholder refresh (#4251)", () => { "telegram.default.botToken is an OpenShell placeholder but TELEGRAM_BOT_TOKEN is missing", ); }); + + it("warns when the Slack config alias is present but SLACK_BOT_TOKEN is missing", () => { + const run = runRefresh({ + channels: { + slack: { + accounts: { + default: { + botToken: "xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN", + appToken: "xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN", + }, + }, + }, + }, + }); + + expect(run.result.status, run.result.stderr).toBe(0); + expect(run.result.stderr).toContain( + "slack.default.botToken expects the SLACK_BOT_TOKEN provider placeholder but it is missing", + ); + expect(run.result.stderr).toContain( + "slack.default.appToken expects the SLACK_APP_TOKEN provider placeholder but it is missing", + ); + }); + + it("does not warn when the Slack config alias matches an OpenShell runtime placeholder", () => { + const run = runRefresh( + { + channels: { + slack: { + accounts: { + default: { + botToken: "xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN", + appToken: "xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN", + }, + }, + }, + }, + }, + { + SLACK_BOT_TOKEN: "openshell:resolve:env:v42_SLACK_BOT_TOKEN", + SLACK_APP_TOKEN: "openshell:resolve:env:v42_SLACK_APP_TOKEN", + }, + ); + + expect(run.result.status, run.result.stderr).toBe(0); + expect(run.result.stderr).not.toContain("slack.default"); + // The Bolt-compatible alias is never rewritten on disk; it does not match + // the canonical "openshell:resolve:env:SLACK_BOT_TOKEN" placeholder key. + expect(run.config.channels.slack.accounts.default.botToken).toBe( + "xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN", + ); + expect(run.config.channels.slack.accounts.default.appToken).toBe( + "xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN", + ); + }); + + it("does not warn when the Slack runtime env holds a genuine xoxb-/xapp- token", () => { + const run = runRefresh( + { + channels: { + slack: { + accounts: { + default: { + botToken: "xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN", + appToken: "xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN", + }, + }, + }, + }, + }, + { + SLACK_BOT_TOKEN: "xoxb-1-real-bot-token", + SLACK_APP_TOKEN: "xapp-1-real-app-token", + }, + ); + + expect(run.result.status, run.result.stderr).toBe(0); + expect(run.result.stderr).not.toContain("slack.default"); + expect(JSON.stringify(run.config)).not.toContain("xoxb-1-real-bot-token"); + }); + + it("warns when the Slack runtime env holds neither a placeholder nor a Slack token", () => { + const run = runRefresh( + { + channels: { + slack: { + accounts: { + default: { + botToken: "xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN", + }, + }, + }, + }, + }, + { SLACK_BOT_TOKEN: "garbage-not-a-token" }, + ); + + expect(run.result.status, run.result.stderr).toBe(0); + expect(run.result.stderr).toContain( + "slack.default.botToken runtime SLACK_BOT_TOKEN is neither the SLACK_BOT_TOKEN OpenShell placeholder nor a xoxb- Slack token", + ); + }); + + it("warns when the Slack runtime env resolves a different key than expected", () => { + // A placeholder for the wrong key must not look healthy — Bolt would still + // inherit a non-Slack placeholder and fail at startup. + const run = runRefresh( + { + channels: { + slack: { + accounts: { + default: { + botToken: "xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN", + }, + }, + }, + }, + }, + { SLACK_BOT_TOKEN: "openshell:resolve:env:v51_OTHER_KEY" }, + ); + + expect(run.result.status, run.result.stderr).toBe(0); + expect(run.result.stderr).toContain( + "slack.default.botToken runtime SLACK_BOT_TOKEN is neither the SLACK_BOT_TOKEN OpenShell placeholder nor a xoxb- Slack token", + ); + }); +}); + +describe("Slack runtime env normalization (#4274)", () => { + const src = fs.readFileSync(START_SCRIPT, "utf-8"); + + // Exercises normalize_slack_runtime_env() through the real shell function so + // we prove the *exported* process-env values the OpenClaw child inherits are + // Bolt-compatible, not the canonical "openshell:resolve:env:*" placeholder. + function runNormalize(env: Record = {}): { + bot: string; + app: string; + result: ReturnType; + } { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-slack-runtime-env-")); + const scriptPath = path.join(tmpDir, "run.sh"); + const fn = extractShellFunctionFromSource(src, "normalize_slack_runtime_env"); + fs.writeFileSync( + scriptPath, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + fn, + "normalize_slack_runtime_env", + 'printf "BOT=%s\\n" "${SLACK_BOT_TOKEN-__UNSET__}"', + 'printf "APP=%s\\n" "${SLACK_APP_TOKEN-__UNSET__}"', + ].join("\n"), + { mode: 0o700 }, + ); + // A clean env so an inherited SLACK_* from the host can't mask an "unset" case. + const childEnv: Record = { PATH: process.env.PATH || "" }; + for (const [key, value] of Object.entries(env)) { + if (value !== undefined) childEnv[key] = value; + } + const result = spawnSync("bash", [scriptPath], { + encoding: "utf-8", + env: childEnv, + timeout: 5000, + }); + fs.rmSync(tmpDir, { recursive: true, force: true }); + const bot = (result.stdout.match(/^BOT=(.*)$/m)?.[1] ?? "").trimEnd(); + const app = (result.stdout.match(/^APP=(.*)$/m)?.[1] ?? "").trimEnd(); + return { bot, app, result }; + } + + it("normalizes revision-scoped Slack placeholders to Bolt-compatible aliases", () => { + const run = runNormalize({ + SLACK_BOT_TOKEN: "openshell:resolve:env:v51_SLACK_BOT_TOKEN", + SLACK_APP_TOKEN: "openshell:resolve:env:v51_SLACK_APP_TOKEN", + }); + + expect(run.result.status, run.result.stderr).toBe(0); + expect(run.bot).toBe("xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN"); + expect(run.app).toBe("xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN"); + }); + + it("does not leak the revision suffix into the normalized env or logs", () => { + const run = runNormalize({ + SLACK_BOT_TOKEN: "openshell:resolve:env:v51_SLACK_BOT_TOKEN", + SLACK_APP_TOKEN: "openshell:resolve:env:v51_SLACK_APP_TOKEN", + }); + + expect(run.result.status, run.result.stderr).toBe(0); + expect(run.bot).not.toContain("v51_"); + expect(run.app).not.toContain("v51_"); + expect(run.result.stderr).not.toContain("v51_"); + expect(run.bot).not.toContain("openshell:resolve:env:"); + expect(run.app).not.toContain("openshell:resolve:env:"); + }); + + it("normalizes the canonical (non-revision) placeholder too", () => { + const run = runNormalize({ + SLACK_BOT_TOKEN: "openshell:resolve:env:SLACK_BOT_TOKEN", + SLACK_APP_TOKEN: "openshell:resolve:env:SLACK_APP_TOKEN", + }); + + expect(run.result.status, run.result.stderr).toBe(0); + expect(run.bot).toBe("xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN"); + expect(run.app).toBe("xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN"); + }); + + it("leaves already-aliased Slack tokens unchanged (idempotent)", () => { + const run = runNormalize({ + SLACK_BOT_TOKEN: "xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN", + SLACK_APP_TOKEN: "xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN", + }); + + expect(run.result.status, run.result.stderr).toBe(0); + expect(run.bot).toBe("xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN"); + expect(run.app).toBe("xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN"); + }); + + it("leaves real Slack tokens untouched", () => { + const run = runNormalize({ + SLACK_BOT_TOKEN: "xoxb-123-real-bot-token", + SLACK_APP_TOKEN: "xapp-1-real-app-token", + }); + + expect(run.result.status, run.result.stderr).toBe(0); + expect(run.bot).toBe("xoxb-123-real-bot-token"); + expect(run.app).toBe("xapp-1-real-app-token"); + }); + + it("does not create Slack env vars that were never set", () => { + const run = runNormalize(); + + expect(run.result.status, run.result.stderr).toBe(0); + expect(run.bot).toBe("__UNSET__"); + expect(run.app).toBe("__UNSET__"); + }); + + it("leaves a placeholder that resolves a different key untouched", () => { + // OpenShell injects self-referential placeholders. A placeholder resolving + // some other secret must not be silently rebound to the Slack alias. + const run = runNormalize({ + SLACK_BOT_TOKEN: "openshell:resolve:env:v51_SOME_OTHER_KEY", + SLACK_APP_TOKEN: "openshell:resolve:env:v51_SOME_OTHER_KEY", + }); + + expect(run.result.status, run.result.stderr).toBe(0); + expect(run.bot).toBe("openshell:resolve:env:v51_SOME_OTHER_KEY"); + expect(run.app).toBe("openshell:resolve:env:v51_SOME_OTHER_KEY"); + }); + + it("leaves a suffix-collision key (…_NOT_SLACK_BOT_TOKEN) untouched", () => { + // The match is anchored: only the canonical key or its v_ form is + // rebound, never a key that merely ends with the same suffix. + const run = runNormalize({ + SLACK_BOT_TOKEN: "openshell:resolve:env:v51_NOT_SLACK_BOT_TOKEN", + SLACK_APP_TOKEN: "openshell:resolve:env:MY_SLACK_APP_TOKEN", + }); + + expect(run.result.status, run.result.stderr).toBe(0); + expect(run.bot).toBe("openshell:resolve:env:v51_NOT_SLACK_BOT_TOKEN"); + expect(run.app).toBe("openshell:resolve:env:MY_SLACK_APP_TOKEN"); + }); }); describe("Telegram diagnostics (#2766)", () => { @@ -2968,6 +3230,7 @@ describe("Telegram diagnostics (#2766)", () => { 'write_runtime_shell_env() { :; }', 'ensure_runtime_shell_env_shim() { :; }', 'lock_rc_files() { :; }', + 'normalize_slack_runtime_env() { :; }', 'configure_messaging_channels() { echo "ORDER:configure"; }', 'install_slack_channel_guard() { :; }', 'verify_no_slack_secrets_on_disk() { :; }',