From 81d426a585426375ab42e79611d4d5dd1693af37 Mon Sep 17 00:00:00 2001 From: Filipe Felisbino Date: Fri, 10 Apr 2026 14:56:14 -0400 Subject: [PATCH 1/2] feat(onboard): add SLACK_ALLOWED_CHANNELS support for channel allowlisting Slack's default groupPolicy is "allowlist" but NemoClaw provided no way to bake channel IDs into openclaw.json at image build time, leaving all channels blocked after onboarding with no CLI workaround. This change adds SLACK_ALLOWED_CHANNELS (comma-separated Slack channel IDs) parallel to the existing TELEGRAM_ALLOWED_IDS mechanism. The IDs are base64-encoded into a new NEMOCLAW_SLACK_ALLOWED_CHANNELS_B64 Dockerfile ARG and written into channels.slack.accounts.default.channels at image build time. - Dockerfile: add NEMOCLAW_SLACK_ALLOWED_CHANNELS_B64 ARG/ENV and write the channels map in the Python config generation script - src/lib/onboard.ts: add userIdEnvKey/allowIdsMode to the Slack MESSAGING_CHANNELS entry; collect and inject slackAllowedChannels into patchStagedDockerfile - test/onboard.test.ts: add test for Slack channel allowlist build arg - docs/deployment/set-up-slack-bridge.md: new how-to guide for Slack setup including Socket Mode, token collection, and channel allowlisting - docs/reference/architecture.md: document SLACK_BOT_TOKEN, SLACK_APP_TOKEN, and SLACK_ALLOWED_CHANNELS env vars --- .../nemoclaw-user-deploy-remote/SKILL.md | 101 +++++++++++++++- .../references/architecture.md | 3 + Dockerfile | 8 ++ docs/deployment/set-up-slack-bridge.md | 113 ++++++++++++++++++ docs/reference/architecture.md | 3 + src/lib/onboard.ts | 25 +++- test/onboard.test.ts | 50 ++++++++ 7 files changed, 296 insertions(+), 7 deletions(-) create mode 100644 docs/deployment/set-up-slack-bridge.md diff --git a/.agents/skills/nemoclaw-user-deploy-remote/SKILL.md b/.agents/skills/nemoclaw-user-deploy-remote/SKILL.md index 486cb70da09..d7afa36702f 100644 --- a/.agents/skills/nemoclaw-user-deploy-remote/SKILL.md +++ b/.agents/skills/nemoclaw-user-deploy-remote/SKILL.md @@ -1,6 +1,6 @@ --- name: "nemoclaw-user-deploy-remote" -description: "Explains how to run NemoClaw on a remote GPU instance, including the deprecated Brev compatibility path and the preferred installer plus onboard flow. Describes security hardening measures applied to the NemoClaw sandbox container image. Use when reviewing container security, Docker capabilities, process limits, or sandbox hardening controls. Explains how Telegram reaches the sandboxed OpenClaw agent through OpenShell-managed processes and onboarding-time channel configuration. Use when setting up Telegram, a chat interface, or messaging integration without relying on nemoclaw start for bridges." +description: "Explains how to run NemoClaw on a remote GPU instance, including the deprecated Brev compatibility path and the preferred installer plus onboard flow. Describes security hardening measures applied to the NemoClaw sandbox container image. Use when reviewing container security, Docker capabilities, process limits, or sandbox hardening controls. Explains how Slack reaches the sandboxed OpenClaw agent through OpenShell-managed processes and onboarding-time channel configuration. Use when setting up Slack, a chat interface, or messaging integration. Explains how Telegram reaches the sandboxed OpenClaw agent through OpenShell-managed processes and onboarding-time channel configuration. Use when setting up Telegram, a chat interface, or messaging integration without relying on nemoclaw start for bridges." --- @@ -15,6 +15,8 @@ Explains how to run NemoClaw on a remote GPU instance, including the deprecated - The [Brev CLI](https://brev.nvidia.com) installed and authenticated. - A provider credential for the inference backend you want to use during onboarding. - NemoClaw installed locally if you plan to use the deprecated `nemoclaw deploy` wrapper. Otherwise, install NemoClaw directly on the remote host after provisioning it. +- A Slack workspace where you can install apps. +- NemoClaw installed and `openshell` available on your host. - A machine where you can run `nemoclaw onboard` (local or remote host that runs the gateway and sandbox). - A Telegram bot token from [BotFather](https://t.me/BotFather). @@ -135,18 +137,105 @@ $ nemoclaw deploy --- +NemoClaw supports Slack via Socket Mode — a persistent WebSocket connection that does not require a public URL or inbound firewall rules. The bot and app tokens are stored by OpenShell as secure providers; the sandbox receives placeholder values, not the raw secrets. + +## Step 9: Create a Slack App + +1. Go to [api.slack.com/apps](https://api.slack.com/apps) and click **Create New App → From scratch**. +2. Give the app a name and select your workspace. + +### Enable Socket Mode + +1. In the app settings sidebar, select **Socket Mode** and toggle it on. +2. Under **App-Level Tokens**, click **Generate Token and Scopes**. +3. Give the token a name, add the `connections:write` scope, and click **Generate**. +4. Copy the token — it starts with `xapp-`. This is your `SLACK_APP_TOKEN`. + +### Add Bot Scopes + +1. In the sidebar select **OAuth & Permissions**. +2. Under **Bot Token Scopes** add at minimum: `chat:write`, `channels:history`, `channels:read`, `app_mentions:read`. +3. Click **Install to Workspace** and copy the **Bot User OAuth Token** (starts with `xoxb-`). This is your `SLACK_BOT_TOKEN`. + +### Enable Event Subscriptions + +1. In the sidebar select **Event Subscriptions** and toggle on **Enable Events**. +2. Under **Subscribe to bot events** add: `message.channels`, `app_mention`. +3. Save changes. + +## Step 10: Provide Tokens and Optional Channel Allowlist + +Onboarding reads Slack credentials from either host environment variables or the NemoClaw credential store. You do not have to export variables if you enter the tokens when the wizard asks. + +### Option A: Environment variables (CI, scripts, or before you start the wizard) + +```console +$ export SLACK_BOT_TOKEN=xoxb-... +$ export SLACK_APP_TOKEN=xapp-... +``` + +Optional comma-separated channel ID allowlist. When set, only messages from these channels trigger the agent. Leave blank to block all channels (the default `groupPolicy` is `allowlist` with no channels configured): + +```console +$ export SLACK_ALLOWED_CHANNELS="C012AB3CD,C987ZY6XW" +``` + +Channel IDs are stable identifiers — find them in Slack by right-clicking a channel, selecting **View channel details**, and copying the ID from the bottom of the **About** tab. + +### Option B: Interactive `nemoclaw onboard` + +When the wizard reaches **Messaging channels**, it lists Telegram, Discord, and Slack. +Toggle Slack on, then enter the bot token and app token when prompted. +If `SLACK_ALLOWED_CHANNELS` is not set, the wizard prompts for channel IDs — you can leave this blank and add channels later by re-running `nemoclaw onboard --recreate-sandbox`. + +## Step 11: Run `nemoclaw onboard` + +```console +$ nemoclaw onboard +``` + +NemoClaw bakes channel configuration into the sandbox image at build time (`NEMOCLAW_SLACK_ALLOWED_CHANNELS_B64`), creates an OpenShell provider for the bot token, and starts the sandbox. + +Channel entries in `/sandbox/.openclaw/openclaw.json` are fixed at image build time. Landlock keeps that path read-only at runtime, so you cannot patch messaging config inside a running sandbox. + +If you add or change tokens or channel IDs after a sandbox already exists, re-run: + +```console +$ SLACK_ALLOWED_CHANNELS="C012AB3CD" nemoclaw onboard --recreate-sandbox +``` + +## Step 12: Apply the Slack Network Policy Preset + +The `slack` policy preset opens the required egress endpoints (Slack REST API, Socket Mode WebSocket). Apply it after onboarding if it was not selected during the wizard: + +```console +$ nemoclaw policy-add +``` + +Select `slack` from the menu. + +## Step 13: Confirm Delivery + +After the sandbox is running, invite the bot to one of your allowlisted channels in Slack and send a message. If the bot does not respond, check: + +- The channel ID in `SLACK_ALLOWED_CHANNELS` matches the channel you are posting in. +- The `slack` policy preset is applied (`nemoclaw policy-list`). +- Gateway logs inside the sandbox: `openshell sandbox connect ` then `tail -f /tmp/gateway.log`. + +--- + Telegram, Discord, and Slack reach your agent through OpenShell-managed processes and gateway constructs. NemoClaw configures those channels during `nemoclaw onboard`. Tokens are registered with OpenShell providers, channel configuration is baked into the sandbox image, and runtime delivery stays under OpenShell control. `nemoclaw start` does not start Telegram (or other chat bridges). It only starts optional host services such as the cloudflared tunnel when that binary is present. For details, refer to Commands (see the `nemoclaw-user-reference` skill). -## Step 9: Create a Telegram Bot +## Step 14: Create a Telegram Bot Open Telegram and send `/newbot` to [@BotFather](https://t.me/BotFather). Follow the prompts to create a bot and copy the bot token. -## Step 10: Provide the Bot Token and Optional Allowlist +## Step 15: Provide the Bot Token and Optional Allowlist Onboarding reads Telegram credentials from either host environment variables or the NemoClaw credential store (`getCredential` / `saveCredential` in the onboard flow). You do not have to export variables if you enter the token when the wizard asks. @@ -171,7 +260,7 @@ If `TELEGRAM_ALLOWED_IDS` is not set, the wizard can prompt for allowed sender I NemoClaw applies that allowlist to Telegram DMs only. Group chats stay open by default so rebuilt sandboxes do not silently drop Telegram group messages because of an empty group allowlist. -## Step 11: Run `nemoclaw onboard` +## Step 16: Run `nemoclaw onboard` Complete the rest of the wizard so the blueprint can create OpenShell providers (for example `-telegram-bridge`), bake channel configuration into the image (`NEMOCLAW_MESSAGING_CHANNELS_B64`), and start the sandbox. @@ -181,12 +270,12 @@ If you add or change `TELEGRAM_BOT_TOKEN` (or toggle channels) after a sandbox a For a full first-time flow, refer to Quickstart (see the `nemoclaw-user-get-started` skill). -## Step 12: Confirm Delivery +## Step 17: Confirm Delivery After the sandbox is running, send a message to your bot in Telegram. If something fails, use `openshell term` on the host, check gateway logs, and verify network policy allows the Telegram API (see Customize the Network Policy (see the `nemoclaw-user-manage-policy` skill) and the `telegram` preset). -## Step 13: `nemoclaw start` (cloudflared Only) +## Step 18: `nemoclaw start` (cloudflared Only) `nemoclaw start` starts cloudflared when it is installed, which can expose the dashboard with a public URL. It does not affect Telegram connectivity. diff --git a/.agents/skills/nemoclaw-user-reference/references/architecture.md b/.agents/skills/nemoclaw-user-reference/references/architecture.md index c45ea7c9d62..9b09916f8d2 100644 --- a/.agents/skills/nemoclaw-user-reference/references/architecture.md +++ b/.agents/skills/nemoclaw-user-reference/references/architecture.md @@ -168,6 +168,9 @@ 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_CHANNELS` | Comma-separated Slack channel IDs to allowlist (e.g. `C012AB3CD,C987ZY6XW`). Baked into the sandbox image at build time. When unset, no channels are allowed by default. | | `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/Dockerfile b/Dockerfile index 70815cc740e..1322d1b951a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -74,6 +74,10 @@ ARG NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=e30= # (e.g. {"1234567890":{"requireMention":true,"users":["555"]}}). # Used to enable guild-channel responses for native Discord. Default: empty map. ARG NEMOCLAW_DISCORD_GUILDS_B64=e30= +# Base64-encoded JSON list of Slack channel IDs to allowlist +# (e.g. ["C012AB3CD","C987ZY6XW"]). When set, only messages from these channels +# are accepted; groupPolicy stays "allowlist". Default: empty list (block all). +ARG NEMOCLAW_SLACK_ALLOWED_CHANNELS_B64=W10= # Set to "1" to disable device-pairing auth (development/headless only). # Default: "0" (device auth enabled — secure by default). ARG NEMOCLAW_DISABLE_DEVICE_AUTH=0 @@ -101,6 +105,7 @@ ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \ NEMOCLAW_MESSAGING_CHANNELS_B64=${NEMOCLAW_MESSAGING_CHANNELS_B64} \ NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=${NEMOCLAW_MESSAGING_ALLOWED_IDS_B64} \ NEMOCLAW_DISCORD_GUILDS_B64=${NEMOCLAW_DISCORD_GUILDS_B64} \ + NEMOCLAW_SLACK_ALLOWED_CHANNELS_B64=${NEMOCLAW_SLACK_ALLOWED_CHANNELS_B64} \ NEMOCLAW_DISABLE_DEVICE_AUTH=${NEMOCLAW_DISABLE_DEVICE_AUTH} \ NEMOCLAW_PROXY_HOST=${NEMOCLAW_PROXY_HOST} \ NEMOCLAW_PROXY_PORT=${NEMOCLAW_PROXY_PORT} @@ -127,10 +132,13 @@ web_config = json.loads(base64.b64decode(os.environ.get('NEMOCLAW_WEB_CONFIG_B64 msg_channels = json.loads(base64.b64decode(os.environ.get('NEMOCLAW_MESSAGING_CHANNELS_B64', 'W10=') or 'W10=').decode('utf-8')); \ _allowed_ids = json.loads(base64.b64decode(os.environ.get('NEMOCLAW_MESSAGING_ALLOWED_IDS_B64', 'e30=') or 'e30=').decode('utf-8')); \ _discord_guilds = json.loads(base64.b64decode(os.environ.get('NEMOCLAW_DISCORD_GUILDS_B64', 'e30=') or 'e30=').decode('utf-8')); \ +_slack_allowed_channels = json.loads(base64.b64decode(os.environ.get('NEMOCLAW_SLACK_ALLOWED_CHANNELS_B64', 'W10=') or 'W10=').decode('utf-8')); \ _token_keys = {'discord': 'token', 'telegram': 'botToken', 'slack': 'botToken'}; \ _env_keys = {'discord': 'DISCORD_BOT_TOKEN', 'telegram': 'TELEGRAM_BOT_TOKEN', 'slack': 'SLACK_BOT_TOKEN'}; \ _ch_cfg = {ch: {'accounts': {'default': {_token_keys[ch]: f'openshell:resolve:env:{_env_keys[ch]}', 'enabled': True, **({'groupPolicy': 'open'} if ch == 'telegram' else {}), **({'dmPolicy': 'allowlist', 'allowFrom': _allowed_ids[ch]} if ch in _allowed_ids and _allowed_ids[ch] else {})}}} for ch in msg_channels if ch in _token_keys}; \ +_ch_cfg['slack']['accounts']['default'].update({'appToken': 'openshell:resolve:env:SLACK_APP_TOKEN'}) if 'slack' in _ch_cfg else None; \ _ch_cfg['discord'].update({'groupPolicy': 'allowlist', 'guilds': _discord_guilds}) if 'discord' in _ch_cfg and _discord_guilds else None; \ +_ch_cfg['slack']['accounts']['default'].update({'channels': {ch_id: {'enabled': True} for ch_id in _slack_allowed_channels}}) if 'slack' in _ch_cfg and _slack_allowed_channels else None; \ parsed = urlparse(chat_ui_url); \ chat_origin = f'{parsed.scheme}://{parsed.netloc}' if parsed.scheme and parsed.netloc else 'http://127.0.0.1:18789'; \ origins = ['http://127.0.0.1:18789']; \ diff --git a/docs/deployment/set-up-slack-bridge.md b/docs/deployment/set-up-slack-bridge.md new file mode 100644 index 00000000000..8d5186f6381 --- /dev/null +++ b/docs/deployment/set-up-slack-bridge.md @@ -0,0 +1,113 @@ +--- +title: + page: "Set Up Slack with NemoClaw and OpenShell" + nav: "Set Up Slack" +description: + main: "Connect Slack to your sandboxed OpenClaw agent using OpenShell-managed channel messaging configured during onboarding." + agent: "Explains how Slack reaches the sandboxed OpenClaw agent through OpenShell-managed processes and onboarding-time channel configuration. Use when setting up Slack, a chat interface, or messaging integration." +keywords: ["nemoclaw slack", "slack bot openclaw agent", "openshell channel messaging", "slack socket mode"] +topics: ["generative_ai", "ai_agents"] +tags: ["openclaw", "openshell", "slack", "deployment", "nemoclaw"] +content: + type: how_to + difficulty: intermediate + audience: ["developer", "engineer"] +status: published +--- + + + +# Set Up Slack with NemoClaw and OpenShell + +NemoClaw supports Slack via Socket Mode — a persistent WebSocket connection that does not require a public URL or inbound firewall rules. The bot and app tokens are stored by OpenShell as secure providers; the sandbox receives placeholder values, not the raw secrets. + +## Prerequisites + +- A Slack workspace where you can install apps. +- NemoClaw installed and `openshell` available on your host. + +## Create a Slack App + +1. Go to [api.slack.com/apps](https://api.slack.com/apps) and click **Create New App → From scratch**. +2. Give the app a name and select your workspace. + +### Enable Socket Mode + +1. In the app settings sidebar, select **Socket Mode** and toggle it on. +2. Under **App-Level Tokens**, click **Generate Token and Scopes**. +3. Give the token a name, add the `connections:write` scope, and click **Generate**. +4. Copy the token — it starts with `xapp-`. This is your `SLACK_APP_TOKEN`. + +### Add Bot Scopes + +1. In the sidebar select **OAuth & Permissions**. +2. Under **Bot Token Scopes** add at minimum: `chat:write`, `channels:history`, `channels:read`, `app_mentions:read`. +3. Click **Install to Workspace** and copy the **Bot User OAuth Token** (starts with `xoxb-`). This is your `SLACK_BOT_TOKEN`. + +### Enable Event Subscriptions + +1. In the sidebar select **Event Subscriptions** and toggle on **Enable Events**. +2. Under **Subscribe to bot events** add: `message.channels`, `app_mention`. +3. Save changes. + +## Provide Tokens and Optional Channel Allowlist + +Onboarding reads Slack credentials from either host environment variables or the NemoClaw credential store. You do not have to export variables if you enter the tokens when the wizard asks. + +### Option A: Environment variables (CI, scripts, or before you start the wizard) + +```console +$ export SLACK_BOT_TOKEN=xoxb-... +$ export SLACK_APP_TOKEN=xapp-... +``` + +Optional comma-separated channel ID allowlist. When set, only messages from these channels trigger the agent. Leave blank to block all channels (the default `groupPolicy` is `allowlist` with no channels configured): + +```console +$ export SLACK_ALLOWED_CHANNELS="C012AB3CD,C987ZY6XW" +``` + +Channel IDs are stable identifiers — find them in Slack by right-clicking a channel, selecting **View channel details**, and copying the ID from the bottom of the **About** tab. + +### Option B: Interactive `nemoclaw onboard` + +When the wizard reaches **Messaging channels**, it lists Telegram, Discord, and Slack. +Toggle Slack on, then enter the bot token and app token when prompted. +If `SLACK_ALLOWED_CHANNELS` is not set, the wizard prompts for channel IDs — you can leave this blank and add channels later by re-running `nemoclaw onboard --recreate-sandbox`. + +## Run `nemoclaw onboard` + +```console +$ nemoclaw onboard +``` + +NemoClaw bakes channel configuration into the sandbox image at build time (`NEMOCLAW_SLACK_ALLOWED_CHANNELS_B64`), creates an OpenShell provider for the bot token, and starts the sandbox. + +Channel entries in `/sandbox/.openclaw/openclaw.json` are fixed at image build time. Landlock keeps that path read-only at runtime, so you cannot patch messaging config inside a running sandbox. + +If you add or change tokens or channel IDs after a sandbox already exists, re-run: + +```console +$ SLACK_ALLOWED_CHANNELS="C012AB3CD" nemoclaw onboard --recreate-sandbox +``` + +## Apply the Slack Network Policy Preset + +The `slack` policy preset opens the required egress endpoints (Slack REST API, Socket Mode WebSocket). Apply it after onboarding if it was not selected during the wizard: + +```console +$ nemoclaw policy-add +``` + +Select `slack` from the menu. + +## Confirm Delivery + +After the sandbox is running, invite the bot to one of your allowlisted channels in Slack and send a message. If the bot does not respond, check: + +- The channel ID in `SLACK_ALLOWED_CHANNELS` matches the channel you are posting in. +- The `slack` policy preset is applied (`nemoclaw policy-list`). +- Gateway logs inside the sandbox: `openshell sandbox connect ` then `tail -f /tmp/gateway.log`. diff --git a/docs/reference/architecture.md b/docs/reference/architecture.md index 0c516b6eb5b..c8c173d0fe1 100644 --- a/docs/reference/architecture.md +++ b/docs/reference/architecture.md @@ -188,6 +188,9 @@ 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_CHANNELS` | Comma-separated Slack channel IDs to allowlist (e.g. `C012AB3CD,C987ZY6XW`). Baked into the sandbox image at build time. When unset, no channels are allowed by default. | | `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/src/lib/onboard.ts b/src/lib/onboard.ts index 5dbb26affc0..a8eb6fed1d4 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -986,6 +986,7 @@ function patchStagedDockerfile( messagingChannels = [], messagingAllowedIds = {}, discordGuilds = {}, + slackAllowedChannels = [], ) { const { providerKey, primaryModelRef, inferenceBaseUrl, inferenceApi, inferenceCompat } = getSandboxInferenceConfig(model, provider, preferredInferenceApi); @@ -1064,6 +1065,12 @@ function patchStagedDockerfile( `ARG NEMOCLAW_DISCORD_GUILDS_B64=${encodeDockerJsonArg(discordGuilds)}`, ); } + if (slackAllowedChannels.length > 0) { + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_SLACK_ALLOWED_CHANNELS_B64=.*$/m, + `ARG NEMOCLAW_SLACK_ALLOWED_CHANNELS_B64=${encodeDockerJsonArg(slackAllowedChannels)}`, + ); + } fs.writeFileSync(dockerfilePath, dockerfile); } @@ -2662,6 +2669,16 @@ async function createSandbox( }; } } + // Build Slack channel allowlist from SLACK_ALLOWED_CHANNELS env var. + // Stored separately from messagingAllowedIds because it maps to openclaw.json + // `channels.slack.accounts.default.channels` rather than dmPolicy/allowFrom. + const slackAllowedChannels = []; + if (enabledTokenEnvKeys.has("SLACK_BOT_TOKEN") && process.env.SLACK_ALLOWED_CHANNELS) { + const ids = process.env.SLACK_ALLOWED_CHANNELS.split(",") + .map((s) => s.trim()) + .filter(Boolean); + slackAllowedChannels.push(...ids); + } patchStagedDockerfile( stagedDockerfile, model, @@ -2673,6 +2690,7 @@ async function createSandbox( activeMessagingChannels, messagingAllowedIds, discordGuilds, + slackAllowedChannels, ); // Only pass non-sensitive env vars to the sandbox. Credentials flow through // OpenShell providers — the gateway injects them as placeholders and the L7 @@ -3686,8 +3704,13 @@ const MESSAGING_CHANNELS = [ label: "Slack Bot Token", appTokenEnvKey: "SLACK_APP_TOKEN", appTokenHelp: - "Slack API → Your Apps → Basic Information → App-Level Tokens (xapp-...).", + "Slack API → Your Apps → Basic Information → App-Level Tokens → Generate (scope: connections:write). Required for Socket Mode (xapp-...).", appTokenLabel: "Slack App Token (Socket Mode)", + userIdEnvKey: "SLACK_ALLOWED_CHANNELS", + userIdHelp: + "Optional: comma-separated Slack channel IDs to allowlist (e.g. C012AB3CD,C987ZY6XW). Leave blank to block all channels until added manually.", + userIdLabel: "Slack Channel IDs (optional allowlist)", + allowIdsMode: "channel", }, ]; diff --git a/test/onboard.test.ts b/test/onboard.test.ts index c407a5fd881..01998998486 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -231,6 +231,56 @@ describe("onboard helpers", () => { } }); + it("patches the staged Dockerfile with Slack channel allowlist", () => { + const tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-onboard-dockerfile-slack-channels-"), + ); + const dockerfilePath = path.join(tmpDir, "Dockerfile"); + fs.writeFileSync( + dockerfilePath, + [ + "ARG NEMOCLAW_MODEL=nvidia/nemotron-3-super-120b-a12b", + "ARG NEMOCLAW_PROVIDER_KEY=nvidia", + "ARG NEMOCLAW_PRIMARY_MODEL_REF=nvidia/nemotron-3-super-120b-a12b", + "ARG CHAT_UI_URL=http://127.0.0.1:18789", + "ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30=", + "ARG NEMOCLAW_WEB_CONFIG_B64=e30=", + "ARG NEMOCLAW_MESSAGING_CHANNELS_B64=W10=", + "ARG NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=e30=", + "ARG NEMOCLAW_DISCORD_GUILDS_B64=e30=", + "ARG NEMOCLAW_SLACK_ALLOWED_CHANNELS_B64=W10=", + "ARG NEMOCLAW_BUILD_ID=default", + ].join("\n"), + ); + + try { + patchStagedDockerfile( + dockerfilePath, + "gpt-5.4", + "http://127.0.0.1:19999", + "build-slack-channels", + "openai-api", + null, + null, + ["slack"], + {}, + {}, + ["C012AB3CD", "C987ZY6XW"], + ); + const patched = fs.readFileSync(dockerfilePath, "utf8"); + assert.match(patched, /^ARG NEMOCLAW_MESSAGING_CHANNELS_B64=/m); + const channelsLine = patched + .split("\n") + .find((line) => line.startsWith("ARG NEMOCLAW_SLACK_ALLOWED_CHANNELS_B64=")); + assert.ok(channelsLine, "expected slack allowed channels build arg"); + const encoded = channelsLine.split("=")[1]; + const decoded = JSON.parse(Buffer.from(encoded, "base64").toString("utf8")); + assert.deepEqual(decoded, ["C012AB3CD", "C987ZY6XW"]); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it("patches the staged Dockerfile with Discord guild config that allows all server members", () => { const tmpDir = fs.mkdtempSync( path.join(os.tmpdir(), "nemoclaw-onboard-dockerfile-discord-open-"), From 75e1e230206e1ac39863412818226754563258c4 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Mon, 25 May 2026 07:29:45 -0700 Subject: [PATCH 2/2] refactor(onboard): extract messaging build config Signed-off-by: Aaron Erickson --- src/lib/onboard.ts | 53 ++-------------- src/lib/onboard/messaging-config.test.ts | 81 ++++++++++++++++++++++++ src/lib/onboard/messaging-config.ts | 77 ++++++++++++++++++++++ 3 files changed, 165 insertions(+), 46 deletions(-) create mode 100644 src/lib/onboard/messaging-config.test.ts diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index a44b8722c6f..f37ecc335b1 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -244,7 +244,6 @@ const { LOCAL_INFERENCE_PROVIDERS, OLLAMA_PROXY_CREDENTIAL_ENV, VLLM_LOCAL_CREDENTIAL_ENV, - DISCORD_SNOWFLAKE_RE, getProviderLabel, getEffectiveProviderName, getNonInteractiveProvider, @@ -257,7 +256,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; @@ -366,6 +364,7 @@ const { getRecordedMessagingChannelsForResume: getRecordedMessagingChannelsForResumeFromState, }: typeof import("./onboard/messaging-credentials") = require("./onboard/messaging-credentials"); const { + collectMessagingBuildConfig, computeTelegramRequireMention, getStoredMessagingChannelConfig, messagingChannelConfigsEqual, @@ -3606,52 +3605,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); - const parseConfigList = (value: unknown): string[] => - String(value ?? "") - .split(",") - .map((s) => s.replace(/[\r\n]/g, "").trim()) - .filter(Boolean); - for (const ch of MESSAGING_CHANNELS) { - if (activeChannelNames.has(ch.name) && ch.userIdEnvKey && process.env[ch.userIdEnvKey]) { - const ids = parseConfigList(process.env[ch.userIdEnvKey]); - if (ids.length > 0) messagingAllowedIds[ch.name] = ids; - } - } - const slackConfig: Record = {}; - if (activeChannelNames.has("slack") && process.env.SLACK_ALLOWED_CHANNELS) { - const allowedChannels = parseConfigList(process.env.SLACK_ALLOWED_CHANNELS); - if (allowedChannels.length > 0) slackConfig.allowedChannels = allowedChannels; - } - const discordGuilds: Record = {}; - if (enabledTokenEnvKeys.has("DISCORD_BOT_TOKEN")) { - const serverIds = parseConfigList( - process.env.DISCORD_SERVER_IDS || process.env.DISCORD_SERVER_ID, - ); - const userIds = parseConfigList(process.env.DISCORD_ALLOWED_IDS || process.env.DISCORD_USER_ID); - 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. 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,