diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index d1a45c5a4e..01aedea39e 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -1411,6 +1411,7 @@ These flags toggle optional behaviors during onboarding; set them before running | `NEMOCLAW_DISABLE_OVERLAY_FIX` | `1` to enable | Skips the Docker overlay-fix step during sandbox build. For environments where the fix is incompatible. | | `NEMOCLAW_OVERLAY_SNAPSHOTTER` | snapshotter name | Selects the containerd overlay snapshotter for sandbox builds. Empty (default) preserves containerd's choice. | | `NEMOCLAW_SKIP_TELEGRAM_REACHABILITY` | `1` to enable | Skips the Telegram bot reachability probe during onboard (useful in restricted networks). | +| `NEMOCLAW_SKIP_SLACK_AUTH_VALIDATION` | `1`, `true`, `yes`, or `on` to enable | Skips the live Slack `auth.test` and `apps.connections.open` credential probes during onboard and `channels add slack`. Use only in restricted networks or hermetic test environments; Slack token format checks still apply. | | `NEMOCLAW_CONFIG_ACCEPT_NEW_PATH` | `1` to enable | Accepts a new sandbox config path without an interactive prompt when the stored path differs from the discovered one. | | `NEMOCLAW_RESOURCE_PROFILE` | profile name or `default` | Selects a sandbox CPU/RAM resource profile from the blueprint during onboarding. `default` means no resource preference, so NemoClaw passes no OpenShell CPU or memory flags. Unknown names fail fast. | | `NEMOCLAW_CPU` | percentage or Kubernetes CPU quantity | Overrides the selected profile's CPU size passed to OpenShell `--cpu`. Percentages resolve against detected capacity. | diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index a92ea1ab30..6bd1dfecdc 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -28,6 +28,7 @@ import { runOpenshell } from "../../adapters/openshell/runtime"; import { shellQuote } from "../../runner"; import { executeSandboxCommand, executeSandboxExecCommand } from "./process-recovery"; import { rebuildSandbox } from "./rebuild"; +import { validateSlackChannelCredentials } from "./slack-channel-validation"; import { printTelegramDirectMessageAllowlistWarning } from "./telegram-channel-bridge-verification"; import { type ChannelDef, @@ -824,6 +825,17 @@ export async function addSandboxChannel( await acquirePasteTokens(canonical, channel, acquired); } + if (canonical === "slack") { + const validation = validateSlackChannelCredentials(channel, acquired); + if (!validation.ok) { + console.error(` ${validation.message}`); + process.exit(1); + } + if (validation.message) { + console.log(` ${YW}⚠${R} ${validation.message}`); + } + } + persistChannelTokens(acquired); // Push to the gateway and update the registry NOW so that answering // "rebuild later" (or running non-interactively) does not silently diff --git a/src/lib/actions/sandbox/slack-channel-validation.ts b/src/lib/actions/sandbox/slack-channel-validation.ts new file mode 100644 index 0000000000..8502b9c4a2 --- /dev/null +++ b/src/lib/actions/sandbox/slack-channel-validation.ts @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { ChannelDef } from "../../sandbox/channels"; +import { + formatSlackValidationFailure, + validateSlackCredentials, +} from "../../onboard/slack-validation"; + +export type SlackChannelCredentialValidationResult = + | { ok: true; message?: string } + | { ok: false; message: string }; + +function isAcquiredTokenFormatValid(channel: ChannelDef, envKey: string, token: string): boolean { + if (envKey === channel.envKey) return !channel.tokenFormat || channel.tokenFormat.test(token); + if (envKey === channel.appTokenEnvKey) { + return !channel.appTokenFormat || channel.appTokenFormat.test(token); + } + return false; +} + +export function validateSlackChannelCredentials( + channel: ChannelDef, + acquired: Record, +): SlackChannelCredentialValidationResult { + if (!channel.envKey || !channel.appTokenEnvKey) { + return { ok: false, message: "Slack channel definition is missing required token keys." }; + } + + const botToken = acquired[channel.envKey]; + const appToken = acquired[channel.appTokenEnvKey]; + if (!botToken || !appToken) { + return { ok: false, message: "Slack requires both SLACK_BOT_TOKEN and SLACK_APP_TOKEN." }; + } + + for (const [envKey, token] of Object.entries(acquired)) { + if (!isAcquiredTokenFormatValid(channel, envKey, token)) { + const hint = + envKey === channel.appTokenEnvKey + ? channel.appTokenFormatHint || "Check the token and try again." + : channel.tokenFormatHint || "Check the token and try again."; + return { ok: false, message: `Invalid ${envKey} format. ${hint}` }; + } + } + + const validation = validateSlackCredentials({ botToken, appToken }); + if (validation.ok) { + return validation.skipped && validation.message + ? { ok: true, message: validation.message } + : { ok: true }; + } + + return { + ok: false, + message: `Slack credential validation failed. ${formatSlackValidationFailure(validation)}`, + }; +} diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 504004f75f..ec4cf998ef 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -552,12 +552,10 @@ import { type SandboxGpuConfig, type SandboxGpuFlag, } from "./onboard/sandbox-gpu-mode"; +import { filterSlackSelectionByValidation } from "./onboard/slack-validation"; import type { SelectionDrift } from "./onboard/selection-drift"; import { formatOnboardConfigSummary, formatSandboxBuildEstimateNote } from "./onboard/summary"; -import type { - ModelValidationResult, - ValidationFailureLike, -} from "./onboard/types"; +import type { ModelValidationResult, ValidationFailureLike } from "./onboard/types"; import type { ContainerRuntime } from "./platform"; import { getChannelTokenKeys, listChannels } from "./sandbox/channels"; import type { GatewayReuseState } from "./state/gateway"; @@ -5611,6 +5609,7 @@ async function setupMessagingChannels( if (reachability.skipped) found = found.filter((c) => c !== "telegram"); } } + found = filterSlackSelectionByValidation(found, MESSAGING_CHANNELS); } else { note(" [non-interactive] No messaging tokens configured. Skipping."); } diff --git a/src/lib/onboard/messaging-channel-setup.test.ts b/src/lib/onboard/messaging-channel-setup.test.ts index fd36c68c64..734aeee51c 100644 --- a/src/lib/onboard/messaging-channel-setup.test.ts +++ b/src/lib/onboard/messaging-channel-setup.test.ts @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { prompt, saveCredential } from "../credentials/store"; import { KNOWN_CHANNELS } from "../sandbox/channels"; import { setupSelectedMessagingChannels } from "./messaging-channel-setup"; +import { validateSlackCredentials } from "./slack-validation"; vi.mock("../credentials/store", () => ({ getCredential: vi.fn(() => null), @@ -20,11 +21,18 @@ vi.mock("./host-qr-dispatch", () => ({ dispatchHostQrLogin: vi.fn(), })); +vi.mock("./slack-validation", () => ({ + formatSlackValidationFailure: vi.fn((result: { message: string }) => result.message), + validateSlackCredentials: vi.fn(() => ({ ok: true })), +})); + const ORIGINAL_ENV = { ...process.env }; describe("setupSelectedMessagingChannels", () => { beforeEach(() => { process.env = { ...ORIGINAL_ENV }; + vi.clearAllMocks(); + vi.mocked(validateSlackCredentials).mockReturnValue({ ok: true }); }); afterEach(() => { @@ -121,4 +129,105 @@ describe("setupSelectedMessagingChannels", () => { expect(output).toContain("Slack channel IDs"); expect(output).toContain("channel IDs saved"); }); + + it("does not save prompted Slack credentials when Slack API rejects them", async () => { + delete process.env.SLACK_BOT_TOKEN; + delete process.env.SLACK_APP_TOKEN; + vi.mocked(prompt) + .mockResolvedValueOnce("xoxb-fake-bot-token") + .mockResolvedValueOnce("xapp-fake-app-token"); + vi.mocked(validateSlackCredentials).mockReturnValueOnce({ + ok: false, + kind: "rejected", + tokenKind: "app", + credential: "app", + error: "invalid_auth", + httpStatus: 200, + curlStatus: 0, + message: "Slack app token was rejected by Slack API: invalid_auth.", + }); + const enabled = new Set(["slack"]); + const logs: string[] = []; + vi.spyOn(console, "log").mockImplementation((message = "") => { + logs.push(String(message)); + }); + + await setupSelectedMessagingChannels( + ["slack"], + enabled, + [{ name: "slack", ...KNOWN_CHANNELS.slack }], + ); + + expect(enabled.has("slack")).toBe(false); + expect(saveCredential).not.toHaveBeenCalled(); + expect(process.env.SLACK_BOT_TOKEN).toBeUndefined(); + expect(process.env.SLACK_APP_TOKEN).toBeUndefined(); + const output = logs.join("\n"); + expect(output).toContain("Slack app token was rejected by Slack API"); + expect(output).not.toContain("xoxb-fake-bot-token"); + expect(output).not.toContain("xapp-fake-app-token"); + }); + + it("does not save prompted Slack credentials when Slack API validation is indeterminate", async () => { + delete process.env.SLACK_BOT_TOKEN; + delete process.env.SLACK_APP_TOKEN; + vi.mocked(prompt) + .mockResolvedValueOnce("xoxb-timeout-bot-token") + .mockResolvedValueOnce("xapp-timeout-app-token"); + vi.mocked(validateSlackCredentials).mockReturnValueOnce({ + ok: false, + kind: "indeterminate", + tokenKind: "bot", + credential: "bot", + httpStatus: 0, + curlStatus: 28, + message: "Slack bot token could not be validated because Slack API was unreachable.", + }); + const enabled = new Set(["slack"]); + + await setupSelectedMessagingChannels( + ["slack"], + enabled, + [{ name: "slack", ...KNOWN_CHANNELS.slack }], + ); + + expect(enabled.has("slack")).toBe(false); + expect(saveCredential).not.toHaveBeenCalled(); + expect(process.env.SLACK_BOT_TOKEN).toBeUndefined(); + expect(process.env.SLACK_APP_TOKEN).toBeUndefined(); + }); + + it("ignores existing Slack tokens that pass format but fail Slack API validation", async () => { + process.env.SLACK_BOT_TOKEN = "xoxb-existing-invalid"; + process.env.SLACK_APP_TOKEN = "xapp-existing-valid"; + vi.mocked(validateSlackCredentials).mockReturnValueOnce({ + ok: false, + kind: "rejected", + tokenKind: "bot", + credential: "bot", + error: "token_revoked", + httpStatus: 200, + curlStatus: 0, + message: "Slack bot token was rejected by Slack API: token_revoked.", + }); + const enabled = new Set(["slack"]); + const logs: string[] = []; + vi.spyOn(console, "log").mockImplementation((message = "") => { + logs.push(String(message)); + }); + + await setupSelectedMessagingChannels( + ["slack"], + enabled, + [{ name: "slack", ...KNOWN_CHANNELS.slack }], + ); + + expect(enabled.has("slack")).toBe(false); + expect(prompt).not.toHaveBeenCalled(); + expect(saveCredential).not.toHaveBeenCalled(); + const output = logs.join("\n"); + expect(output).toContain("Invalid existing slack token ignored"); + expect(output).toContain("token_revoked"); + expect(output).not.toContain("slack — already configured"); + }); }); diff --git a/src/lib/onboard/messaging-channel-setup.ts b/src/lib/onboard/messaging-channel-setup.ts index ad01c3d51f..e0e590188c 100644 --- a/src/lib/onboard/messaging-channel-setup.ts +++ b/src/lib/onboard/messaging-channel-setup.ts @@ -16,6 +16,11 @@ import { getMessagingToken, isMessagingTokenFormatValid, } from "./messaging-token"; +import { + formatSlackValidationFailure, + type SlackTokenKind, + validateSlackCredentials, +} from "./slack-validation"; type ChannelEntry = { name: string } & ChannelDef; @@ -41,6 +46,127 @@ function getExistingMessagingToken( return token; } +type SlackTokenSlot = { + envKey: string; + kind: SlackTokenKind; + label: "token" | "app token"; + promptLabel: string; + help: string; +}; + +type CollectedSlackToken = { + token: string; + save: boolean; + label: "token" | "app token"; +}; + +function skipSlack(enabled: Set, reason: string): null { + console.log(` Skipped slack (${reason})`); + enabled.delete("slack"); + return null; +} + +async function collectSlackToken( + ch: ChannelEntry, + slot: SlackTokenSlot, + enabled: Set, +): Promise { + const existing = getExistingMessagingToken(ch, slot.envKey, slot.label); + if (existing) { + return { token: existing, save: false, label: slot.label }; + } + + console.log(""); + console.log(` ${slot.help}`); + const token = normalizeCredentialValue(await prompt(` ${slot.promptLabel}: `, { secret: true })); + if (!token) { + const reason = + slot.kind === "app" ? "Socket Mode requires both tokens" : "no token entered"; + return skipSlack(enabled, reason); + } + + if (!isMessagingTokenFormatValid(ch, slot.envKey, token)) { + const formatHint = + slot.kind === "app" + ? ch.appTokenFormatHint || "Check the token and try again." + : ch.tokenFormatHint || "Check the token and try again."; + console.log(` ✗ Invalid format. ${formatHint}`); + return skipSlack(enabled, "invalid token format"); + } + + return { token, save: true, label: slot.label }; +} + +async function setupSlackTokens(ch: ChannelEntry, enabled: Set): Promise { + if (!ch.envKey || !ch.appTokenEnvKey || !ch.appTokenHelp || !ch.appTokenLabel) { + return false; + } + + const bot = await collectSlackToken( + ch, + { + envKey: ch.envKey, + kind: "bot", + label: "token", + promptLabel: ch.label, + help: ch.help, + }, + enabled, + ); + if (!bot) return false; + + const app = await collectSlackToken( + ch, + { + envKey: ch.appTokenEnvKey, + kind: "app", + label: "app token", + promptLabel: ch.appTokenLabel, + help: ch.appTokenHelp, + }, + enabled, + ); + if (!app) return false; + + const validation = validateSlackCredentials({ botToken: bot.token, appToken: app.token }); + if (!validation.ok) { + if (!bot.save && validation.credential === "bot") { + console.log(` ✗ Invalid existing ${ch.name} ${bot.label} ignored.`); + } + if (!app.save && validation.credential === "app") { + console.log(` ✗ Invalid existing ${ch.name} ${app.label} ignored.`); + } + const prefix = validation.kind === "rejected" ? "✗" : "⚠"; + console.log(` ${prefix} ${formatSlackValidationFailure(validation)}`); + skipSlack( + enabled, + validation.kind === "rejected" + ? "invalid Slack credentials" + : "Slack API validation unavailable", + ); + return false; + } + if (validation.skipped && validation.message) { + console.log(` ⚠ ${validation.message}`); + } + + if (bot.save) { + saveCredential(ch.envKey, bot.token); + process.env[ch.envKey] = bot.token; + console.log(` ✓ ${ch.name} token saved`); + } else { + console.log(` ✓ ${ch.name} — already configured`); + } + if (app.save) { + saveCredential(ch.appTokenEnvKey, app.token); + process.env[ch.appTokenEnvKey] = app.token; + console.log(` ✓ ${ch.name} app token saved`); + } else { + console.log(` ✓ ${ch.name} app token — already configured`); + } + return true; +} + /** * Prompt for token + per-channel config (app token, server ID, mention * mode, allowlist IDs) for each selected messaging channel. Mutates @@ -63,7 +189,10 @@ export async function setupSelectedMessagingChannels( console.log(` Unknown channel: ${name}`); continue; } - if (channelHasStaticToken(ch) && getExistingMessagingToken(ch, ch.envKey, "token")) { + if (ch.name === "slack" && channelHasStaticToken(ch)) { + const configured = await setupSlackTokens(ch, enabled); + if (!configured) continue; + } else if (channelHasStaticToken(ch) && getExistingMessagingToken(ch, ch.envKey, "token")) { console.log(` ✓ ${ch.name} — already configured`); } else if (ch.loginMethod === "host-qr") { console.log(""); @@ -115,7 +244,7 @@ export async function setupSelectedMessagingChannels( for (const line of ch.setupNotes ?? []) { console.log(` ${line}`); } - if (ch.appTokenEnvKey) { + if (ch.name !== "slack" && ch.appTokenEnvKey) { const existingAppToken = getExistingMessagingToken(ch, ch.appTokenEnvKey, "app token"); if (existingAppToken) { console.log(` ✓ ${ch.name} app token — already configured`); diff --git a/src/lib/onboard/slack-validation.test.ts b/src/lib/onboard/slack-validation.test.ts new file mode 100644 index 0000000000..8be6024372 --- /dev/null +++ b/src/lib/onboard/slack-validation.test.ts @@ -0,0 +1,234 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { ProbeResult } from "./types"; +import { KNOWN_CHANNELS } from "../sandbox/channels"; + +vi.mock("../adapters/http/probe", () => ({ + runCurlProbe: vi.fn(), +})); + +import { runCurlProbe } from "../adapters/http/probe"; +import { + filterSlackSelectionByValidation, + validateSlackAppToken, + validateSlackBotToken, + validateSlackCredentials, +} from "./slack-validation"; + +function probe(body: string, overrides: Partial = {}): ProbeResult { + return { + ok: true, + httpStatus: 200, + curlStatus: 0, + body, + stderr: "", + message: "", + ...overrides, + }; +} + +beforeEach(() => { + vi.mocked(runCurlProbe).mockReset(); + delete process.env.SLACK_BOT_TOKEN; + delete process.env.SLACK_APP_TOKEN; + delete process.env.NEMOCLAW_SKIP_SLACK_AUTH_VALIDATION; +}); + +function curlArgs(): string[] { + return vi.mocked(runCurlProbe).mock.calls[0][0]; +} + +function curlConfigPath(args: string[]): string { + const index = args.indexOf("--config"); + expect(index).toBeGreaterThanOrEqual(0); + return args[index + 1]; +} + +describe("Slack token validation", () => { + it("validates bot tokens with auth.test", () => { + let configPath = ""; + let configText = ""; + vi.mocked(runCurlProbe).mockImplementation((args) => { + configPath = curlConfigPath(args); + configText = fs.readFileSync(configPath, "utf8"); + expect(fs.statSync(configPath).mode & 0o777).toBe(0o600); + return probe('{"ok":true,"user_id":"U123"}'); + }); + + expect(validateSlackBotToken("xoxb-valid-bot")).toEqual({ ok: true }); + const args = curlArgs(); + expect(args).toContain("https://slack.com/api/auth.test"); + expect(args.join("\n")).not.toContain("xoxb-valid-bot"); + expect(args.join("\n")).not.toContain("Authorization: Bearer"); + expect(configText).toContain("Authorization: Bearer xoxb-valid-bot"); + expect(fs.existsSync(configPath)).toBe(false); + }); + + it.each(["invalid_auth", "token_revoked", "not_authed"])( + "rejects bot token error %s", + (error) => { + vi.mocked(runCurlProbe).mockReturnValue(probe(JSON.stringify({ ok: false, error }))); + + const result = validateSlackBotToken("xoxb-bad-bot"); + + expect(result).toMatchObject({ ok: false, kind: "rejected", tokenKind: "bot", error }); + if (!result.ok) expect(result.message).toContain(error); + }, + ); + + it("validates app tokens with apps.connections.open", () => { + vi.mocked(runCurlProbe).mockReturnValue( + probe('{"ok":true,"url":"wss://wss-primary.slack.com/link"}'), + ); + + expect(validateSlackAppToken("xapp-valid-app")).toEqual({ ok: true }); + expect(curlArgs()).toContain("https://slack.com/api/apps.connections.open"); + expect(curlArgs().join("\n")).not.toContain("xapp-valid-app"); + }); + + it.each(["invalid_auth", "missing_scope", "not_allowed_token_type"])( + "rejects app token error %s", + (error) => { + vi.mocked(runCurlProbe).mockReturnValue(probe(JSON.stringify({ ok: false, error }))); + + const result = validateSlackAppToken("xapp-bad-app"); + + expect(result).toMatchObject({ ok: false, kind: "rejected", tokenKind: "app", error }); + if (!result.ok) expect(result.message).toContain(error); + }, + ); + + it("returns the first rejected credential when validating a bot/app pair", () => { + vi.mocked(runCurlProbe).mockReturnValue( + probe('{"ok":false,"error":"invalid_auth"}'), + ); + + expect( + validateSlackCredentials({ botToken: "xoxb-bad-bot", appToken: "xapp-not-checked" }), + ).toMatchObject({ ok: false, credential: "bot", error: "invalid_auth" }); + expect(vi.mocked(runCurlProbe)).toHaveBeenCalledTimes(1); + }); + + it("validates the app token after the bot token passes", () => { + vi.mocked(runCurlProbe) + .mockReturnValueOnce(probe('{"ok":true}')) + .mockReturnValueOnce(probe('{"ok":false,"error":"missing_scope"}')); + + expect( + validateSlackCredentials({ botToken: "xoxb-valid-bot", appToken: "xapp-missing-scope" }), + ).toMatchObject({ ok: false, credential: "app", error: "missing_scope" }); + }); + + it("skips live Slack API probes when explicitly requested", () => { + process.env.NEMOCLAW_SKIP_SLACK_AUTH_VALIDATION = "1"; + + const result = validateSlackCredentials({ + botToken: "xoxb-offline-bot", + appToken: "xapp-offline-app", + }); + + expect(result).toMatchObject({ + ok: true, + skipped: true, + message: expect.stringContaining("NEMOCLAW_SKIP_SLACK_AUTH_VALIDATION is set"), + }); + expect(vi.mocked(runCurlProbe)).not.toHaveBeenCalled(); + }); + + it.each(["ratelimited", "request_timeout"])( + "treats documented transient Slack API error %s as indeterminate", + (error) => { + vi.mocked(runCurlProbe).mockReturnValue(probe(JSON.stringify({ ok: false, error }))); + + const result = validateSlackBotToken("xoxb-transient-bot"); + + expect(result).toMatchObject({ ok: false, kind: "indeterminate", error }); + }, + ); + + it("does not treat undocumented Slack API errors as transient based only on the error body", () => { + vi.mocked(runCurlProbe).mockReturnValue( + probe('{"ok":false,"error":"internal_error"}'), + ); + + const result = validateSlackBotToken("xoxb-internal-error"); + + expect(result).toMatchObject({ ok: false, kind: "rejected", error: "internal_error" }); + }); + + it("treats unreadable Slack API responses as indeterminate", () => { + vi.mocked(runCurlProbe).mockReturnValue(probe("not json")); + + expect(validateSlackBotToken("xoxb-valid-looking")).toMatchObject({ + ok: false, + kind: "indeterminate", + tokenKind: "bot", + }); + }); + + it("treats network failures as indeterminate without leaking token material", () => { + const token = "xoxb-sensitive-token-value"; + vi.mocked(runCurlProbe).mockReturnValue( + probe("", { + ok: false, + httpStatus: 0, + curlStatus: 28, + stderr: `timeout while using ${token}`, + message: `curl failed while using ${token}`, + }), + ); + + const result = validateSlackBotToken(token); + + expect(result).toMatchObject({ ok: false, kind: "indeterminate", tokenKind: "bot" }); + if (!result.ok) expect(result.message).not.toContain(token); + }); + + it("drops Slack selections when live validation is indeterminate", () => { + process.env.SLACK_BOT_TOKEN = "xoxb-timeout-bot"; + process.env.SLACK_APP_TOKEN = "xapp-timeout-app"; + vi.mocked(runCurlProbe).mockReturnValue( + probe("", { + ok: false, + httpStatus: 0, + curlStatus: 28, + stderr: "operation timed out", + message: "curl failed (exit 28): operation timed out", + }), + ); + const warnings: string[] = []; + + const result = filterSlackSelectionByValidation( + ["telegram", "slack"], + [KNOWN_CHANNELS.slack], + (message) => warnings.push(message), + ); + + expect(result).toEqual(["telegram"]); + expect(warnings.join("\n")).toContain("Slack integration will be disabled"); + expect(warnings.join("\n")).not.toContain("xoxb-timeout-bot"); + }); + + it("keeps Slack selected in explicit skip mode without probing Slack", () => { + process.env.SLACK_BOT_TOKEN = "xoxb-offline-bot"; + process.env.SLACK_APP_TOKEN = "xapp-offline-app"; + process.env.NEMOCLAW_SKIP_SLACK_AUTH_VALIDATION = "1"; + vi.mocked(runCurlProbe).mockReturnValue(probe('{"ok":false,"error":"invalid_auth"}')); + const warnings: string[] = []; + + const result = filterSlackSelectionByValidation( + ["telegram", "slack"], + [KNOWN_CHANNELS.slack], + (message) => warnings.push(message), + ); + + expect(result).toEqual(["telegram", "slack"]); + expect(vi.mocked(runCurlProbe)).not.toHaveBeenCalled(); + expect(warnings.join("\n")).toContain("Live Slack API validation skipped"); + }); +}); diff --git a/src/lib/onboard/slack-validation.ts b/src/lib/onboard/slack-validation.ts new file mode 100644 index 0000000000..0158f2c0fe --- /dev/null +++ b/src/lib/onboard/slack-validation.ts @@ -0,0 +1,270 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { runCurlProbe, type CurlProbeResult } from "../adapters/http/probe"; +import type { ChannelDef } from "../sandbox/channels"; +import { getValidatedMessagingTokenByEnvKey } from "./messaging-token"; + +export type SlackTokenKind = "bot" | "app"; +export type SlackValidationFailureKind = "rejected" | "indeterminate"; + +export type SlackTokenValidationResult = + | { ok: true; skipped?: boolean; message?: string } + | { + ok: false; + kind: SlackValidationFailureKind; + tokenKind: SlackTokenKind; + error?: string; + httpStatus: number; + curlStatus: number; + message: string; + }; + +export type SlackCredentialValidationResult = + | Extract + | (Exclude & { credential: SlackTokenKind }); + +const SLACK_AUTH_TEST_URL = "https://slack.com/api/auth.test"; +const SLACK_APPS_CONNECTIONS_OPEN_URL = "https://slack.com/api/apps.connections.open"; +export const SLACK_AUTH_VALIDATION_SKIP_ENV = "NEMOCLAW_SKIP_SLACK_AUTH_VALIDATION"; + +const TRANSIENT_SLACK_ERRORS = new Set(["ratelimited", "request_timeout"]); + +const SLACK_CURL_CONFIG_PREFIX = "nemoclaw-slack-probe"; + +function isTruthyEnvFlag(value: string | undefined): boolean { + return value === "1" || value === "true" || value === "yes" || value === "on"; +} + +export function shouldSkipSlackAuthValidation( + env: Record = process.env, +): boolean { + return isTruthyEnvFlag(env[SLACK_AUTH_VALIDATION_SKIP_ENV]); +} + +function skippedSlackValidationResult(): Extract { + return { + ok: true, + skipped: true, + message: `Live Slack API validation skipped because ${SLACK_AUTH_VALIDATION_SKIP_ENV} is set.`, + }; +} + +function escapeCurlConfigValue(value: string): string { + return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +} + +function slackCurlConfig(token: string): string { + const authorization = escapeCurlConfigValue(`Authorization: Bearer ${token}`); + return [ + `header = "${authorization}"`, + 'header = "Content-Type: application/x-www-form-urlencoded"', + "", + ].join("\n"); +} + +function writeSlackCurlConfig(token: string): { configPath: string; cleanup: () => void } { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), `${SLACK_CURL_CONFIG_PREFIX}-`)); + const configPath = path.join(dir, "curl.conf"); + try { + fs.writeFileSync(configPath, slackCurlConfig(token), { mode: 0o600 }); + } catch (error) { + fs.rmSync(dir, { recursive: true, force: true }); + throw error; + } + return { + configPath, + cleanup: () => fs.rmSync(dir, { recursive: true, force: true }), + }; +} + +function slackApiArgs(configPath: string, url: string): string[] { + return [ + "-sS", + "--connect-timeout", + "5", + "--max-time", + "10", + "-X", + "POST", + "--config", + configPath, + "--data", + "", + url, + ]; +} + +function runSlackApiProbe(token: string, url: string): CurlProbeResult { + const { configPath, cleanup } = writeSlackCurlConfig(token); + try { + return runCurlProbe(slackApiArgs(configPath, url)); + } finally { + cleanup(); + } +} + +function redactToken(text: string, token: string): string { + return token ? text.split(token).join("") : text; +} + +function slackLabel(tokenKind: SlackTokenKind): string { + return tokenKind === "bot" ? "Slack bot token" : "Slack app token"; +} + +function parseSlackApiResponse(body: string): { ok?: unknown; error?: unknown } | null { + try { + const parsed = JSON.parse(body); + return parsed && typeof parsed === "object" ? parsed : null; + } catch { + return null; + } +} + +function validationFailure( + tokenKind: SlackTokenKind, + kind: SlackValidationFailureKind, + result: CurlProbeResult, + message: string, + token: string, + error?: string, +): Exclude { + return { + ok: false, + kind, + tokenKind, + error, + httpStatus: result.httpStatus, + curlStatus: result.curlStatus, + message: redactToken(message, token), + }; +} + +function classifySlackProbeResult( + tokenKind: SlackTokenKind, + token: string, + result: CurlProbeResult, +): SlackTokenValidationResult { + const label = slackLabel(tokenKind); + // Transport failures, unreadable bodies, and documented transient Slack errors + // are outside NemoClaw's credential source of truth, so callers fail closed + // without saving or enabling unvalidated Slack credentials. + if (result.curlStatus !== 0 || result.httpStatus === 0) { + return validationFailure( + tokenKind, + "indeterminate", + result, + `${label} could not be validated because Slack API was unreachable: ${result.message}`, + token, + ); + } + + const parsed = parseSlackApiResponse(result.body); + if (!parsed) { + return validationFailure( + tokenKind, + "indeterminate", + result, + `${label} could not be validated because Slack API returned an unreadable response.`, + token, + ); + } + + if (parsed.ok === true) return { ok: true }; + + const error = typeof parsed.error === "string" ? parsed.error : "unknown_error"; + if (result.httpStatus === 429 || result.httpStatus >= 500 || TRANSIENT_SLACK_ERRORS.has(error)) { + return validationFailure( + tokenKind, + "indeterminate", + result, + `${label} could not be validated because Slack API returned ${error}.`, + token, + error, + ); + } + + return validationFailure( + tokenKind, + "rejected", + result, + `${label} was rejected by Slack API: ${error}.`, + token, + error, + ); +} + +export function validateSlackBotToken(token: string): SlackTokenValidationResult { + if (shouldSkipSlackAuthValidation()) return skippedSlackValidationResult(); + + return classifySlackProbeResult( + "bot", + token, + runSlackApiProbe(token, SLACK_AUTH_TEST_URL), + ); +} + +export function validateSlackAppToken(token: string): SlackTokenValidationResult { + if (shouldSkipSlackAuthValidation()) return skippedSlackValidationResult(); + + return classifySlackProbeResult( + "app", + token, + runSlackApiProbe(token, SLACK_APPS_CONNECTIONS_OPEN_URL), + ); +} + +export function validateSlackCredentials(tokens: { + botToken: string; + appToken: string; +}): SlackCredentialValidationResult { + if (shouldSkipSlackAuthValidation()) return skippedSlackValidationResult(); + + const bot = validateSlackBotToken(tokens.botToken); + if (!bot.ok) return { ...bot, credential: "bot" }; + + const app = validateSlackAppToken(tokens.appToken); + if (!app.ok) return { ...app, credential: "app" }; + + return { ok: true }; +} + +export function formatSlackValidationFailure( + result: Exclude, +): string { + return result.message; +} + +export function filterSlackSelectionByValidation( + found: string[], + channels: readonly ChannelDef[], + warn: (message: string) => void = console.warn, +): string[] { + if (!found.includes("slack")) return found; + + const botToken = getValidatedMessagingTokenByEnvKey(channels, "SLACK_BOT_TOKEN"); + const appToken = getValidatedMessagingTokenByEnvKey(channels, "SLACK_APP_TOKEN"); + if (!botToken || !appToken) { + warn( + " Slack integration will be disabled for this onboard run because both SLACK_BOT_TOKEN and SLACK_APP_TOKEN are required.", + ); + return found.filter((channel) => channel !== "slack"); + } + + const validation = validateSlackCredentials({ botToken, appToken }); + if (validation.ok) { + if (validation.skipped && validation.message) { + warn(` ${validation.message}`); + } + return found; + } + + warn( + ` Slack integration will be disabled for this onboard run. ${formatSlackValidationFailure(validation)}`, + ); + return found.filter((channel) => channel !== "slack"); +} diff --git a/test/channels-add-preset.test.ts b/test/channels-add-preset.test.ts index 82d8a78175..f1d26fe77f 100644 --- a/test/channels-add-preset.test.ts +++ b/test/channels-add-preset.test.ts @@ -26,9 +26,10 @@ function runScript(scriptBody: string, extraEnv: Record = {}): S ...process.env, HOME: tmpDir, NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_SKIP_SLACK_AUTH_VALIDATION: "", TELEGRAM_BOT_TOKEN: "test-telegram-token", - SLACK_BOT_TOKEN: "slack-bot-token-for-test", - SLACK_APP_TOKEN: "slack-app-token-for-test", + SLACK_BOT_TOKEN: "xoxb-slack-bot-token-for-test", + SLACK_APP_TOKEN: "xapp-slack-app-token-for-test", DISCORD_BOT_TOKEN: "test-discord-token", ...extraEnv, }, @@ -86,8 +87,13 @@ const gatewayRuntime = require(${j("gateway-runtime-action.js")}); gatewayRuntime.recoverNamedGatewayRuntime = async () => ({ recovered: true }); const credentials = require(${j("credentials/store.js")}); +const credentialSaveCalls = []; credentials.getCredential = (key) => process.env[key] || null; -credentials.saveCredential = () => true; +credentials.saveCredential = (key, value) => { + credentialSaveCalls.push({ key, value }); + callOrder.push("saveCredential:" + key); + return true; +}; credentials.deleteCredential = () => true; credentials.prompt = async (msg) => { throw new Error("unexpected prompt: " + msg); }; @@ -96,7 +102,10 @@ onboard.isNonInteractive = () => true; const onboardProviders = require(${j("onboard/providers.js")}); const providerCalls = []; -onboardProviders.upsertMessagingProviders = (defs) => { providerCalls.push(...defs); }; +onboardProviders.upsertMessagingProviders = (defs) => { + providerCalls.push(...defs); + callOrder.push("upsertMessagingProviders"); +}; const registry = require(${j("state/registry.js")}); const registryUpdates = []; @@ -129,6 +138,27 @@ policies.removePreset = (sandboxName, presetName) => { }; policies.getAppliedPresets = () => ${JSON.stringify(appliedPresets)}; +const httpProbe = require(${j("adapters/http/probe.js")}); +const slackProbeCalls = []; +const slackProbeOk = (body = '{"ok":true}') => ({ + ok: true, + httpStatus: 200, + curlStatus: 0, + body, + stderr: "", + message: "", +}); +httpProbe.runCurlProbe = (argv) => { + const url = argv[argv.length - 1]; + if (typeof url === "string" && url.includes("slack.com/api/")) { + slackProbeCalls.push(argv); + callOrder.push(url.includes("auth.test") ? "slackProbe:bot" : "slackProbe:app"); + if (url.includes("auth.test")) return global.__slackBotProbe || slackProbeOk(); + if (url.includes("apps.connections.open")) return global.__slackAppProbe || slackProbeOk('{"ok":true,"url":"wss://wss-primary.slack.com/link"}'); + } + return slackProbeOk(); +}; + // Stub onboardSession so the new policyPresets-sync helper has something // to read/write. The test asserts on sessionUpdates to verify the // helper kept session.policyPresets aligned with the registry. @@ -177,7 +207,18 @@ console.log = (...args) => { const channelModule = require(${j("actions/sandbox/policy-channel.js")}); -module.exports = { channelModule, appliedCalls, removedCalls, callOrder, providerCalls, registryUpdates, sessionUpdates, getSessionState: () => sessionState }; +module.exports = { + channelModule, + appliedCalls, + removedCalls, + callOrder, + providerCalls, + registryUpdates, + sessionUpdates, + credentialSaveCalls, + slackProbeCalls, + getSessionState: () => sessionState, +}; `; } @@ -376,6 +417,231 @@ const ctx = module.exports; `expected promptAndRebuild to still run; got order: ${JSON.stringify(payload.callOrder)}`, ); }); + + it("validates Slack credentials before persisting tokens or registering providers", () => { + const script = `${buildPreamble()} +const ctx = module.exports; +(async () => { + try { + await ctx.channelModule.addSandboxChannel("test-sb", { channel: "slack" }); + process.stdout.write("\\n__RESULT__" + JSON.stringify({ + slackProbeCalls: ctx.slackProbeCalls, + credentialSaveCalls: ctx.credentialSaveCalls, + providerCalls: ctx.providerCalls, + callOrder: ctx.callOrder, + }) + "\\n"); + } catch (err) { + process.stdout.write("\\n__RESULT__" + JSON.stringify({ error: err.message, stack: err.stack }) + "\\n"); + } +})(); +`; + const result = runScript(script); + assert.equal(result.status, 0, `script failed: ${result.stderr}\n${result.stdout}`); + const marker = result.stdout.lastIndexOf("__RESULT__"); + assert.ok(marker >= 0, `no __RESULT__ marker in stdout:\n${result.stdout}`); + const payload = JSON.parse(result.stdout.slice(marker + "__RESULT__".length).trim()); + assert.ok(!payload.error, `unexpected error: ${payload.error}\n${payload.stack || ""}`); + + assert.equal(payload.slackProbeCalls.length, 2, "expected bot and app Slack probes"); + assert.ok( + payload.slackProbeCalls[0].includes("https://slack.com/api/auth.test"), + `expected auth.test first; got ${JSON.stringify(payload.slackProbeCalls)}`, + ); + assert.ok( + payload.slackProbeCalls[1].includes("https://slack.com/api/apps.connections.open"), + `expected apps.connections.open second; got ${JSON.stringify(payload.slackProbeCalls)}`, + ); + assert.deepEqual( + payload.credentialSaveCalls.map((call: { key: string }) => call.key), + ["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"], + ); + assert.deepEqual( + payload.providerCalls.map((call: { envKey: string }) => call.envKey), + ["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"], + ); + assert.ok( + payload.callOrder.indexOf("slackProbe:app") < + payload.callOrder.indexOf("saveCredential:SLACK_BOT_TOKEN"), + `Slack validation must complete before token persistence; got ${JSON.stringify(payload.callOrder)}`, + ); + assert.ok( + payload.callOrder.indexOf("saveCredential:SLACK_APP_TOKEN") < + payload.callOrder.indexOf("upsertMessagingProviders"), + `token persistence should happen before provider registration; got ${JSON.stringify(payload.callOrder)}`, + ); + }); + + it("can explicitly skip live Slack validation for offline channel add", () => { + const script = `${buildPreamble()} +const ctx = module.exports; +global.__slackBotProbe = { + ok: true, + httpStatus: 200, + curlStatus: 0, + body: '{"ok":false,"error":"invalid_auth"}', + stderr: "", + message: "", +}; +(async () => { + try { + await ctx.channelModule.addSandboxChannel("test-sb", { channel: "slack" }); + process.stdout.write("\\n__RESULT__" + JSON.stringify({ + slackProbeCalls: ctx.slackProbeCalls, + credentialSaveCalls: ctx.credentialSaveCalls, + providerCalls: ctx.providerCalls, + callOrder: ctx.callOrder, + }) + "\\n"); + } catch (err) { + process.stdout.write("\\n__RESULT__" + JSON.stringify({ error: err.message, stack: err.stack }) + "\\n"); + } +})(); +`; + const result = runScript(script, { NEMOCLAW_SKIP_SLACK_AUTH_VALIDATION: "1" }); + assert.equal(result.status, 0, `script failed: ${result.stderr}\n${result.stdout}`); + const marker = result.stdout.lastIndexOf("__RESULT__"); + assert.ok(marker >= 0, `no __RESULT__ marker in stdout:\n${result.stdout}`); + const payload = JSON.parse(result.stdout.slice(marker + "__RESULT__".length).trim()); + assert.ok(!payload.error, `unexpected error: ${payload.error}\n${payload.stack || ""}`); + + assert.deepEqual(payload.slackProbeCalls, []); + assert.deepEqual( + payload.credentialSaveCalls.map((call: { key: string }) => call.key), + ["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"], + ); + assert.deepEqual( + payload.providerCalls.map((call: { envKey: string }) => call.envKey), + ["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"], + ); + assert.ok( + !payload.callOrder.some((entry: string) => entry.startsWith("slackProbe:")), + `offline skip mode must not probe Slack; got ${JSON.stringify(payload.callOrder)}`, + ); + assert.ok( + payload.callOrder.indexOf("saveCredential:SLACK_APP_TOKEN") < + payload.callOrder.indexOf("upsertMessagingProviders"), + `token persistence should happen before provider registration; got ${JSON.stringify(payload.callOrder)}`, + ); + }); + + it("aborts Slack channel add on rejected Slack API validation before persistence or registration", () => { + const script = `${buildPreamble()} +const ctx = module.exports; +global.__slackBotProbe = { + ok: true, + httpStatus: 200, + curlStatus: 0, + body: '{"ok":false,"error":"invalid_auth"}', + stderr: "", + message: "", +}; +const exitCodes = []; +const originalExit = process.exit; +process.exit = (code) => { + exitCodes.push(code ?? 0); + throw new Error("__EXIT__" + (code ?? 0)); +}; +(async () => { + try { + await ctx.channelModule.addSandboxChannel("test-sb", { channel: "slack" }); + } catch (err) { + if (!String(err && err.message).startsWith("__EXIT__")) { + process.stdout.write("\\n__RESULT__" + JSON.stringify({ error: err.message, stack: err.stack }) + "\\n"); + return; + } + } finally { + process.exit = originalExit; + } + process.stdout.write("\\n__RESULT__" + JSON.stringify({ + exitCodes, + credentialSaveCalls: ctx.credentialSaveCalls, + providerCalls: ctx.providerCalls, + registryUpdates: ctx.registryUpdates, + appliedCalls: ctx.appliedCalls, + callOrder: ctx.callOrder, + }) + "\\n"); +})(); +`; + const result = runScript(script); + assert.equal(result.status, 0, `script failed: ${result.stderr}\n${result.stdout}`); + const marker = result.stdout.lastIndexOf("__RESULT__"); + assert.ok(marker >= 0, `no __RESULT__ marker in stdout:\n${result.stdout}`); + const payload = JSON.parse(result.stdout.slice(marker + "__RESULT__".length).trim()); + assert.ok(!payload.error, `unexpected error: ${payload.error}\n${payload.stack || ""}`); + + assert.deepEqual(payload.exitCodes, [1]); + assert.deepEqual(payload.credentialSaveCalls, []); + assert.deepEqual(payload.providerCalls, []); + assert.deepEqual(payload.registryUpdates, []); + assert.deepEqual(payload.appliedCalls, []); + assert.ok( + !payload.callOrder.some((entry: string) => entry.startsWith("saveCredential:")), + `rejected Slack credentials must not be persisted; got ${JSON.stringify(payload.callOrder)}`, + ); + assert.ok( + !payload.callOrder.includes("upsertMessagingProviders"), + `rejected Slack credentials must not register providers; got ${JSON.stringify(payload.callOrder)}`, + ); + }); + + it("aborts Slack channel add on indeterminate Slack API validation before persistence or registration", () => { + const script = `${buildPreamble()} +const ctx = module.exports; +global.__slackBotProbe = { + ok: false, + httpStatus: 0, + curlStatus: 28, + body: "", + stderr: "operation timed out", + message: "curl failed (exit 28): operation timed out", +}; +const exitCodes = []; +const originalExit = process.exit; +process.exit = (code) => { + exitCodes.push(code ?? 0); + throw new Error("__EXIT__" + (code ?? 0)); +}; +(async () => { + try { + await ctx.channelModule.addSandboxChannel("test-sb", { channel: "slack" }); + } catch (err) { + if (!String(err && err.message).startsWith("__EXIT__")) { + process.stdout.write("\\n__RESULT__" + JSON.stringify({ error: err.message, stack: err.stack }) + "\\n"); + return; + } + } finally { + process.exit = originalExit; + } + process.stdout.write("\\n__RESULT__" + JSON.stringify({ + exitCodes, + credentialSaveCalls: ctx.credentialSaveCalls, + providerCalls: ctx.providerCalls, + registryUpdates: ctx.registryUpdates, + appliedCalls: ctx.appliedCalls, + callOrder: ctx.callOrder, + }) + "\\n"); +})(); +`; + const result = runScript(script); + assert.equal(result.status, 0, `script failed: ${result.stderr}\n${result.stdout}`); + const marker = result.stdout.lastIndexOf("__RESULT__"); + assert.ok(marker >= 0, `no __RESULT__ marker in stdout:\n${result.stdout}`); + const payload = JSON.parse(result.stdout.slice(marker + "__RESULT__".length).trim()); + assert.ok(!payload.error, `unexpected error: ${payload.error}\n${payload.stack || ""}`); + + assert.deepEqual(payload.exitCodes, [1]); + assert.deepEqual(payload.credentialSaveCalls, []); + assert.deepEqual(payload.providerCalls, []); + assert.deepEqual(payload.registryUpdates, []); + assert.deepEqual(payload.appliedCalls, []); + assert.ok( + !payload.callOrder.some((entry: string) => entry.startsWith("saveCredential:")), + `indeterminate Slack credentials must not be persisted; got ${JSON.stringify(payload.callOrder)}`, + ); + assert.ok( + !payload.callOrder.includes("upsertMessagingProviders"), + `indeterminate Slack credentials must not register providers; got ${JSON.stringify(payload.callOrder)}`, + ); + }); }); // Regression: `channels add` was updating the registry but NOT diff --git a/test/e2e/test-channels-stop-start.sh b/test/e2e/test-channels-stop-start.sh index 3d5e4f62d0..aeec0697cb 100755 --- a/test/e2e/test-channels-stop-start.sh +++ b/test/e2e/test-channels-stop-start.sh @@ -375,6 +375,12 @@ is_fake_telegram_token() { *) return 1 ;; esac } +is_fake_slack_token() { + case "${1:-}" in + xoxb-fake-* | xoxb-test-* | xapp-fake-* | xapp-test-*) return 0 ;; + *) return 1 ;; + esac +} export_fake_channel_env() { local suffix="$1" @@ -432,6 +438,13 @@ install_for_active_agent() { export NEMOCLAW_SKIP_TELEGRAM_REACHABILITY=1 info "Skipping onboarding Telegram reachability probe for fake-token E2E" fi + if [ -z "${NEMOCLAW_SKIP_SLACK_AUTH_VALIDATION:-}" ] \ + && { is_fake_slack_token "$SLACK_BOT_TOKEN" || is_fake_slack_token "$SLACK_APP_TOKEN"; }; then + # This E2E normally uses fake Slack tokens to exercise channel lifecycle + # plumbing, not the live Slack API. + export NEMOCLAW_SKIP_SLACK_AUTH_VALIDATION=1 + info "Skipping onboarding Slack auth validation for fake-token E2E" + fi info "Running install.sh --non-interactive for ${ACTIVE_AGENT} (${ACTIVE_SANDBOX})..." bash install.sh --non-interactive >"$log" 2>&1 & diff --git a/test/e2e/test-hermes-slack-e2e.sh b/test/e2e/test-hermes-slack-e2e.sh index 808de20b6a..4815d3f1b8 100755 --- a/test/e2e/test-hermes-slack-e2e.sh +++ b/test/e2e/test-hermes-slack-e2e.sh @@ -52,6 +52,12 @@ section() { printf '\033[1;36m=== %s ===\033[0m\n' "$1" } info() { printf '\033[1;34m [info]\033[0m %s\n' "$1"; } +is_fake_slack_token() { + case "${1:-}" in + xoxb-fake-* | xoxb-test-* | xapp-fake-* | xapp-test-*) return 0 ;; + *) return 1 ;; + esac +} run_with_timeout() { local seconds="$1" @@ -159,6 +165,11 @@ export NEMOCLAW_SANDBOX_NAME="$SANDBOX_NAME" export NEMOCLAW_RECREATE_SANDBOX=1 export SLACK_BOT_TOKEN="$SLACK_BOT" export SLACK_APP_TOKEN="$SLACK_APP" +if [ -z "${NEMOCLAW_SKIP_SLACK_AUTH_VALIDATION:-}" ] \ + && { is_fake_slack_token "$SLACK_BOT" || is_fake_slack_token "$SLACK_APP"; }; then + export NEMOCLAW_SKIP_SLACK_AUTH_VALIDATION=1 + info "Skipping onboarding Slack auth validation for fake-token E2E" +fi # shellcheck source=test/e2e/lib/sandbox-teardown.sh . "$(dirname "${BASH_SOURCE[0]}")/lib/sandbox-teardown.sh" diff --git a/test/e2e/test-messaging-providers.sh b/test/e2e/test-messaging-providers.sh index af3c729f8b..7cd9157651 100755 --- a/test/e2e/test-messaging-providers.sh +++ b/test/e2e/test-messaging-providers.sh @@ -111,6 +111,12 @@ section() { printf '\033[1;36m=== %s ===\033[0m\n' "$1" } info() { printf '\033[1;34m [info]\033[0m %s\n' "$1"; } +is_fake_slack_token() { + case "${1:-}" in + xoxb-fake-* | xoxb-test-* | xapp-fake-* | xapp-test-*) return 0 ;; + *) return 1 ;; + esac +} is_unresolved_placeholder_rejection() { printf '%s\n' "$1" | grep -qiE 'credential_injection_failed|unresolved credential placeholder' } @@ -680,6 +686,15 @@ if [ -z "${NEMOCLAW_SKIP_TELEGRAM_REACHABILITY:-}" ] \ export NEMOCLAW_SKIP_TELEGRAM_REACHABILITY=1 info "Skipping onboarding Telegram reachability probe for fake-token E2E" fi +if [ -z "${NEMOCLAW_SKIP_SLACK_AUTH_VALIDATION:-}" ] \ + && [ -z "${SLACK_BOT_TOKEN_REAL:-}" ] \ + && [ -z "${SLACK_APP_TOKEN_REAL:-}" ] \ + && { is_fake_slack_token "$SLACK_TOKEN" || is_fake_slack_token "$SLACK_APP"; }; then + # This E2E uses fake Slack tokens to prove placeholder/proxy behavior against + # the hermetic fake Slack API. Keep real-token runs on the live validation path. + export NEMOCLAW_SKIP_SLACK_AUTH_VALIDATION=1 + info "Skipping onboarding Slack auth validation for fake-token E2E" +fi # Pre-merge Slack policy into the base sandbox policy. # diff --git a/test/e2e/test-openclaw-slack-pairing.sh b/test/e2e/test-openclaw-slack-pairing.sh index d1a83bee91..eb183d42be 100755 --- a/test/e2e/test-openclaw-slack-pairing.sh +++ b/test/e2e/test-openclaw-slack-pairing.sh @@ -56,6 +56,12 @@ section() { printf '\033[1;36m=== %s ===\033[0m\n' "$1" } info() { printf '\033[1;34m [info]\033[0m %s\n' "$1"; } +is_fake_slack_token() { + case "${1:-}" in + xoxb-fake-* | xoxb-test-* | xapp-fake-* | xapp-test-*) return 0 ;; + *) return 1 ;; + esac +} run_with_timeout() { local seconds="$1" @@ -90,6 +96,11 @@ export NEMOCLAW_FRESH=1 export NEMOCLAW_POLICY_TIER="${NEMOCLAW_POLICY_TIER:-open}" export SLACK_BOT_TOKEN="$SLACK_TOKEN" export SLACK_APP_TOKEN="$SLACK_APP" +if [ -z "${NEMOCLAW_SKIP_SLACK_AUTH_VALIDATION:-}" ] \ + && { is_fake_slack_token "$SLACK_TOKEN" || is_fake_slack_token "$SLACK_APP"; }; then + export NEMOCLAW_SKIP_SLACK_AUTH_VALIDATION=1 + info "Skipping onboarding Slack auth validation for fake-token E2E" +fi openshell() { if [ "$OPENSHELL_BIN" = "openshell" ]; then diff --git a/test/e2e/test-token-rotation.sh b/test/e2e/test-token-rotation.sh index aab7f0b7a9..bf22eb4998 100755 --- a/test/e2e/test-token-rotation.sh +++ b/test/e2e/test-token-rotation.sh @@ -156,6 +156,12 @@ is_fake_telegram_token() { *) return 1 ;; esac } +is_fake_slack_token() { + case "${1:-}" in + xoxb-fake-* | xoxb-test-* | xapp-fake-* | xapp-test-*) return 0 ;; + *) return 1 ;; + esac +} # ── Phase 0: Install NemoClaw with token A ──────────────────────── @@ -172,6 +178,13 @@ if [ -z "${NEMOCLAW_SKIP_TELEGRAM_REACHABILITY:-}" ] \ export NEMOCLAW_SKIP_TELEGRAM_REACHABILITY=1 info "Skipping onboarding Telegram reachability probe for fake-token E2E" fi +if [ -z "${NEMOCLAW_SKIP_SLACK_AUTH_VALIDATION:-}" ] \ + && { is_fake_slack_token "$SLACK_BOT_TOKEN_A" || is_fake_slack_token "$SLACK_BOT_TOKEN_B" || is_fake_slack_token "$SLACK_APP_TOKEN_A" || is_fake_slack_token "$SLACK_APP_TOKEN_B"; }; then + # This E2E normally uses fake Slack tokens to exercise rotation plumbing, not + # the live Slack API. + export NEMOCLAW_SKIP_SLACK_AUTH_VALIDATION=1 + info "Skipping onboarding Slack auth validation for fake-token E2E" +fi export TELEGRAM_BOT_TOKEN="$TELEGRAM_BOT_TOKEN_A" export DISCORD_BOT_TOKEN="$DISCORD_BOT_TOKEN_A" diff --git a/test/onboard-messaging.test.ts b/test/onboard-messaging.test.ts index 7056e9a75f..3cce1579ee 100644 --- a/test/onboard-messaging.test.ts +++ b/test/onboard-messaging.test.ts @@ -1598,14 +1598,20 @@ runner.runCapture = () => ""; // call — on networks where api.telegram.org is blocked, the non-interactive // preflight would otherwise abort the test. const httpProbe = require(${httpProbePath}); -httpProbe.runCurlProbe = () => ({ - ok: true, - httpStatus: 200, - curlStatus: 0, - body: '{"ok":true,"result":{"id":1,"is_bot":true}}', - stderr: "", - message: "", -}); +httpProbe.runCurlProbe = (argv) => { + const url = String(argv[argv.length - 1] || ""); + if (url.includes("slack.com/api/")) { + throw new Error("Slack live auth probe should be skipped in this offline test"); + } + return { + ok: true, + httpStatus: 200, + curlStatus: 0, + body: '{"ok":true,"result":{"id":1,"is_bot":true}}', + stderr: "", + message: "", + }; +}; const { setupMessagingChannels } = require(${onboardPath}); @@ -1613,6 +1619,8 @@ const { setupMessagingChannels } = require(${onboardPath}); // Only set telegram and slack tokens — discord should be absent process.env.TELEGRAM_BOT_TOKEN = "123456:ABC-test-telegram-token"; process.env.SLACK_BOT_TOKEN = "xoxb-test-slack-token"; + process.env.SLACK_APP_TOKEN = "xapp-test-slack-app-token"; + process.env.NEMOCLAW_SKIP_SLACK_AUTH_VALIDATION = "1"; const result = await setupMessagingChannels(); console.log(JSON.stringify(result)); })().catch((error) => { @@ -1644,6 +1652,91 @@ const { setupMessagingChannels } = require(${onboardPath}); }, ); + it( + "non-interactive setupMessagingChannels drops Slack when live Slack API validation rejects the token", + { timeout: 60_000 }, + async () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-onboard-messaging-slack-live-reject-"), + ); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "messaging-slack-live-reject.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "onboard.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "runner.js")); + const httpProbePath = JSON.stringify(path.join(repoRoot, "dist", "lib", "adapters", "http", "probe.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); + + const script = String.raw` +const runner = require(${runnerPath}); +const _n = (c) => (Array.isArray(c) ? c.join(" ") : String(c)).replace(/'/g, ""); +runner.run = () => ({ status: 0 }); +runner.runCapture = () => ""; + +const httpProbe = require(${httpProbePath}); +httpProbe.runCurlProbe = (argv) => { + const url = argv[argv.length - 1] || ""; + if (String(url).includes("auth.test")) { + return { + ok: true, + httpStatus: 200, + curlStatus: 0, + body: '{"ok":false,"error":"invalid_auth"}', + stderr: "", + message: "", + }; + } + return { + ok: true, + httpStatus: 200, + curlStatus: 0, + body: '{"ok":true}', + stderr: "", + message: "", + }; +}; + +const { setupMessagingChannels } = require(${onboardPath}); + +(async () => { + delete process.env.TELEGRAM_BOT_TOKEN; + delete process.env.DISCORD_BOT_TOKEN; + process.env.SLACK_BOT_TOKEN = "xoxb-fake-bot-token"; + process.env.SLACK_APP_TOKEN = "xapp-fake-app-token"; + const result = await setupMessagingChannels(); + console.log(JSON.stringify(result)); +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + NEMOCLAW_NON_INTERACTIVE: "1", + }, + }); + + assert.equal(result.status, 0, result.stderr); + const channels = parseStdoutJson(result.stdout); + + assert.ok(Array.isArray(channels), "expected an array return value"); + assert.ok(!channels.includes("slack"), "Slack should be dropped after API rejection"); + assert.doesNotMatch(result.stdout, /xoxb-fake-bot-token/); + assert.doesNotMatch(result.stderr, /xoxb-fake-bot-token/); + }, + ); + it( "non-interactive setupMessagingChannels returns empty array when no tokens set", { timeout: 60_000 }, @@ -1912,12 +2005,9 @@ const { setupMessagingChannels, MESSAGING_CHANNELS } = require(${onboardPath}); !out.result.includes("slack"), `slack should have been dropped after invalid app token; got ${JSON.stringify(out.result)}`, ); - // Bot token is persisted before the app-token prompt — that's fine, the - // user can retry later and the pre-saved bot token will light up as - // "already configured" on the next onboard. assert.ok( - out.saveCalls.some((c: { key: string }) => c.key === "SLACK_BOT_TOKEN"), - `SLACK_BOT_TOKEN should have been persisted (valid format); saveCalls=${JSON.stringify(out.saveCalls)}`, + !out.saveCalls.some((c: { key: string }) => c.key === "SLACK_BOT_TOKEN"), + `SLACK_BOT_TOKEN should NOT be persisted until the app token also passes; saveCalls=${JSON.stringify(out.saveCalls)}`, ); assert.ok( !out.saveCalls.some((c: { key: string }) => c.key === "SLACK_APP_TOKEN"),