diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index b5dcd3b0bfc..f5fc9b5c09d 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. @@ -401,6 +401,86 @@ 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 + else + printf '[channels] Warning: SLACK_BOT_TOKEN is set but SLACK_APP_TOKEN is missing — Socket Mode requires both tokens\n' >&2 + 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 +715,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). + # + # 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. # - # 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. + # 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 @@ -876,6 +958,14 @@ if [ "$(id -u)" -ne 0 ]; then fi 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 @@ -975,6 +1065,7 @@ fi verify_config_integrity apply_model_override apply_cors_override +apply_slack_token_override export_gateway_token install_configure_guard @@ -983,6 +1074,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" diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index e329fa928ca..d7b70b3db80 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, @@ -1090,8 +1105,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.", ); } @@ -1213,7 +1227,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; @@ -1382,12 +1399,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(), @@ -1438,18 +1455,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", @@ -1573,9 +1592,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, @@ -1923,7 +1940,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; @@ -2275,8 +2294,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"); @@ -2331,18 +2351,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() { @@ -2393,7 +2407,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. @@ -2641,7 +2657,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.", + ); } } @@ -2678,13 +2696,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`, @@ -2716,12 +2731,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); @@ -3199,10 +3215,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) { @@ -3590,7 +3603,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}`); @@ -3643,6 +3658,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. @@ -3813,7 +3838,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); @@ -4122,7 +4149,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") { @@ -4291,7 +4322,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); @@ -4369,7 +4402,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"; @@ -4426,12 +4461,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); @@ -4487,9 +4526,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) { @@ -4663,7 +4705,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); } @@ -4673,7 +4717,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; @@ -4725,8 +4771,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`, ]); @@ -4735,9 +4783,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; } @@ -4749,7 +4795,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")) @@ -4909,6 +4957,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) { @@ -4969,18 +5038,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 @@ -5026,10 +5095,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"); } @@ -5811,8 +5880,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`); } } @@ -6255,7 +6328,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 3f566289e1b..bc9193eabe1 100644 --- a/test/nemoclaw-start.test.ts +++ b/test/nemoclaw-start.test.ts @@ -322,6 +322,7 @@ 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("NEMOCLAW_REASONING"); // shfmt may format `|| return 0` as a standalone `return 0` on its own line expect(fn[1]).toMatch(/\|\|\s*return 0|^\s*return 0/m); }); @@ -408,6 +409,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)", () => { @@ -426,7 +437,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(); }); @@ -471,6 +482,108 @@ 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("warns when SLACK_BOT_TOKEN is set but SLACK_APP_TOKEN is missing", () => { + const fn = src.match(/apply_slack_token_override\(\) \{([\s\S]*?)^}/m); + expect(fn).toBeTruthy(); + expect(fn[1]).toContain("SLACK_APP_TOKEN is missing"); + expect(fn[1]).toContain("Socket Mode requires both tokens"); + }); + + 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 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", () => { + 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"); diff --git a/test/onboard.test.ts b/test/onboard.test.ts index 0cced605456..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", ]; @@ -2724,6 +2732,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 +2793,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 +2814,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 +2838,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"), @@ -3192,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")); @@ -4978,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 { @@ -5062,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 {