diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 930ddf547d8..32748ce5efe 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -389,12 +389,13 @@ PYCORS apply_slack_token_override() { [ -n "${SLACK_BOT_TOKEN:-}" ] || return 0 - # SECURITY: Only root can write to /sandbox/.openclaw (root:root 444). - # Non-root with SLACK_BOT_TOKEN set means the placeholder can never be resolved — - # Bolt will crash with invalid_auth. Fail fast rather than silently skip. + # Non-root cannot write to /sandbox/.openclaw (root:root 444), so the + # placeholder token cannot be resolved here. Log a warning and continue — + # the Slack channel guard will catch the inevitable auth failure at runtime + # without crashing the gateway. Ref: #2340 if [ "$(id -u)" -ne 0 ]; 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 - return 1 + printf '[channels] Slack token override skipped (non-root) — channel guard will handle auth failure at runtime\n' >&2 + return 0 fi local config_file="/sandbox/.openclaw/openclaw.json" @@ -465,6 +466,131 @@ PYSLACK printf '[channels] Config hash recomputed after Slack token override\n' >&2 } +# ── Slack channel guard (unhandled-rejection safety net) ───────── +# Prevents the gateway from crashing when a Slack channel fails to +# initialize (e.g., invalid_auth, token_revoked, unresolved placeholder +# tokens). Instead of modifying openclaw.json (which is Landlock +# read-only at runtime), this injects a Node.js preload via +# NODE_OPTIONS that catches unhandled promise rejections originating +# from Slack channel initialization and logs them as warnings instead +# of letting Node v22 treat them as fatal. +# +# Same pattern as the HTTP proxy fix (_PROXY_FIX_SCRIPT) and the +# WebSocket CONNECT fix (_WS_FIX_SCRIPT). +# +# Ref: https://github.com/NVIDIA/NemoClaw/issues/2340 +_SLACK_GUARD_SCRIPT="/tmp/nemoclaw-slack-channel-guard.js" + +install_slack_channel_guard() { + local config_file="/sandbox/.openclaw/openclaw.json" + + # Only install if a Slack channel is configured + if ! grep -q '"slack"' "$config_file" 2>/dev/null; then + return 0 + fi + + printf '[channels] Installing Slack channel guard (unhandled-rejection safety net)\n' >&2 + + emit_sandbox_sourced_file "$_SLACK_GUARD_SCRIPT" <<'SLACK_GUARD_EOF' +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// slack-channel-guard.js — catches unhandled promise rejections from Slack +// channel initialization so a single channel auth failure does not crash +// the entire OpenClaw gateway. Node v22 treats unhandled rejections as +// fatal (--unhandled-rejections=throw is the default), taking down +// inference, chat, and TUI alongside the failed Slack channel. +// +// This preload installs a process-level handler that detects Slack-specific +// rejections (by error code or stack trace) and logs a warning instead of +// crashing. Non-Slack rejections are re-thrown to preserve normal behavior. +// +// Ref: https://github.com/NVIDIA/NemoClaw/issues/2340 + +(function () { + 'use strict'; + + // Slack-specific error codes from @slack/web-api that indicate auth failure. + // These appear as error.code on the WebAPIRequestError or CodedError objects. + var SLACK_AUTH_ERRORS = [ + 'slack_webapi_platform_error', + 'slack_webapi_request_error', + 'slackbot_error', + ]; + + // Slack-specific error messages that indicate auth/token problems. + var SLACK_AUTH_MESSAGES = [ + 'invalid_auth', + 'not_authed', + 'token_revoked', + 'token_expired', + 'account_inactive', + 'missing_scope', + 'not_allowed_token_type', + 'An API error occurred: invalid_auth', + ]; + + function isSlackRejection(reason) { + if (!reason) return false; + + // Check error code (Slack SDK sets .code on its errors) + var code = reason.code || ''; + for (var i = 0; i < SLACK_AUTH_ERRORS.length; i++) { + if (code === SLACK_AUTH_ERRORS[i]) return true; + } + + // Check error message + var msg = String(reason.message || reason); + for (var j = 0; j < SLACK_AUTH_MESSAGES.length; j++) { + if (msg.indexOf(SLACK_AUTH_MESSAGES[j]) !== -1) return true; + } + + // Check stack trace for @slack/ packages + var stack = reason.stack || ''; + if (stack.indexOf('@slack/') !== -1 || stack.indexOf('slack-') !== -1) { + return true; + } + + return false; + } + + function handleSlackError(reason, source) { + if (isSlackRejection(reason)) { + var msg = (reason && reason.message) ? reason.message : String(reason); + process.stderr.write( + '[channels] [slack] provider failed to start: ' + msg + + ' \u2014 ' + source + ' caught by safety net, gateway continues\n' + ); + return true; // handled + } + return false; + } + + // Catch async Slack errors (rejected promises from @slack/web-api). + process.on('unhandledRejection', function (reason, promise) { + if (handleSlackError(reason, 'unhandledRejection')) return; + // Non-Slack: re-throw to preserve default --unhandled-rejections=throw. + throw reason; + }); + + // Catch sync Slack errors (e.g., Bolt token format validation throws + // synchronously when appToken doesn't start with xapp-). + process.on('uncaughtException', function (err, origin) { + if (handleSlackError(err, 'uncaughtException')) return; + // Non-Slack: re-throw to preserve normal crash behavior. + // Print the error first since re-throw inside uncaughtException handler + // may not print the original stack. + process.stderr.write(err.stack || String(err)); + process.stderr.write('\n'); + process.exit(1); + }); +})(); +SLACK_GUARD_EOF + + export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--require $_SLACK_GUARD_SCRIPT" + printf '[channels] Slack channel guard installed (NODE_OPTIONS updated)\n' >&2 +} + _read_gateway_token() { python3 - <<'PYTOKEN' import json @@ -1129,6 +1255,7 @@ if [ "$(id -u)" -ne 0 ]; then export_gateway_token install_configure_guard configure_messaging_channels + install_slack_channel_guard validate_openclaw_symlinks # Ensure writable state directories exist and are owned by the current user. @@ -1248,6 +1375,7 @@ install_configure_guard # Must run AFTER integrity check (to detect build-time tampering) and # BEFORE chattr +i (which locks the config permanently). configure_messaging_channels +install_slack_channel_guard # Write auth profile as sandbox user (needs writable .openclaw-data) # and recursively re-tighten any auth-profiles.json files under ~/.openclaw. diff --git a/test/e2e/brev-e2e.test.ts b/test/e2e/brev-e2e.test.ts index bbfc4cb5e17..cec1c70cf4e 100644 --- a/test/e2e/brev-e2e.test.ts +++ b/test/e2e/brev-e2e.test.ts @@ -29,6 +29,10 @@ * BREV_MIN_DISK — Minimum disk size in GB (default: 50) * TELEGRAM_BOT_TOKEN — Telegram bot token for messaging-providers test (fake OK) * DISCORD_BOT_TOKEN — Discord bot token for messaging-providers test (fake OK) + * SLACK_BOT_TOKEN — Slack bot token for messaging-providers test (fake OK) + * SLACK_APP_TOKEN — Slack app token for messaging-providers test (fake OK) + * SLACK_BOT_TOKEN_REVOKED — Revoked xoxb- token to test auth pre-validation (#2340) + * SLACK_APP_TOKEN_REVOKED — Paired xapp- token for the revoked bot token * TELEGRAM_BOT_TOKEN_REAL — Real Telegram token for optional live round-trip * DISCORD_BOT_TOKEN_REAL — Real Discord token for optional live round-trip * TELEGRAM_CHAT_ID_E2E — Telegram chat ID for optional sendMessage test @@ -143,6 +147,10 @@ function sshEnv(cmd, { timeout = 600_000, stream = false } = {}) { for (const key of [ "TELEGRAM_BOT_TOKEN", "DISCORD_BOT_TOKEN", + "SLACK_BOT_TOKEN", + "SLACK_APP_TOKEN", + "SLACK_BOT_TOKEN_REVOKED", + "SLACK_APP_TOKEN_REVOKED", "TELEGRAM_BOT_TOKEN_REAL", "DISCORD_BOT_TOKEN_REAL", "TELEGRAM_CHAT_ID_E2E", diff --git a/test/e2e/test-messaging-providers.sh b/test/e2e/test-messaging-providers.sh index 62a5ad434cc..098d52ff5aa 100755 --- a/test/e2e/test-messaging-providers.sh +++ b/test/e2e/test-messaging-providers.sh @@ -42,6 +42,10 @@ # TELEGRAM_ALLOWED_IDS — comma-separated Telegram user IDs for DM allowlisting # TELEGRAM_BOT_TOKEN_REAL — optional: enables Phase 6 real round-trip # DISCORD_BOT_TOKEN_REAL — optional: enables Phase 6 real round-trip +# SLACK_BOT_TOKEN — defaults to fake token (xoxb-fake-...) +# SLACK_APP_TOKEN — defaults to fake token (xapp-fake-...) +# SLACK_BOT_TOKEN_REVOKED — optional: revoked xoxb- token to test auth pre-validation (#2340) +# SLACK_APP_TOKEN_REVOKED — optional: paired xapp- token for the revoked bot token # TELEGRAM_CHAT_ID_E2E — optional: enables sendMessage test # NEMOCLAW_E2E_STRICT_DISCORD_GATEWAY — fail instead of skip on known Discord gateway blockers # @@ -98,9 +102,13 @@ register_sandbox_for_teardown "$SANDBOX_NAME" # Default to fake tokens if not provided TELEGRAM_TOKEN="${TELEGRAM_BOT_TOKEN:-test-fake-telegram-token-e2e}" DISCORD_TOKEN="${DISCORD_BOT_TOKEN:-test-fake-discord-token-e2e}" +SLACK_TOKEN="${SLACK_BOT_TOKEN:-xoxb-fake-slack-token-e2e}" +SLACK_APP="${SLACK_APP_TOKEN:-xapp-fake-slack-app-token-e2e}" TELEGRAM_IDS="${TELEGRAM_ALLOWED_IDS:-123456789,987654321}" export TELEGRAM_BOT_TOKEN="$TELEGRAM_TOKEN" export DISCORD_BOT_TOKEN="$DISCORD_TOKEN" +export SLACK_BOT_TOKEN="$SLACK_TOKEN" +export SLACK_APP_TOKEN="$SLACK_APP" export TELEGRAM_ALLOWED_IDS="$TELEGRAM_IDS" # Run a command inside the sandbox via stdin (avoids exposing sensitive args in process list) @@ -164,6 +172,8 @@ pass "Docker is running" info "Telegram token: ${TELEGRAM_TOKEN:0:10}... (${#TELEGRAM_TOKEN} chars)" info "Discord token: ${DISCORD_TOKEN:0:10}... (${#DISCORD_TOKEN} chars)" +info "Slack bot token: configured (${#SLACK_TOKEN} chars)" +info "Slack app token: configured (${#SLACK_APP} chars)" info "Sandbox name: $SANDBOX_NAME" STRICT_DISCORD_GATEWAY="${NEMOCLAW_E2E_STRICT_DISCORD_GATEWAY:-0}" @@ -559,6 +569,37 @@ print(account.get('groupPolicy', '')) else skip "M11d: Telegram groupPolicy not set (channel may not be configured)" fi + + # M11e: Slack channel configured — gateway must survive auth failure (#2340) + # The Slack channel has placeholder tokens that will fail auth. The channel + # guard preload (NODE_OPTIONS --require) should catch the error. We can't + # verify the guard file via SSH (different container), but we CAN check the + # gateway port from here. This is tested more thoroughly in Phase 7. + slack_configured=$(echo "$channel_json" | python3 -c " +import json, sys +d = json.load(sys.stdin) +print('yes' if 'slack' in d else 'no') +" 2>/dev/null || true) + if [ "$slack_configured" = "yes" ]; then + pass "M11e: Slack channel configured with placeholder tokens (guard needed)" + + # Diagnostics: check if the guard was installed and what NODE_OPTIONS looks like + info "Checking guard installation diagnostics (via openshell exec as root):" + guard_exists=$(openshell sandbox exec --name "$SANDBOX_NAME" -- ls -la /tmp/nemoclaw-slack-channel-guard.js 2>/dev/null || echo "EXEC_FAILED") + info " Guard file: $guard_exists" + node_opts=$(openshell sandbox exec --name "$SANDBOX_NAME" -- bash -c 'echo "$NODE_OPTIONS"' 2>/dev/null || echo "EXEC_FAILED") + info " NODE_OPTIONS: $node_opts" + proxy_fix=$(openshell sandbox exec --name "$SANDBOX_NAME" -- ls -la /tmp/nemoclaw-http-proxy-fix.js 2>/dev/null || echo "EXEC_FAILED") + info " Proxy fix file: $proxy_fix" + # Check what processes are running + procs=$(openshell sandbox exec --name "$SANDBOX_NAME" -- ps aux 2>/dev/null | head -10 || echo "EXEC_FAILED") + info " Processes:" + echo "$procs" | while IFS= read -r line; do + info " $line" + done + else + skip "M11e: No Slack channel in config" + fi fi # ══════════════════════════════════════════════════════════════════ @@ -1043,9 +1084,56 @@ else fi # ══════════════════════════════════════════════════════════════════ -# Phase 7: Cleanup +# Phase 7: Slack channel guard (#2340) +# +# The sandbox was installed with fake Slack tokens. The channel guard +# preload (NODE_OPTIONS --require) should catch the unhandled rejection +# from @slack/web-api and keep the gateway alive. +# ══════════════════════════════════════════════════════════════════ +section "Phase 7: Slack channel guard (#2340)" + +# S1: Gateway is serving on port 18789 — the guard caught the Slack rejection +gw_port=$(sandbox_exec 'node -e " +const net = require(\"net\"); +const sock = net.connect(18789, \"127.0.0.1\"); +sock.on(\"connect\", () => { console.log(\"OPEN\"); sock.end(); }); +sock.on(\"error\", () => console.log(\"CLOSED\")); +setTimeout(() => { console.log(\"TIMEOUT\"); sock.destroy(); }, 5000); +"' 2>/dev/null || true) +if echo "$gw_port" | grep -q "OPEN"; then + pass "S1: Gateway is serving on port 18789 — Slack auth failure did not crash it" +else + fail "S1: Gateway is not serving on port 18789 (${gw_port:0:200})" +fi + +# S2: Dump gateway.log for diagnostics (must use openshell exec — SSH user +# cannot read the file because it's 600 gateway:gateway). +gw_log=$(openshell sandbox exec --name "$SANDBOX_NAME" -- cat /tmp/gateway.log 2>/dev/null || true) +if [ -z "$gw_log" ]; then + # Container may have already exited + gw_log=$(nemoclaw "$SANDBOX_NAME" logs 2>&1 | tail -200 || true) +fi + +info "Gateway log (last 30 lines):" +echo "$gw_log" | tail -30 | while IFS= read -r line; do + info " $line" +done + +if echo "$gw_log" | grep -q "provider failed to start:.*gateway continues"; then + pass "S2: Gateway log shows Slack rejection was caught by channel guard" +elif echo "$gw_log" | grep -qi "slack"; then + info "Slack-related lines: $(echo "$gw_log" | grep -i slack | head -5)" + skip "S2: Gateway log has Slack output but not the guard catch message" +elif [ -z "$gw_log" ]; then + skip "S2: Could not read gateway log (container may have exited)" +else + skip "S2: No Slack-related output in gateway log" +fi + +# ══════════════════════════════════════════════════════════════════ +# Phase 8: Cleanup # ══════════════════════════════════════════════════════════════════ -section "Phase 7: Cleanup" +section "Phase 8: Cleanup" info "Destroying sandbox '$SANDBOX_NAME'..." [[ "${NEMOCLAW_E2E_KEEP_SANDBOX:-}" = "1" ]] || nemoclaw "$SANDBOX_NAME" destroy --yes 2>/dev/null || true diff --git a/test/local-slack-auth-test.sh b/test/local-slack-auth-test.sh new file mode 100755 index 00000000000..73717163abb --- /dev/null +++ b/test/local-slack-auth-test.sh @@ -0,0 +1,209 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Local end-to-end test for the Slack channel guard (install_slack_channel_guard) +# from nemoclaw-start.sh. +# +# Extracts the guard's JS preload from the shell script, then runs Node.js +# scenarios that simulate Slack-style unhandled rejections and uncaught +# exceptions to verify the guard catches them without crashing the process, +# while still letting non-Slack errors through. +# +# Usage: bash test/local-slack-auth-test.sh +# +# Requirements: node (v22+), bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +START_SCRIPT="$SCRIPT_DIR/../scripts/nemoclaw-start.sh" +PASS=0 +FAIL=0 + +# ── Helpers ────────────────────────────────────────────────────── + +green() { printf '\033[32m%s\033[0m\n' "$*"; } +red() { printf '\033[31m%s\033[0m\n' "$*"; } + +pass() { + green " PASS: $1" + PASS=$((PASS + 1)) +} +fail() { + red " FAIL: $1" + FAIL=$((FAIL + 1)) +} + +header() { printf '\n── %s ──\n' "$1"; } + +# ── Extract the guard JS from the shell script ────────────────── + +TMPDIR_BASE="$(mktemp -d)" +trap 'rm -rf "$TMPDIR_BASE"' EXIT + +GUARD_JS="$TMPDIR_BASE/slack-channel-guard.js" + +# The JS is between the line containing <<'SLACK_GUARD_EOF' and the closing SLACK_GUARD_EOF +sed -n "/<<'SLACK_GUARD_EOF'$/,/^SLACK_GUARD_EOF$/p" "$START_SCRIPT" \ + | sed '1d;$d' >"$GUARD_JS" + +if [ ! -s "$GUARD_JS" ]; then + echo "ERROR: could not extract guard JS from $START_SCRIPT" >&2 + exit 1 +fi + +echo "Extracted guard JS ($(wc -l <"$GUARD_JS") lines)" + +# ── Test runner ───────────────────────────────────────────────── +# Runs node with the guard preloaded, executing inline JS. +# Captures stderr and exit code. + +run_node() { + local script="$1" + local stderr_file="$TMPDIR_BASE/stderr.log" + local exit_code=0 + + node --require "$GUARD_JS" -e "$script" 2>"$stderr_file" || exit_code=$? + + LAST_STDERR=$(cat "$stderr_file") + LAST_EXIT=$exit_code +} + +# ══════════════════════════════════════════════════════════════════ +# TESTS +# ══════════════════════════════════════════════════════════════════ + +header "T1: Slack unhandled rejection (invalid_auth) — should be caught" +run_node " + var err = new Error('An API error occurred: invalid_auth'); + err.code = 'slack_webapi_platform_error'; + Promise.reject(err); + setTimeout(function() { console.log('ALIVE'); }, 200); +" + +if [ "$LAST_EXIT" -eq 0 ] && echo "$LAST_STDERR" | grep -q "caught by safety net"; then + pass "invalid_auth rejection caught, process survived (exit=$LAST_EXIT)" +else + fail "expected guard to catch, got exit=$LAST_EXIT stderr='$LAST_STDERR'" +fi + +# ────────────────────────────────────────────────────────────────── + +header "T2: Slack unhandled rejection (token_revoked) — should be caught" +run_node " + var err = new Error('token_revoked'); + err.code = 'slack_webapi_platform_error'; + Promise.reject(err); + setTimeout(function() { console.log('ALIVE'); }, 200); +" + +if [ "$LAST_EXIT" -eq 0 ] && echo "$LAST_STDERR" | grep -q "caught by safety net"; then + pass "token_revoked rejection caught (exit=$LAST_EXIT)" +else + fail "expected guard to catch, got exit=$LAST_EXIT stderr='$LAST_STDERR'" +fi + +# ────────────────────────────────────────────────────────────────── + +header "T3: Slack rejection detected by stack trace (@slack/ in stack)" +run_node " + var err = new Error('something went wrong'); + err.stack = 'Error: something\n at Object. (node_modules/@slack/web-api/src/WebClient.ts:405:36)'; + Promise.reject(err); + setTimeout(function() { console.log('ALIVE'); }, 200); +" + +if [ "$LAST_EXIT" -eq 0 ] && echo "$LAST_STDERR" | grep -q "caught by safety net"; then + pass "stack-trace detection works (exit=$LAST_EXIT)" +else + fail "expected guard to catch via stack, got exit=$LAST_EXIT stderr='$LAST_STDERR'" +fi + +# ────────────────────────────────────────────────────────────────── + +header "T4: Non-Slack rejection — should NOT be caught (re-thrown)" +run_node " + Promise.reject(new Error('database connection failed')); + setTimeout(function() { console.log('SHOULD NOT REACH'); }, 200); +" + +if [ "$LAST_EXIT" -ne 0 ]; then + pass "non-Slack rejection re-thrown, process exited (exit=$LAST_EXIT)" +else + fail "expected process to crash on non-Slack error, got exit=$LAST_EXIT" +fi + +# ────────────────────────────────────────────────────────────────── + +header "T5: Slack sync exception (uncaughtException) — should be caught" +run_node " + var err = new Error('invalid_auth'); + err.code = 'slack_webapi_platform_error'; + throw err; +" + +if [ "$LAST_EXIT" -eq 0 ] && echo "$LAST_STDERR" | grep -q "caught by safety net"; then + pass "sync Slack exception caught (exit=$LAST_EXIT)" +else + fail "expected guard to catch sync throw, got exit=$LAST_EXIT stderr='$LAST_STDERR'" +fi + +# ────────────────────────────────────────────────────────────────── + +header "T6: Non-Slack sync exception — should crash" +run_node " + throw new Error('out of memory'); +" + +if [ "$LAST_EXIT" -ne 0 ]; then + pass "non-Slack exception crashes as expected (exit=$LAST_EXIT)" +else + fail "expected crash on non-Slack exception, got exit=$LAST_EXIT" +fi + +# ────────────────────────────────────────────────────────────────── + +header "T7: Guard logs include the error message" +run_node " + var err = new Error('An API error occurred: invalid_auth'); + err.code = 'slack_webapi_platform_error'; + Promise.reject(err); + setTimeout(function() {}, 200); +" + +if echo "$LAST_STDERR" | grep -q "provider failed to start.*invalid_auth"; then + pass "log message includes the Slack error details" +else + fail "log missing error details, got: '$LAST_STDERR'" +fi + +# ────────────────────────────────────────────────────────────────── + +header "T8: Normal operation — no errors, guard is invisible" +run_node " + console.log('hello'); + setTimeout(function() { console.log('done'); }, 100); +" + +if [ "$LAST_EXIT" -eq 0 ] && [ -z "$LAST_STDERR" ]; then + pass "guard is invisible during normal operation (exit=$LAST_EXIT)" +else + fail "guard interfered with normal operation, exit=$LAST_EXIT stderr='$LAST_STDERR'" +fi + +# ══════════════════════════════════════════════════════════════════ +# Summary +# ══════════════════════════════════════════════════════════════════ + +echo "" +echo "═══════════════════════════════════════" +printf " Results: " +green "$PASS passed" +if [ "$FAIL" -gt 0 ]; then + printf " " + red "$FAIL failed" +fi +echo "═══════════════════════════════════════" + +exit "$FAIL" diff --git a/test/nemoclaw-start.test.ts b/test/nemoclaw-start.test.ts index ef682ab01da..baeae1f6463 100644 --- a/test/nemoclaw-start.test.ts +++ b/test/nemoclaw-start.test.ts @@ -430,7 +430,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*apply_slack_token_override\n\s*export_gateway_token/, + /# ── Root path[\s\S]*?apply_model_override[\s\S]*?apply_cors_override[\s\S]*?apply_slack_token_override[\s\S]*?export_gateway_token/, ); expect(rootBlock).toBeTruthy(); }); @@ -475,6 +475,76 @@ describe("runtime CORS origin override (#719)", () => { }); }); +describe("Slack channel guard — unhandled-rejection safety net (#2340)", () => { + const src = fs.readFileSync(START_SCRIPT, "utf-8"); + + it("defines install_slack_channel_guard function", () => { + expect(src).toMatch(/install_slack_channel_guard\(\) \{/); + }); + + it("calls install_slack_channel_guard after configure_messaging_channels in both paths", () => { + const nonRootBlock = src.match( + /if \[ "\$\(id -u\)" -ne 0 \]; then([\s\S]*?)# ── Root path/, + ); + expect(nonRootBlock).toBeTruthy(); + expect(nonRootBlock[1]).toMatch( + /configure_messaging_channels[\s\S]*?install_slack_channel_guard/, + ); + + const rootBlock = src.match( + /# ── Root path[\s\S]*?configure_messaging_channels[\s\S]*?install_slack_channel_guard/, + ); + expect(rootBlock).toBeTruthy(); + }); + + it("is a no-op when no Slack channel is configured", () => { + const fn = src.match(/install_slack_channel_guard\(\) \{([\s\S]*?)^}/m); + expect(fn).toBeTruthy(); + expect(fn[1]).toContain('grep -q \'"slack"\''); + expect(fn[1]).toContain("return 0"); + }); + + it("installs a Node.js preload script via NODE_OPTIONS", () => { + expect(src).toContain('export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--require $_SLACK_GUARD_SCRIPT"'); + }); + + it("catches unhandled promise rejections from Slack", () => { + const fn = src.match(/install_slack_channel_guard\(\) \{([\s\S]*?)^}/m); + expect(fn).toBeTruthy(); + expect(fn[1]).toContain("unhandledRejection"); + expect(fn[1]).toContain("isSlackRejection"); + }); + + it("catches uncaught exceptions from Slack (sync throws)", () => { + const fn = src.match(/install_slack_channel_guard\(\) \{([\s\S]*?)^}/m); + expect(fn).toBeTruthy(); + expect(fn[1]).toContain("uncaughtException"); + }); + + it("re-throws non-Slack rejections to preserve default behavior", () => { + const fn = src.match(/install_slack_channel_guard\(\) \{([\s\S]*?)^}/m); + expect(fn).toBeTruthy(); + expect(fn[1]).toContain("throw reason"); + expect(fn[1]).toContain("process.exit(1)"); + }); + + it("detects Slack errors by error code, message, and stack trace", () => { + const fn = src.match(/install_slack_channel_guard\(\) \{([\s\S]*?)^}/m); + expect(fn).toBeTruthy(); + expect(fn[1]).toContain("slack_webapi_platform_error"); + expect(fn[1]).toContain("invalid_auth"); + expect(fn[1]).toContain("token_revoked"); + expect(fn[1]).toContain("@slack/"); + }); + + it("logs caught Slack errors as warnings instead of crashing", () => { + const fn = src.match(/install_slack_channel_guard\(\) \{([\s\S]*?)^}/m); + expect(fn).toBeTruthy(); + expect(fn[1]).toContain("provider failed to start"); + expect(fn[1]).toContain("caught by safety net, gateway continues"); + }); +}); + describe("nemoclaw-start auto-pair client whitelisting (#117)", () => { const src = fs.readFileSync(START_SCRIPT, "utf-8");