diff --git a/.agents/skills/nemoclaw-user-manage-sandboxes/references/messaging-channels.md b/.agents/skills/nemoclaw-user-manage-sandboxes/references/messaging-channels.md index d1df4673c3a..a6756916456 100644 --- a/.agents/skills/nemoclaw-user-manage-sandboxes/references/messaging-channels.md +++ b/.agents/skills/nemoclaw-user-manage-sandboxes/references/messaging-channels.md @@ -25,7 +25,7 @@ For details, refer to Commands (use the `nemoclaw-user-reference` skill). |---------|-----------------|-------------------| | Telegram | `TELEGRAM_BOT_TOKEN` | `TELEGRAM_ALLOWED_IDS` for DM allowlisting, `TELEGRAM_REQUIRE_MENTION` for group-chat replies | | Discord | `DISCORD_BOT_TOKEN` | `DISCORD_SERVER_ID`, `DISCORD_USER_ID`, `DISCORD_REQUIRE_MENTION` | -| Slack | `SLACK_BOT_TOKEN`, `SLACK_APP_TOKEN` | `SLACK_ALLOWED_USERS` for DM and channel `@mention` user allowlisting | +| Slack | `SLACK_BOT_TOKEN`, `SLACK_APP_TOKEN` | `SLACK_ALLOWED_USERS` for DM and channel `@mention` user allowlisting, `SLACK_ALLOWED_CHANNELS` for channel ID allowlisting | | WhatsApp | None. Pair via QR after rebuild | None | Telegram uses a bot token from [BotFather](https://t.me/BotFather). @@ -44,6 +44,8 @@ Set `DISCORD_USER_ID` to restrict access to one user; otherwise, any member of t Slack uses Socket Mode and requires two tokens. Use `SLACK_BOT_TOKEN` for the bot user OAuth token (`xoxb-...`) and `SLACK_APP_TOKEN` for the app-level Socket Mode token (`xapp-...`). Set `SLACK_ALLOWED_USERS` to comma-separated Slack member IDs to authorize those users for DMs and for channel `@mention` events in channels where the Slack app is present. +Set `SLACK_ALLOWED_CHANNELS` to comma-separated Slack channel IDs to restrict channel `@mention` handling to those channels. +When both Slack allowlists are set, NemoClaw requires the mention to come from one of the allowed channels and one of the allowed members. Channel messages still require an explicit bot mention. WhatsApp Web does not use a host-side token or OpenShell credential provider. @@ -80,6 +82,7 @@ $ export DISCORD_SERVER_ID= $ export SLACK_BOT_TOKEN= $ export SLACK_APP_TOKEN= $ export SLACK_ALLOWED_USERS= +$ export SLACK_ALLOWED_CHANNELS= ``` Then run onboarding: @@ -112,7 +115,7 @@ $ nemoclaw my-assistant channels add whatsapp The command accepts mixed-case input such as `Telegram`, then stores and prints the canonical lowercase channel name. If a matching built-in network policy preset exists, `channels add` applies it to the sandbox automatically before the rebuild so the bridge has egress to its upstream API; if applying the preset fails, NemoClaw warns and tells you to re-apply manually with `nemoclaw policy-add ` after the rebuild. Choose the rebuild so the running sandbox image picks up the new channel. -If you need optional channel settings such as `TELEGRAM_ALLOWED_IDS`, `TELEGRAM_REQUIRE_MENTION`, `DISCORD_SERVER_ID`, `DISCORD_USER_ID`, or `DISCORD_REQUIRE_MENTION`, export them before the rebuild starts. +If you need optional channel settings such as `TELEGRAM_ALLOWED_IDS`, `TELEGRAM_REQUIRE_MENTION`, `DISCORD_SERVER_ID`, `DISCORD_USER_ID`, `DISCORD_REQUIRE_MENTION`, `SLACK_ALLOWED_USERS`, or `SLACK_ALLOWED_CHANNELS`, export them before the rebuild starts. If you defer the rebuild, apply the change later: ```console diff --git a/.agents/skills/nemoclaw-user-reference/references/architecture.md b/.agents/skills/nemoclaw-user-reference/references/architecture.md index 224ae1ec01d..14a5c352f5e 100644 --- a/.agents/skills/nemoclaw-user-reference/references/architecture.md +++ b/.agents/skills/nemoclaw-user-reference/references/architecture.md @@ -247,6 +247,10 @@ The following environment variables configure optional services and local access |---|---| | `TELEGRAM_BOT_TOKEN` | Telegram bot token you provide before `nemoclaw onboard`. OpenShell stores it in a provider; the sandbox receives placeholders, not the raw secret. | | `TELEGRAM_ALLOWED_IDS` | Comma-separated Telegram user or chat IDs for allowlists when onboarding applies channel restrictions. | +| `SLACK_BOT_TOKEN` | Slack bot token (`xoxb-...`) you provide before `nemoclaw onboard`. Stored as an OpenShell provider; never passed directly to the sandbox. | +| `SLACK_APP_TOKEN` | Slack app-level token (`xapp-...`) required for Socket Mode. Stored alongside `SLACK_BOT_TOKEN` during onboarding. | +| `SLACK_ALLOWED_USERS` | Comma-separated Slack member IDs for DM and channel `@mention` user allowlisting. | +| `SLACK_ALLOWED_CHANNELS` | Comma-separated Slack channel IDs where channel `@mention` events are enabled (e.g. `C012AB3CD,C987ZY6XW`). Baked into the sandbox image at build time. Combine with `SLACK_ALLOWED_USERS` to restrict both channel and member. | | `CHAT_UI_URL` | URL for the optional chat UI endpoint. | | `NEMOCLAW_DISABLE_DEVICE_AUTH` | Build-time-only toggle that disables gateway device pairing when set to `1` before the sandbox image is created. | diff --git a/.agents/skills/nemoclaw-user-reference/references/commands.md b/.agents/skills/nemoclaw-user-reference/references/commands.md index 74d1d3944b3..0fbe480eda4 100644 --- a/.agents/skills/nemoclaw-user-reference/references/commands.md +++ b/.agents/skills/nemoclaw-user-reference/references/commands.md @@ -151,6 +151,7 @@ This flag takes precedence over `CHAT_UI_URL`, `NEMOCLAW_DASHBOARD_PORT`, the pr If you enable Slack during onboarding, the wizard collects both the Bot Token (`SLACK_BOT_TOKEN`) and the App-Level Token (`SLACK_APP_TOKEN`). Socket Mode requires both tokens. The app-level token is stored in a dedicated `slack-app` OpenShell provider and forwarded to the sandbox alongside the bot token. +The wizard also accepts optional `SLACK_ALLOWED_USERS` and `SLACK_ALLOWED_CHANNELS` values so you can restrict Slack DMs, channel `@mention` users, and channel IDs before the sandbox image is built. If you enable Discord during onboarding, the wizard can also prompt for a Discord Server ID, whether the bot should reply only to `@mentions` or to all messages in that server, and an optional Discord User ID. NemoClaw bakes those values into the sandbox image as Discord guild workspace config so the bot can respond in the selected server, not just in DMs. @@ -664,6 +665,7 @@ $ nemoclaw my-assistant channels add telegram | `--dry-run` | Validate the channel and token inputs without saving credentials or rebuilding | Slack requires both `SLACK_BOT_TOKEN` (bot user OAuth) and `SLACK_APP_TOKEN` (app-level Socket Mode token); the command prompts for each in turn. +Optional Slack allowlists come from `SLACK_ALLOWED_USERS` and `SLACK_ALLOWED_CHANNELS` at rebuild time. When `NEMOCLAW_NON_INTERACTIVE=1` is set, any missing token fails fast and no rebuild prompt is shown — instead, the change is queued and you are told to run `nemoclaw rebuild` manually. If you omit the required `` argument, the CLI prints the `channels add ` usage with the supported channel list instead of falling back to top-level help. diff --git a/Dockerfile b/Dockerfile index c21d54eabf0..1381964afaf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -395,6 +395,11 @@ ARG NEMOCLAW_TELEGRAM_CONFIG_B64=e30= # metadata only — the bot token flows through the OpenShell provider, never # baked into the image. Default: empty map. ARG NEMOCLAW_WECHAT_CONFIG_B64=e30= +# Base64-encoded JSON Slack config (e.g. +# {"allowedChannels":["C012AB3CD","C987ZY6XW"]}). +# Channel IDs scope Slack channel @mention handling. User allowlists still come +# from NEMOCLAW_MESSAGING_ALLOWED_IDS_B64. Default: empty map. +ARG NEMOCLAW_SLACK_CONFIG_B64=e30= # Set to "1" to force-disable device-pairing auth. Also auto-disabled when # CHAT_UI_URL is a non-loopback address (Brev Launchable, remote deployments) # since terminal-based pairing is impossible in those contexts. @@ -442,6 +447,7 @@ ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \ NEMOCLAW_DISCORD_GUILDS_B64=${NEMOCLAW_DISCORD_GUILDS_B64} \ NEMOCLAW_TELEGRAM_CONFIG_B64=${NEMOCLAW_TELEGRAM_CONFIG_B64} \ NEMOCLAW_WECHAT_CONFIG_B64=${NEMOCLAW_WECHAT_CONFIG_B64} \ + NEMOCLAW_SLACK_CONFIG_B64=${NEMOCLAW_SLACK_CONFIG_B64} \ NEMOCLAW_OPENCLAW_WECHAT_PLUGIN_PREINSTALLED=1 \ NEMOCLAW_DISABLE_DEVICE_AUTH=${NEMOCLAW_DISABLE_DEVICE_AUTH} \ NEMOCLAW_PROXY_HOST=${NEMOCLAW_PROXY_HOST} \ diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 9b4868e7bab..1fd2af73c53 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -89,6 +89,7 @@ ARG NEMOCLAW_TELEGRAM_CONFIG_B64=e30= # is never baked here — it flows through the OpenShell L7 proxy via the # WECHAT_BOT_TOKEN credential slot. ARG NEMOCLAW_WECHAT_CONFIG_B64=e30= +ARG NEMOCLAW_SLACK_CONFIG_B64=e30= ARG NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER=0 ARG NEMOCLAW_HERMES_TOOL_GATEWAY_PRESETS_B64=W10= ARG NEMOCLAW_BUILD_ID=default @@ -104,6 +105,7 @@ ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \ NEMOCLAW_DISCORD_GUILDS_B64=${NEMOCLAW_DISCORD_GUILDS_B64} \ NEMOCLAW_TELEGRAM_CONFIG_B64=${NEMOCLAW_TELEGRAM_CONFIG_B64} \ NEMOCLAW_WECHAT_CONFIG_B64=${NEMOCLAW_WECHAT_CONFIG_B64} \ + NEMOCLAW_SLACK_CONFIG_B64=${NEMOCLAW_SLACK_CONFIG_B64} \ NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER=${NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER} \ NEMOCLAW_HERMES_TOOL_GATEWAY_PRESETS_B64=${NEMOCLAW_HERMES_TOOL_GATEWAY_PRESETS_B64} diff --git a/agents/hermes/config/build-env.ts b/agents/hermes/config/build-env.ts index 6ebd2c0151f..3646e860ac4 100644 --- a/agents/hermes/config/build-env.ts +++ b/agents/hermes/config/build-env.ts @@ -28,6 +28,10 @@ export type WechatConfig = { userId?: string; }; +export type SlackConfig = { + allowedChannels?: string[]; +}; + export type HermesBuildSettings = { model: string; baseUrl: string; @@ -43,6 +47,7 @@ export type HermesBuildSettings = { discordGuilds: DiscordGuilds; telegramConfig: TelegramConfig; wechatConfig: WechatConfig; + slackConfig: SlackConfig; }; }; @@ -79,6 +84,7 @@ export function readHermesBuildSettings(env: NodeJS.ProcessEnv): HermesBuildSett "e30=", ), wechatConfig: readBase64Json(env, "NEMOCLAW_WECHAT_CONFIG_B64", "e30="), + slackConfig: readBase64Json(env, "NEMOCLAW_SLACK_CONFIG_B64", "e30="), }, }; } diff --git a/agents/hermes/config/messaging-config.ts b/agents/hermes/config/messaging-config.ts index 147869756c6..6871dfe5d0e 100644 --- a/agents/hermes/config/messaging-config.ts +++ b/agents/hermes/config/messaging-config.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import type { DiscordGuilds, MessagingAllowedIds, WechatConfig } from "./build-env.ts"; +import type { DiscordGuilds, MessagingAllowedIds, SlackConfig, WechatConfig } from "./build-env.ts"; import { loadManagedToolGatewayMatrix } from "./managed-tool-gateway.ts"; // Maps each Hermes-supported channel to the in-sandbox env-var name(s) the @@ -24,6 +24,7 @@ export function buildMessagingEnvLines( allowedIds: MessagingAllowedIds, discordGuilds: DiscordGuilds, wechatConfig: WechatConfig, + slackConfig: SlackConfig, managedToolGatewayPresets: string[] = [], ): string[] { const envLines = ["API_SERVER_PORT=18642", "API_SERVER_HOST=127.0.0.1"]; @@ -74,6 +75,10 @@ export function buildMessagingEnvLines( if (allowedIds.slack?.length) { envLines.push(`SLACK_ALLOWED_USERS=${allowedIds.slack.map(String).join(",")}`); } + const slackAllowedChannels = collectSlackAllowedChannels(slackConfig); + if (enabledChannels.has("slack") && slackAllowedChannels.length > 0) { + envLines.push(`SLACK_ALLOWED_CHANNELS=${slackAllowedChannels.join(",")}`); + } return envLines; } @@ -179,3 +184,12 @@ function collectDiscordAllowedUsers( } return [...users]; } + +function collectSlackAllowedChannels(slackConfig: SlackConfig): string[] { + const channels = Array.isArray(slackConfig.allowedChannels) ? slackConfig.allowedChannels : []; + return [ + ...new Set( + channels.map((channel) => String(channel).replace(/[\r\n]/g, "").trim()).filter(Boolean), + ), + ]; +} diff --git a/agents/hermes/generate-config.ts b/agents/hermes/generate-config.ts index 97d88638100..35a9fe8d4e4 100644 --- a/agents/hermes/generate-config.ts +++ b/agents/hermes/generate-config.ts @@ -42,6 +42,7 @@ function main(): void { settings.messaging.allowedIds, settings.messaging.discordGuilds, settings.messaging.wechatConfig, + settings.messaging.slackConfig, settings.managedToolGateways.brokerEnabled ? settings.managedToolGateways.presets : [], diff --git a/docs/manage-sandboxes/messaging-channels.mdx b/docs/manage-sandboxes/messaging-channels.mdx index ee084a8fd14..f3d4af3bb62 100644 --- a/docs/manage-sandboxes/messaging-channels.mdx +++ b/docs/manage-sandboxes/messaging-channels.mdx @@ -34,7 +34,7 @@ For details, refer to [Commands](/reference/commands). |---------|-----------------|-------------------| | Telegram | `TELEGRAM_BOT_TOKEN` | `TELEGRAM_ALLOWED_IDS` for DM allowlisting, `TELEGRAM_REQUIRE_MENTION` for group-chat replies | | Discord | `DISCORD_BOT_TOKEN` | `DISCORD_SERVER_ID`, `DISCORD_USER_ID`, `DISCORD_REQUIRE_MENTION` | -| Slack | `SLACK_BOT_TOKEN`, `SLACK_APP_TOKEN` | `SLACK_ALLOWED_USERS` for DM and channel `@mention` user allowlisting | +| Slack | `SLACK_BOT_TOKEN`, `SLACK_APP_TOKEN` | `SLACK_ALLOWED_USERS` for DM and channel `@mention` user allowlisting, `SLACK_ALLOWED_CHANNELS` for channel ID allowlisting | | WhatsApp | None. Pair via QR after rebuild | None | Telegram uses a bot token from [BotFather](https://t.me/BotFather). @@ -55,6 +55,8 @@ Set `DISCORD_USER_ID` to restrict access to one user; otherwise, any member of t Slack uses Socket Mode and requires two tokens. Use `SLACK_BOT_TOKEN` for the bot user OAuth token (`xoxb-...`) and `SLACK_APP_TOKEN` for the app-level Socket Mode token (`xapp-...`). Set `SLACK_ALLOWED_USERS` to comma-separated Slack member IDs to authorize those users for DMs and for channel `@mention` events in channels where the Slack app is present. +Set `SLACK_ALLOWED_CHANNELS` to comma-separated Slack channel IDs to restrict channel `@mention` handling to those channels. +When both Slack allowlists are set, NemoClaw requires the mention to come from one of the allowed channels and one of the allowed members. Channel messages still require an explicit bot mention. WhatsApp Web does not use a host-side token or OpenShell credential provider. @@ -91,6 +93,7 @@ $ export DISCORD_SERVER_ID= $ export SLACK_BOT_TOKEN= $ export SLACK_APP_TOKEN= $ export SLACK_ALLOWED_USERS= +$ export SLACK_ALLOWED_CHANNELS= ``` Then run onboarding: @@ -123,7 +126,7 @@ $ nemoclaw my-assistant channels add whatsapp The command accepts mixed-case input such as `Telegram`, then stores and prints the canonical lowercase channel name. If a matching built-in network policy preset exists, `channels add` applies it to the sandbox automatically before the rebuild so the bridge has egress to its upstream API; if applying the preset fails, NemoClaw warns and tells you to re-apply manually with `nemoclaw policy-add ` after the rebuild. Choose the rebuild so the running sandbox image picks up the new channel. -If you need optional channel settings such as `TELEGRAM_ALLOWED_IDS`, `TELEGRAM_REQUIRE_MENTION`, `DISCORD_SERVER_ID`, `DISCORD_USER_ID`, or `DISCORD_REQUIRE_MENTION`, export them before the rebuild starts. +If you need optional channel settings such as `TELEGRAM_ALLOWED_IDS`, `TELEGRAM_REQUIRE_MENTION`, `DISCORD_SERVER_ID`, `DISCORD_USER_ID`, `DISCORD_REQUIRE_MENTION`, `SLACK_ALLOWED_USERS`, or `SLACK_ALLOWED_CHANNELS`, export them before the rebuild starts. If you defer the rebuild, apply the change later: ```console diff --git a/docs/reference/architecture.mdx b/docs/reference/architecture.mdx index f4ee2dce639..790b90d742f 100644 --- a/docs/reference/architecture.mdx +++ b/docs/reference/architecture.mdx @@ -254,6 +254,10 @@ The following environment variables configure optional services and local access |---|---| | `TELEGRAM_BOT_TOKEN` | Telegram bot token you provide before `nemoclaw onboard`. OpenShell stores it in a provider; the sandbox receives placeholders, not the raw secret. | | `TELEGRAM_ALLOWED_IDS` | Comma-separated Telegram user or chat IDs for allowlists when onboarding applies channel restrictions. | +| `SLACK_BOT_TOKEN` | Slack bot token (`xoxb-...`) you provide before `nemoclaw onboard`. Stored as an OpenShell provider; never passed directly to the sandbox. | +| `SLACK_APP_TOKEN` | Slack app-level token (`xapp-...`) required for Socket Mode. Stored alongside `SLACK_BOT_TOKEN` during onboarding. | +| `SLACK_ALLOWED_USERS` | Comma-separated Slack member IDs for DM and channel `@mention` user allowlisting. | +| `SLACK_ALLOWED_CHANNELS` | Comma-separated Slack channel IDs where channel `@mention` events are enabled (e.g. `C012AB3CD,C987ZY6XW`). Baked into the sandbox image at build time. Combine with `SLACK_ALLOWED_USERS` to restrict both channel and member. | | `CHAT_UI_URL` | URL for the optional chat UI endpoint. | | `NEMOCLAW_DISABLE_DEVICE_AUTH` | Build-time-only toggle that disables gateway device pairing when set to `1` before the sandbox image is created. | diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 6741036b10e..909749d3eeb 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -169,6 +169,7 @@ This flag takes precedence over `CHAT_UI_URL`, `NEMOCLAW_DASHBOARD_PORT`, the pr If you enable Slack during onboarding, the wizard collects both the Bot Token (`SLACK_BOT_TOKEN`) and the App-Level Token (`SLACK_APP_TOKEN`). Socket Mode requires both tokens. The app-level token is stored in a dedicated `slack-app` OpenShell provider and forwarded to the sandbox alongside the bot token. +The wizard also accepts optional `SLACK_ALLOWED_USERS` and `SLACK_ALLOWED_CHANNELS` values so you can restrict Slack DMs, channel `@mention` users, and channel IDs before the sandbox image is built. If you enable Discord during onboarding, the wizard can also prompt for a Discord Server ID, whether the bot should reply only to `@mentions` or to all messages in that server, and an optional Discord User ID. NemoClaw bakes those values into the sandbox image as Discord guild workspace config so the bot can respond in the selected server, not just in DMs. @@ -682,6 +683,7 @@ $ nemoclaw my-assistant channels add telegram | `--dry-run` | Validate the channel and token inputs without saving credentials or rebuilding | Slack requires both `SLACK_BOT_TOKEN` (bot user OAuth) and `SLACK_APP_TOKEN` (app-level Socket Mode token); the command prompts for each in turn. +Optional Slack allowlists come from `SLACK_ALLOWED_USERS` and `SLACK_ALLOWED_CHANNELS` at rebuild time. When `NEMOCLAW_NON_INTERACTIVE=1` is set, any missing token fails fast and no rebuild prompt is shown — instead, the change is queued and you are told to run `nemoclaw rebuild` manually. If you omit the required `` argument, the CLI prints the `channels add ` usage with the supported channel list instead of falling back to top-level help. diff --git a/scripts/generate-openclaw-config.py b/scripts/generate-openclaw-config.py index 28dd98fd853..f3c8108ce66 100755 --- a/scripts/generate-openclaw-config.py +++ b/scripts/generate-openclaw-config.py @@ -32,6 +32,7 @@ NEMOCLAW_DISCORD_GUILDS_B64 Base64-encoded Discord guild config NEMOCLAW_TELEGRAM_CONFIG_B64 Base64-encoded Telegram config (e.g. {"requireMention": true}) NEMOCLAW_WECHAT_CONFIG_B64 Base64-encoded WeChat config (e.g. {"accountId": "...", "baseUrl": "...", "userId": "..."}) + NEMOCLAW_SLACK_CONFIG_B64 Base64-encoded Slack config (e.g. {"allowedChannels": ["C012AB3CD"]}) NEMOCLAW_DISABLE_DEVICE_AUTH Set to "1" to force-disable device auth NEMOCLAW_PROXY_HOST Egress proxy host (default: 10.200.0.1) NEMOCLAW_PROXY_PORT Egress proxy port (default: 3128) @@ -510,6 +511,27 @@ def build_config(env: dict | None = None) -> dict: env.get("NEMOCLAW_TELEGRAM_CONFIG_B64", "e30=") or "e30=" ).decode("utf-8") ) + _slack_config = json.loads( + base64.b64decode( + env.get("NEMOCLAW_SLACK_CONFIG_B64", "e30=") or "e30=" + ).decode("utf-8") + ) + _raw_slack_channels = ( + _slack_config.get("allowedChannels") + if isinstance(_slack_config, dict) + else [] + ) + _slack_allowed_channels = ( + list( + dict.fromkeys( + str(channel).replace("\r", "").replace("\n", "").strip() + for channel in _raw_slack_channels + if str(channel).replace("\r", "").replace("\n", "").strip() + ) + ) + if isinstance(_raw_slack_channels, list) + else [] + ) # NEMOCLAW_WECHAT_CONFIG_B64 is intentionally not decoded here. The # WeChat plugin's per-account state (accountId/baseUrl/userId) is read by # seed-wechat-accounts.py, which runs after the base image has installed @@ -577,6 +599,18 @@ def _placeholder(channel: str, env_key: str) -> str: "users": _allowed_ids[ch], } } + if ch == "slack" and _slack_allowed_channels: + account["groupPolicy"] = "allowlist" + slack_channel_config = { + "enabled": True, + "requireMention": True, + } + if ch in _allowed_ids and _allowed_ids[ch]: + slack_channel_config["users"] = _allowed_ids[ch] + account["channels"] = { + channel_id: dict(slack_channel_config) + for channel_id in _slack_allowed_channels + } _ch_cfg[ch] = {"accounts": {"default": account}} # WeChat (openclaw-weixin) is NOT added to channels.* here in build diff --git a/src/lib/messaging-channel-config.test.ts b/src/lib/messaging-channel-config.test.ts index c5a718eaedf..2ae549b5dc3 100644 --- a/src/lib/messaging-channel-config.test.ts +++ b/src/lib/messaging-channel-config.test.ts @@ -20,6 +20,7 @@ describe("messaging channel config", () => { "DISCORD_REQUIRE_MENTION", "WECHAT_ALLOWED_IDS", "SLACK_ALLOWED_USERS", + "SLACK_ALLOWED_CHANNELS", ]); }); @@ -31,6 +32,7 @@ describe("messaging channel config", () => { DISCORD_SERVER_ID: "1491590992753590594", DISCORD_REQUIRE_MENTION: "0", SLACK_ALLOWED_USERS: " U01ABC2DEF3, U04GHI5JKL6 ", + SLACK_ALLOWED_CHANNELS: " C012AB3CD, C987ZY6XW ", NVIDIA_API_KEY: "not-channel-config", }), ).toEqual({ @@ -38,6 +40,7 @@ describe("messaging channel config", () => { DISCORD_SERVER_ID: "1491590992753590594", DISCORD_REQUIRE_MENTION: "0", SLACK_ALLOWED_USERS: "U01ABC2DEF3, U04GHI5JKL6", + SLACK_ALLOWED_CHANNELS: "C012AB3CD, C987ZY6XW", }); }); diff --git a/src/lib/messaging-channel-config.ts b/src/lib/messaging-channel-config.ts index 814810a42f9..88afca1b3d5 100644 --- a/src/lib/messaging-channel-config.ts +++ b/src/lib/messaging-channel-config.ts @@ -15,9 +15,12 @@ const requireMentionKeys = new Set( export const MESSAGING_CHANNEL_CONFIG_ENV_KEYS: readonly string[] = [ ...new Set( channels.flatMap((channel) => - [channel.serverIdEnvKey, channel.userIdEnvKey, channel.requireMentionEnvKey].filter( - (key): key is string => typeof key === "string" && key.length > 0, - ), + [ + channel.serverIdEnvKey, + channel.userIdEnvKey, + channel.channelIdEnvKey, + channel.requireMentionEnvKey, + ].filter((key): key is string => typeof key === "string" && key.length > 0), ), ), ]; @@ -26,7 +29,7 @@ const knownConfigKeys = new Set(MESSAGING_CHANNEL_CONFIG_ENV_KEYS); function normalizeValue(value: unknown): string | null { if (typeof value !== "string") return null; - const normalized = value.replace(/\r/g, "").trim(); + const normalized = value.replace(/[\r\n]/g, "").trim(); return normalized || null; } diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 518a2768439..ed0519a242d 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -250,7 +250,6 @@ const { LOCAL_INFERENCE_PROVIDERS, OLLAMA_PROXY_CREDENTIAL_ENV, VLLM_LOCAL_CREDENTIAL_ENV, - DISCORD_SNOWFLAKE_RE, getProviderLabel, getEffectiveProviderName, getNonInteractiveProvider, @@ -263,7 +262,6 @@ const { LOCAL_INFERENCE_PROVIDERS: string[]; OLLAMA_PROXY_CREDENTIAL_ENV: string; VLLM_LOCAL_CREDENTIAL_ENV: string; - DISCORD_SNOWFLAKE_RE: RegExp; getProviderLabel: (key: string) => string; getEffectiveProviderName: (key: string | null | undefined) => string | null; getNonInteractiveProvider: () => string | null; @@ -372,6 +370,7 @@ const { getRecordedMessagingChannelsForResume: getRecordedMessagingChannelsForResumeFromState, }: typeof import("./onboard/messaging-credentials") = require("./onboard/messaging-credentials"); const { + collectMessagingBuildConfig, computeTelegramRequireMention, getStoredMessagingChannelConfig, messagingChannelConfigsEqual, @@ -3612,49 +3611,14 @@ async function createSandbox( console.log(` Creating sandbox '${sandboxName}' (this takes a few minutes on first run)...`); const messagingChannelConfig = readMessagingChannelConfigFromEnv(); - // Build allowed sender IDs map from env vars set during the messaging prompt. - // Each channel with a userIdEnvKey in MESSAGING_CHANNELS may have a - // comma-separated list of IDs (e.g. TELEGRAM_ALLOWED_IDS="123,456"). - const messagingAllowedIds: Record = {}; const enabledTokenEnvKeys = new Set(messagingTokenDefs.map(({ envKey }) => envKey)); const activeChannelNames = new Set(activeMessagingChannels); - for (const ch of MESSAGING_CHANNELS) { - if (activeChannelNames.has(ch.name) && ch.userIdEnvKey && process.env[ch.userIdEnvKey]) { - const ids = String(process.env[ch.userIdEnvKey]) - .split(",") - .map((s) => s.trim()) - .filter(Boolean); - if (ids.length > 0) messagingAllowedIds[ch.name] = ids; - } - } - const discordGuilds: Record = {}; - if (enabledTokenEnvKeys.has("DISCORD_BOT_TOKEN")) { - const serverIds = (process.env.DISCORD_SERVER_IDS || process.env.DISCORD_SERVER_ID || "") - .split(",") - .map((s) => s.trim()) - .filter(Boolean); - const userIds = (process.env.DISCORD_ALLOWED_IDS || process.env.DISCORD_USER_ID || "") - .split(",") - .map((s) => s.trim()) - .filter(Boolean); - for (const serverId of serverIds) { - if (!DISCORD_SNOWFLAKE_RE.test(serverId)) { - console.warn(" Warning: configured Discord server ID does not look like a snowflake."); - } - } - for (const userId of userIds) { - if (!DISCORD_SNOWFLAKE_RE.test(userId)) { - console.warn(" Warning: configured Discord user ID does not look like a snowflake."); - } - } - const requireMention = process.env.DISCORD_REQUIRE_MENTION !== "0"; - for (const serverId of serverIds) { - discordGuilds[serverId] = { - requireMention, - ...(userIds.length > 0 ? { users: userIds } : {}), - }; - } - } + const { messagingAllowedIds, discordGuilds, slackConfig } = collectMessagingBuildConfig({ + channels: MESSAGING_CHANNELS, + activeChannelNames, + enabledTokenEnvKeys, + discordSnowflakeRe: onboardProviders.DISCORD_SNOWFLAKE_RE, + }); // Telegram mention-only mode — parity with Discord's requireMention. // Off by default so existing sandboxes behave the same; opt-in via // TELEGRAM_REQUIRE_MENTION=1 or the interactive prompt. See #1737. @@ -3743,6 +3707,7 @@ async function createSandbox( false, sandboxInferenceBaseUrlOverride, hermesToolGateways, + slackConfig, ); // Only pass non-sensitive env vars to the sandbox. Credentials flow through // OpenShell providers — the gateway injects them as placeholders and the L7 diff --git a/src/lib/onboard/dockerfile-patch.test.ts b/src/lib/onboard/dockerfile-patch.test.ts index 0a680d29dd0..9f6659a3d90 100644 --- a/src/lib/onboard/dockerfile-patch.test.ts +++ b/src/lib/onboard/dockerfile-patch.test.ts @@ -75,6 +75,7 @@ describe("dockerfile patch helpers", () => { "ARG NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=old", "ARG NEMOCLAW_DISCORD_GUILDS_B64=old", "ARG NEMOCLAW_TELEGRAM_CONFIG_B64=old", + "ARG NEMOCLAW_SLACK_CONFIG_B64=old", ].join("\n"), ); @@ -93,6 +94,9 @@ describe("dockerfile patch helpers", () => { { requireMention: true }, {}, true, + null, + [], + { allowedChannels: ["C012AB3CD", "C987ZY6XW"] }, ); const patched = fs.readFileSync(dockerfilePath, "utf-8"); @@ -110,6 +114,13 @@ describe("dockerfile patch helpers", () => { expect(patched).toContain("ARG NEMOCLAW_DISABLE_DEVICE_AUTH=1"); expect(patched).not.toContain("ARG NEMOCLAW_MESSAGING_CHANNELS_B64=old"); expect(patched).not.toContain("ARG NEMOCLAW_TELEGRAM_CONFIG_B64=old"); + const slackLine = patched + .split("\n") + .find((line) => line.startsWith("ARG NEMOCLAW_SLACK_CONFIG_B64=")); + assert.ok(slackLine, "expected slack config build arg"); + assert.deepEqual(JSON.parse(Buffer.from(slackLine.split("=")[1], "base64").toString("utf8")), { + allowedChannels: ["C012AB3CD", "C987ZY6XW"], + }); }); it("uses the shared sandbox inference mapping", () => { diff --git a/src/lib/onboard/dockerfile-patch.ts b/src/lib/onboard/dockerfile-patch.ts index cb218f57f79..5f70c317b5f 100644 --- a/src/lib/onboard/dockerfile-patch.ts +++ b/src/lib/onboard/dockerfile-patch.ts @@ -51,6 +51,7 @@ export function patchStagedDockerfile( darwinVmCompat = false, inferenceBaseUrlOverride: string | null = null, hermesToolGateways: string[] = [], + slackConfig: LooseObject = {}, ): void { const sanitizedModel = sanitizeDockerArg(model); const sandboxInference = getSandboxInferenceConfig( @@ -233,6 +234,12 @@ export function patchStagedDockerfile( `ARG NEMOCLAW_WECHAT_CONFIG_B64=${encodeSanitizedDockerJsonArg(wechatConfig)}`, ); } + if (slackConfig && Object.keys(slackConfig).length > 0) { + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_SLACK_CONFIG_B64=.*$/m, + `ARG NEMOCLAW_SLACK_CONFIG_B64=${encodeSanitizedDockerJsonArg(slackConfig)}`, + ); + } if (hermesToolGateways.length > 0) { dockerfile = dockerfile.replace( /^ARG NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER=.*$/m, diff --git a/src/lib/onboard/messaging-channel-setup.test.ts b/src/lib/onboard/messaging-channel-setup.test.ts index 30012b4b55a..421dcc7abd6 100644 --- a/src/lib/onboard/messaging-channel-setup.test.ts +++ b/src/lib/onboard/messaging-channel-setup.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { prompt } from "../credentials/store"; import { KNOWN_CHANNELS } from "../sandbox/channels"; import { setupSelectedMessagingChannels } from "./messaging-channel-setup"; @@ -52,4 +53,29 @@ describe("setupSelectedMessagingChannels", () => { expect(output).toContain("remove and re-add the bot to each group"); expect(output).toContain("reply mode already set: @mentions only"); }); + + it("prompts for Slack channel IDs with channel-specific copy", async () => { + process.env.SLACK_BOT_TOKEN = "xoxb-1234-test"; + process.env.SLACK_APP_TOKEN = "xapp-1-A0000-12345-test"; + process.env.SLACK_ALLOWED_USERS = "U01ABC2DEF3"; + vi.mocked(prompt).mockResolvedValueOnce("C012AB3CD,C987ZY6XW"); + const logs: string[] = []; + vi.spyOn(console, "log").mockImplementation((message = "") => { + logs.push(String(message)); + }); + + await setupSelectedMessagingChannels( + ["slack"], + new Set(["slack"]), + [{ name: "slack", ...KNOWN_CHANNELS.slack }], + ); + + expect(process.env.SLACK_ALLOWED_CHANNELS).toBe("C012AB3CD,C987ZY6XW"); + expect(vi.mocked(prompt)).toHaveBeenCalledWith( + " Slack Channel IDs (comma-separated allowlist): ", + ); + const output = logs.join("\n"); + expect(output).toContain("Slack channel IDs"); + expect(output).toContain("channel IDs saved"); + }); }); diff --git a/src/lib/onboard/messaging-channel-setup.ts b/src/lib/onboard/messaging-channel-setup.ts index 0ebe3fac52d..c3dd2dfd28e 100644 --- a/src/lib/onboard/messaging-channel-setup.ts +++ b/src/lib/onboard/messaging-channel-setup.ts @@ -174,5 +174,21 @@ export async function setupSelectedMessagingChannels( } } } + if (ch.channelIdEnvKey && (!ch.serverIdEnvKey || process.env[ch.serverIdEnvKey])) { + const existingChannelIds = getMessagingConfigValue(ch.channelIdEnvKey) || ""; + if (existingChannelIds) { + process.env[ch.channelIdEnvKey] = existingChannelIds; + console.log(` ✓ ${ch.name} — channel IDs already set: ${existingChannelIds}`); + } else { + console.log(` ${ch.channelIdHelp}`); + const channelIds = (await prompt(` ${ch.channelIdLabel}: `)).trim(); + if (channelIds) { + process.env[ch.channelIdEnvKey] = channelIds; + console.log(` ✓ ${ch.name} channel IDs saved`); + } else { + console.log(` Skipped ${ch.name} channel IDs (channel @mentions stay disabled)`); + } + } + } } } diff --git a/src/lib/onboard/messaging-config.test.ts b/src/lib/onboard/messaging-config.test.ts new file mode 100644 index 00000000000..c38b8822814 --- /dev/null +++ b/src/lib/onboard/messaging-config.test.ts @@ -0,0 +1,81 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import { collectMessagingBuildConfig, parseMessagingConfigList } from "./messaging-config"; + +const DISCORD_SNOWFLAKE_RE = /^[0-9]{17,19}$/; + +describe("onboard messaging config", () => { + it("parses comma-separated config without preserving line breaks", () => { + expect(parseMessagingConfigList(" U01\nBAD , C01\rBAD , , U02 ")).toEqual([ + "U01BAD", + "C01BAD", + "U02", + ]); + }); + + it("collects active channel allowlists and Slack channel config", () => { + expect( + collectMessagingBuildConfig({ + channels: [ + { name: "telegram", userIdEnvKey: "TELEGRAM_ALLOWED_IDS" }, + { name: "slack", userIdEnvKey: "SLACK_ALLOWED_USERS" }, + { name: "wechat", userIdEnvKey: "WECHAT_ALLOWED_IDS" }, + ], + activeChannelNames: new Set(["slack", "telegram"]), + enabledTokenEnvKeys: new Set(), + env: { + TELEGRAM_ALLOWED_IDS: "123,456", + SLACK_ALLOWED_USERS: "U01ABC2DEF3", + SLACK_ALLOWED_CHANNELS: "C012AB3CD\n,C987ZY6XW", + WECHAT_ALLOWED_IDS: "wxid-unused", + }, + discordSnowflakeRe: DISCORD_SNOWFLAKE_RE, + }), + ).toEqual({ + messagingAllowedIds: { + telegram: ["123", "456"], + slack: ["U01ABC2DEF3"], + }, + discordGuilds: {}, + slackConfig: { + allowedChannels: ["C012AB3CD", "C987ZY6XW"], + }, + }); + }); + + it("collects Discord guild config and warns on malformed IDs", () => { + const warn = vi.fn(); + + expect( + collectMessagingBuildConfig({ + channels: [], + activeChannelNames: new Set(), + enabledTokenEnvKeys: new Set(["DISCORD_BOT_TOKEN"]), + env: { + DISCORD_SERVER_IDS: "1491590992753590594,bad-server", + DISCORD_ALLOWED_IDS: "1491590992753590595,bad-user", + DISCORD_REQUIRE_MENTION: "0", + }, + discordSnowflakeRe: DISCORD_SNOWFLAKE_RE, + warn, + }), + ).toEqual({ + messagingAllowedIds: {}, + discordGuilds: { + "1491590992753590594": { + requireMention: false, + users: ["1491590992753590595", "bad-user"], + }, + "bad-server": { + requireMention: false, + users: ["1491590992753590595", "bad-user"], + }, + }, + slackConfig: {}, + }); + expect(warn).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/lib/onboard/messaging-config.ts b/src/lib/onboard/messaging-config.ts index 2ac8fa7eae1..e5646660355 100644 --- a/src/lib/onboard/messaging-config.ts +++ b/src/lib/onboard/messaging-config.ts @@ -10,6 +10,28 @@ import type { Session } from "../state/onboard-session"; import * as onboardSession from "../state/onboard-session"; import * as registry from "../state/registry"; +type EnvLike = Record; + +type MessagingBuildChannel = { + name: string; + userIdEnvKey?: string; +}; + +export type MessagingBuildConfig = { + messagingAllowedIds: Record; + discordGuilds: Record; + slackConfig: Record; +}; + +export type CollectMessagingBuildConfigOptions = { + channels: MessagingBuildChannel[]; + activeChannelNames: ReadonlySet; + enabledTokenEnvKeys: ReadonlySet; + env?: EnvLike; + discordSnowflakeRe: RegExp; + warn?: (message: string) => void; +}; + // Read TELEGRAM_REQUIRE_MENTION (set either by the interactive mention prompt // or by the user's shell) and map it to a boolean, or null when the env var // is unset / invalid. Used at build time to bake groupPolicy into @@ -22,6 +44,61 @@ export function computeTelegramRequireMention(): boolean | null { return null; } +export function parseMessagingConfigList(value: unknown): string[] { + return String(value ?? "") + .split(",") + .map((s) => s.replace(/[\r\n]/g, "").trim()) + .filter(Boolean); +} + +export function collectMessagingBuildConfig({ + channels, + activeChannelNames, + enabledTokenEnvKeys, + env = process.env, + discordSnowflakeRe, + warn = console.warn, +}: CollectMessagingBuildConfigOptions): MessagingBuildConfig { + const messagingAllowedIds: Record = {}; + for (const ch of channels) { + if (activeChannelNames.has(ch.name) && ch.userIdEnvKey && env[ch.userIdEnvKey]) { + const ids = parseMessagingConfigList(env[ch.userIdEnvKey]); + if (ids.length > 0) messagingAllowedIds[ch.name] = ids; + } + } + + const slackConfig: Record = {}; + if (activeChannelNames.has("slack") && env.SLACK_ALLOWED_CHANNELS) { + const allowedChannels = parseMessagingConfigList(env.SLACK_ALLOWED_CHANNELS); + if (allowedChannels.length > 0) slackConfig.allowedChannels = allowedChannels; + } + + const discordGuilds: Record = {}; + if (enabledTokenEnvKeys.has("DISCORD_BOT_TOKEN")) { + const serverIds = parseMessagingConfigList(env.DISCORD_SERVER_IDS || env.DISCORD_SERVER_ID); + const userIds = parseMessagingConfigList(env.DISCORD_ALLOWED_IDS || env.DISCORD_USER_ID); + for (const serverId of serverIds) { + if (!discordSnowflakeRe.test(serverId)) { + warn(" Warning: configured Discord server ID does not look like a snowflake."); + } + } + for (const userId of userIds) { + if (!discordSnowflakeRe.test(userId)) { + warn(" Warning: configured Discord user ID does not look like a snowflake."); + } + } + const requireMention = env.DISCORD_REQUIRE_MENTION !== "0"; + for (const serverId of serverIds) { + discordGuilds[serverId] = { + requireMention, + ...(userIds.length > 0 ? { users: userIds } : {}), + }; + } + } + + return { messagingAllowedIds, discordGuilds, slackConfig }; +} + export function getStoredMessagingChannelConfig( sandboxName: string | null, session: Session | null, diff --git a/src/lib/sandbox/channels.test.ts b/src/lib/sandbox/channels.test.ts index febd2dcaff1..176fd847952 100644 --- a/src/lib/sandbox/channels.test.ts +++ b/src/lib/sandbox/channels.test.ts @@ -77,6 +77,9 @@ describe("sandbox-channels KNOWN_CHANNELS", () => { expect(slack?.userIdHelp).toContain("comma-separated member IDs"); expect(slack?.userIdHelp).toContain("not the app or bot user ID"); expect(slack?.allowIdsMode).toBe("dm"); + expect(slack?.channelIdEnvKey).toBe("SLACK_ALLOWED_CHANNELS"); + expect(slack?.channelIdLabel).toBe("Slack Channel IDs (comma-separated allowlist)"); + expect(slack?.channelIdHelp).toContain("Slack channel IDs"); }); it("normalises case and whitespace when resolving a channel name", () => { diff --git a/src/lib/sandbox/channels.ts b/src/lib/sandbox/channels.ts index 4289a94a1f2..56350c876c2 100644 --- a/src/lib/sandbox/channels.ts +++ b/src/lib/sandbox/channels.ts @@ -12,6 +12,9 @@ export interface ChannelBase { userIdHelp?: string; userIdLabel?: string; allowIdsMode?: "dm" | "guild"; + channelIdEnvKey?: string; + channelIdHelp?: string; + channelIdLabel?: string; serverIdEnvKey?: string; serverIdHelp?: string; serverIdLabel?: string; @@ -118,6 +121,10 @@ export const KNOWN_CHANNELS: Record = { "In Slack, open each allowed human user's profile -> More -> Copy member ID. Enter one or more comma-separated member IDs, not the app or bot user ID. Member IDs look like U01ABC2DEF3.", userIdLabel: "Slack Member IDs (comma-separated allowlist)", allowIdsMode: "dm", + channelIdEnvKey: "SLACK_ALLOWED_CHANNELS", + channelIdHelp: + "Optional: enter comma-separated Slack channel IDs where the bot may answer @mentions. Channel IDs look like C012AB3CD.", + channelIdLabel: "Slack Channel IDs (comma-separated allowlist)", }, whatsapp: { description: "WhatsApp Web messaging (QR pairing)", diff --git a/test/generate-hermes-config.test.ts b/test/generate-hermes-config.test.ts index 6871146c66c..4fe6dd7f8e4 100644 --- a/test/generate-hermes-config.test.ts +++ b/test/generate-hermes-config.test.ts @@ -246,6 +246,9 @@ describe("agents/hermes/generate-config.ts", () => { slack: ["U0123456789", "U09ABCDEFGH"], }), NEMOCLAW_TELEGRAM_CONFIG_B64: encodeJson({ requireMention: true }), + NEMOCLAW_SLACK_CONFIG_B64: encodeJson({ + allowedChannels: ["C012AB3CD", "C987ZY6XW"], + }), }); expect(config.telegram).toEqual({ require_mention: true }); @@ -262,6 +265,7 @@ describe("agents/hermes/generate-config.ts", () => { expect(envFile).not.toContain("SLACK_BOT_TOKEN=openshell:resolve:env:SLACK_BOT_TOKEN\n"); expect(envFile).not.toContain("SLACK_APP_TOKEN=openshell:resolve:env:SLACK_APP_TOKEN\n"); expect(envFile).toContain("SLACK_ALLOWED_USERS=U0123456789,U09ABCDEFGH\n"); + expect(envFile).toContain("SLACK_ALLOWED_CHANNELS=C012AB3CD,C987ZY6XW\n"); }); it("bridges captured WeChat metadata to Hermes' WEIXIN_* env contract", () => { diff --git a/test/generate-openclaw-config.test.ts b/test/generate-openclaw-config.test.ts index c0b33092ee3..4264939f38d 100644 --- a/test/generate-openclaw-config.test.ts +++ b/test/generate-openclaw-config.test.ts @@ -572,6 +572,36 @@ describe("generate-openclaw-config.py: config generation", () => { }); }); + it("uses Slack allowed channels to scope channel @mentions", () => { + const allowedUsers = ["U01ABC2DEF3", "U04GHI5JKL6"]; + const allowedChannels = ["C012AB3CD", "C987ZY6XW"]; + const channels = Buffer.from(JSON.stringify(["slack"])).toString("base64"); + const allowedIds = Buffer.from(JSON.stringify({ slack: allowedUsers })).toString("base64"); + const slackConfig = Buffer.from(JSON.stringify({ allowedChannels })).toString("base64"); + const config = runConfigScript({ + NEMOCLAW_MESSAGING_CHANNELS_B64: channels, + NEMOCLAW_MESSAGING_ALLOWED_IDS_B64: allowedIds, + NEMOCLAW_SLACK_CONFIG_B64: slackConfig, + }); + const slack = config.channels.slack.accounts.default; + + expect(slack.dmPolicy).toBe("allowlist"); + expect(slack.allowFrom).toEqual(allowedUsers); + expect(slack.groupPolicy).toBe("allowlist"); + expect(slack.channels).toEqual({ + C012AB3CD: { + enabled: true, + requireMention: true, + users: allowedUsers, + }, + C987ZY6XW: { + enabled: true, + requireMention: true, + users: allowedUsers, + }, + }); + }); + it("enables native OpenClaw Tool Search by default", () => { const config = runConfigScript(); expect(config.tools?.toolSearch).toBe(true);