diff --git a/.agents/skills/nemoclaw-user-reference/references/commands.md b/.agents/skills/nemoclaw-user-reference/references/commands.md index 7f8e9c5fc74..7f60dfcfb91 100644 --- a/.agents/skills/nemoclaw-user-reference/references/commands.md +++ b/.agents/skills/nemoclaw-user-reference/references/commands.md @@ -259,6 +259,49 @@ $ nemoclaw my-assistant policy-remove Unchecking a preset in the onboard TUI checkbox also removes it from the sandbox. +### `nemoclaw channels list` + +List the messaging channels NemoClaw knows about (`telegram`, `discord`, `slack`) with a short description. +The command is a static reference; it does not consult credentials or the running sandbox. + +```console +$ nemoclaw my-assistant channels list +``` + +### `nemoclaw channels add ` + +Store credentials for a messaging channel (`telegram`, `discord`, or `slack`) and rebuild the sandbox so the image picks up the new channel. +The command prompts for any missing token, persists it under `~/.nemoclaw/credentials.json`, then asks whether to rebuild immediately. +Running `add` for an already-configured channel simply overwrites the stored tokens — the operation is idempotent. + +```console +$ nemoclaw my-assistant channels add telegram +``` + +| Flag | Description | +|------|-------------| +| `--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. +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. + +### `nemoclaw channels remove ` + +Clear the stored credentials for a messaging channel and rebuild the sandbox so the image drops the channel. +Running `remove` for a channel that was never configured is a no-op against the credentials file and still triggers the rebuild prompt. + +```console +$ nemoclaw my-assistant channels remove telegram +``` + +| Flag | Description | +|------|-------------| +| `--dry-run` | Report the channel that would be removed without clearing credentials or rebuilding | + +As with `channels add`, `NEMOCLAW_NON_INTERACTIVE=1` skips the rebuild prompt and queues the change for a manual `nemoclaw rebuild`. + +Host-side removal is the supported path because `/sandbox/.openclaw/openclaw.json` is read-only at runtime; `openclaw channels remove` cannot modify the baked config from inside the sandbox. + ### `nemoclaw skill install ` Deploy a skill directory to a running sandbox. diff --git a/.agents/skills/nemoclaw-user-reference/references/troubleshooting.md b/.agents/skills/nemoclaw-user-reference/references/troubleshooting.md index 6dc147b31e3..f50f02f1ecf 100644 --- a/.agents/skills/nemoclaw-user-reference/references/troubleshooting.md +++ b/.agents/skills/nemoclaw-user-reference/references/troubleshooting.md @@ -419,6 +419,24 @@ Changing or exporting it later does not rewrite the baked `openclaw.json` inside If you need a different device-auth setting, rerun onboarding so NemoClaw rebuilds the sandbox image with the desired configuration. For the security trade-offs, refer to Security Best Practices (see the `nemoclaw-user-configure-security` skill). +### `openclaw channels add` or `remove` is blocked inside the sandbox + +This is expected. +The messaging channel list is frozen into the sandbox's container image when the image is built during `nemoclaw onboard` or `nemoclaw rebuild` (the selected channel names are passed to the `docker build` as `NEMOCLAW_MESSAGING_CHANNELS_B64` and written into `/sandbox/.openclaw/openclaw.json` as part of the image). +At runtime the sandbox mounts that path read-only and layers Landlock + filesystem hardening on top, so `openclaw channels` commands that mutate the config cannot write there. +NemoClaw's sandbox entrypoint installs a guard that intercepts `openclaw channels ` and prints an actionable error pointing at the host-side commands below, instead of letting the call fail deep in the binary with a raw `EACCES` trace. + +Run the equivalent host-side command instead: + +```console +$ nemoclaw channels list +$ nemoclaw channels add +$ nemoclaw channels remove +``` + +`channels add` stores credentials under `~/.nemoclaw/credentials.json` and `channels remove` clears them; both offer to rebuild the sandbox so the image reflects the new channel set. +In non-interactive mode (`NEMOCLAW_NON_INTERACTIVE=1`), the commands stage the change and leave the rebuild to a follow-up `nemoclaw rebuild`. + ### `openclaw doctor --fix` cannot repair Discord channel config inside the sandbox This is expected in NemoClaw-managed sandboxes. diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 6304e9f129f..36849a1455f 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -285,6 +285,49 @@ $ nemoclaw my-assistant policy-remove Unchecking a preset in the onboard TUI checkbox also removes it from the sandbox. +### `nemoclaw channels list` + +List the messaging channels NemoClaw knows about (`telegram`, `discord`, `slack`) with a short description. +The command is a static reference; it does not consult credentials or the running sandbox. + +```console +$ nemoclaw my-assistant channels list +``` + +### `nemoclaw channels add ` + +Store credentials for a messaging channel (`telegram`, `discord`, or `slack`) and rebuild the sandbox so the image picks up the new channel. +The command prompts for any missing token, persists it under `~/.nemoclaw/credentials.json`, then asks whether to rebuild immediately. +Running `add` for an already-configured channel simply overwrites the stored tokens — the operation is idempotent. + +```console +$ nemoclaw my-assistant channels add telegram +``` + +| Flag | Description | +|------|-------------| +| `--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. +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. + +### `nemoclaw channels remove ` + +Clear the stored credentials for a messaging channel and rebuild the sandbox so the image drops the channel. +Running `remove` for a channel that was never configured is a no-op against the credentials file and still triggers the rebuild prompt. + +```console +$ nemoclaw my-assistant channels remove telegram +``` + +| Flag | Description | +|------|-------------| +| `--dry-run` | Report the channel that would be removed without clearing credentials or rebuilding | + +As with `channels add`, `NEMOCLAW_NON_INTERACTIVE=1` skips the rebuild prompt and queues the change for a manual `nemoclaw rebuild`. + +Host-side removal is the supported path because `/sandbox/.openclaw/openclaw.json` is read-only at runtime; `openclaw channels remove` cannot modify the baked config from inside the sandbox. + ### `nemoclaw skill install ` Deploy a skill directory to a running sandbox. diff --git a/docs/reference/troubleshooting.md b/docs/reference/troubleshooting.md index f9adb70a7a9..464e21c436f 100644 --- a/docs/reference/troubleshooting.md +++ b/docs/reference/troubleshooting.md @@ -449,6 +449,24 @@ Changing or exporting it later does not rewrite the baked `openclaw.json` inside If you need a different device-auth setting, rerun onboarding so NemoClaw rebuilds the sandbox image with the desired configuration. For the security trade-offs, refer to [Security Best Practices](../security/best-practices.md). +### `openclaw channels add` or `remove` is blocked inside the sandbox + +This is expected. +The messaging channel list is frozen into the sandbox's container image when the image is built during `nemoclaw onboard` or `nemoclaw rebuild` (the selected channel names are passed to the `docker build` as `NEMOCLAW_MESSAGING_CHANNELS_B64` and written into `/sandbox/.openclaw/openclaw.json` as part of the image). +At runtime the sandbox mounts that path read-only and layers Landlock + filesystem hardening on top, so `openclaw channels` commands that mutate the config cannot write there. +NemoClaw's sandbox entrypoint installs a guard that intercepts `openclaw channels ` and prints an actionable error pointing at the host-side commands below, instead of letting the call fail deep in the binary with a raw `EACCES` trace. + +Run the equivalent host-side command instead: + +```console +$ nemoclaw channels list +$ nemoclaw channels add +$ nemoclaw channels remove +``` + +`channels add` stores credentials under `~/.nemoclaw/credentials.json` and `channels remove` clears them; both offer to rebuild the sandbox so the image reflects the new channel set. +In non-interactive mode (`NEMOCLAW_NON_INTERACTIVE=1`), the commands stage the change and leave the rebuild to a follow-up `nemoclaw rebuild`. + ### `openclaw doctor --fix` cannot repair Discord channel config inside the sandbox This is expected in NemoClaw-managed sandboxes. diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 5b263b95ab4..32191542496 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -499,6 +499,22 @@ openclaw() { ;; esac ;; + channels) + case "$2" in + list | "" | -h | --help) ;; + *) + echo "Error: 'openclaw channels $2' cannot modify channels inside the sandbox." >&2 + echo "The sandbox config is read-only (Landlock enforced) for security." >&2 + echo "" >&2 + echo "To add or remove messaging channels, exit the sandbox and run:" >&2 + echo " nemoclaw channels add " >&2 + echo " nemoclaw channels remove " >&2 + echo "" >&2 + echo "These stage the change and rebuild the sandbox to apply it." >&2 + return 1 + ;; + esac + ;; agent) # Block --local inside sandbox — it bypasses gateway protections and can # crash the container's main process, bricking the sandbox. Ref: #1632, #2016 diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 28de5994ea9..56755ce447e 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -89,6 +89,8 @@ const sandboxCreateStream = require("./sandbox-create-stream"); const validationRecovery = require("./validation-recovery"); const webSearch = require("./web-search"); +import { listChannels } from "./sandbox-channels"; + /** * Create a temp file inside a directory with a cryptographically random name. * Uses fs.mkdtempSync (OS-level mkdtemp) to avoid predictable filenames that @@ -4498,48 +4500,7 @@ async function setupInference( // ── Step 6: Messaging channels ─────────────────────────────────── -const MESSAGING_CHANNELS = [ - { - name: "telegram", - envKey: "TELEGRAM_BOT_TOKEN", - description: "Telegram bot messaging", - help: "Create a bot via @BotFather on Telegram, then copy the token.", - label: "Telegram Bot Token", - userIdEnvKey: "TELEGRAM_ALLOWED_IDS", - userIdHelp: "Send /start to @userinfobot on Telegram to get your numeric user ID.", - userIdLabel: "Telegram User ID (for DM access)", - allowIdsMode: "dm", - }, - { - name: "discord", - envKey: "DISCORD_BOT_TOKEN", - description: "Discord bot messaging", - help: "Discord Developer Portal → Applications → Bot → Reset/Copy Token.", - label: "Discord Bot Token", - serverIdEnvKey: "DISCORD_SERVER_ID", - serverIdHelp: - "Enable Developer Mode in Discord, then right-click your server and copy the Server ID.", - serverIdLabel: "Discord Server ID (for guild workspace access)", - requireMentionEnvKey: "DISCORD_REQUIRE_MENTION", - requireMentionHelp: - "Choose whether the bot should reply only when @mentioned or to all messages in this server.", - userIdEnvKey: "DISCORD_USER_ID", - userIdHelp: - "Optional: enable Developer Mode in Discord, then right-click your user/avatar and copy the User ID. Leave blank to allow any member of the configured server to message the bot.", - userIdLabel: "Discord User ID (optional guild allowlist)", - allowIdsMode: "guild", - }, - { - name: "slack", - envKey: "SLACK_BOT_TOKEN", - description: "Slack bot messaging", - help: "Slack API → Your Apps → OAuth & Permissions → Bot User OAuth Token (xoxb-...).", - label: "Slack Bot Token", - appTokenEnvKey: "SLACK_APP_TOKEN", - appTokenHelp: "Slack API → Your Apps → Basic Information → App-Level Tokens (xapp-...).", - appTokenLabel: "Slack App Token (Socket Mode)", - }, -]; +const MESSAGING_CHANNELS = listChannels(); // Curl exit codes that indicate a network-level failure (not a token problem). // 35 (TLS handshake failure) covers corporate proxies that MITM HTTPS. @@ -4617,7 +4578,7 @@ async function setupMessagingChannels() { } // Single-keypress toggle selector — pre-select channels that already have tokens. - // Press 1/2/3 to instantly toggle a channel; press Enter to continue. + // Press a channel number to toggle; press Enter to continue. const enabled = new Set( MESSAGING_CHANNELS.filter((c) => getMessagingToken(c.envKey)).map((c) => c.name), ); @@ -4640,7 +4601,7 @@ async function setupMessagingChannels() { output.write(` [${i + 1}] ${marker} ${ch.name} — ${ch.description}${status}\n`); }); output.write("\n"); - output.write(" Press 1-3 to toggle, Enter when done: "); + output.write(` Press 1-${MESSAGING_CHANNELS.length} to toggle, Enter when done: `); }; showList(); @@ -6419,6 +6380,7 @@ module.exports = { setupMessagingChannels, setupNim, isInferenceRouteReady, + isNonInteractive, isOpenclawReady, arePolicyPresetsApplied, getSuggestedPolicyPresets, diff --git a/src/lib/sandbox-channels.test.ts b/src/lib/sandbox-channels.test.ts new file mode 100644 index 00000000000..9b62c31ef73 --- /dev/null +++ b/src/lib/sandbox-channels.test.ts @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { + KNOWN_CHANNELS, + getChannelDef, + getChannelTokenKeys, + knownChannelNames, + listChannels, +} from "../../dist/lib/sandbox-channels"; + +describe("sandbox-channels KNOWN_CHANNELS", () => { + it("covers telegram, discord, and slack", () => { + expect(knownChannelNames()).toEqual(["telegram", "discord", "slack"]); + }); + + it("exposes the primary bot-token env var for each channel", () => { + expect(getChannelDef("telegram")?.envKey).toBe("TELEGRAM_BOT_TOKEN"); + expect(getChannelDef("discord")?.envKey).toBe("DISCORD_BOT_TOKEN"); + expect(getChannelDef("slack")?.envKey).toBe("SLACK_BOT_TOKEN"); + }); + + it("only slack declares a secondary app-token env var", () => { + expect(getChannelDef("telegram")?.appTokenEnvKey).toBeUndefined(); + expect(getChannelDef("discord")?.appTokenEnvKey).toBeUndefined(); + expect(getChannelDef("slack")?.appTokenEnvKey).toBe("SLACK_APP_TOKEN"); + }); + + it("normalises case and whitespace when resolving a channel name", () => { + expect(getChannelDef(" Telegram ")).toBe(KNOWN_CHANNELS.telegram); + expect(getChannelDef("DISCORD")).toBe(KNOWN_CHANNELS.discord); + }); + + it("returns undefined for unknown channel names", () => { + expect(getChannelDef("mattermost")).toBeUndefined(); + expect(getChannelDef("")).toBeUndefined(); + }); +}); + +describe("sandbox-channels getChannelTokenKeys", () => { + it("returns just the primary token key for single-token channels", () => { + expect(getChannelTokenKeys(KNOWN_CHANNELS.telegram)).toEqual(["TELEGRAM_BOT_TOKEN"]); + expect(getChannelTokenKeys(KNOWN_CHANNELS.discord)).toEqual(["DISCORD_BOT_TOKEN"]); + }); + + it("returns primary then app token for slack", () => { + expect(getChannelTokenKeys(KNOWN_CHANNELS.slack)).toEqual([ + "SLACK_BOT_TOKEN", + "SLACK_APP_TOKEN", + ]); + }); +}); + +describe("sandbox-channels listChannels", () => { + it("materialises an array with the name merged into each entry", () => { + const list = listChannels(); + expect(list.map((c) => c.name)).toEqual(["telegram", "discord", "slack"]); + const telegram = list.find((c) => c.name === "telegram"); + expect(telegram?.envKey).toBe("TELEGRAM_BOT_TOKEN"); + expect(telegram?.allowIdsMode).toBe("dm"); + }); +}); diff --git a/src/lib/sandbox-channels.ts b/src/lib/sandbox-channels.ts new file mode 100644 index 00000000000..336efdb814c --- /dev/null +++ b/src/lib/sandbox-channels.ts @@ -0,0 +1,91 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { deleteCredential, saveCredential } from "./credentials"; + +export interface ChannelDef { + envKey: string; + description: string; + help: string; + label: string; + appTokenEnvKey?: string; + appTokenHelp?: string; + appTokenLabel?: string; + userIdEnvKey?: string; + userIdHelp?: string; + userIdLabel?: string; + allowIdsMode?: "dm" | "guild"; + serverIdEnvKey?: string; + serverIdHelp?: string; + serverIdLabel?: string; + requireMentionEnvKey?: string; + requireMentionHelp?: string; +} + +export const KNOWN_CHANNELS: Record = { + telegram: { + envKey: "TELEGRAM_BOT_TOKEN", + description: "Telegram bot messaging", + help: "Create a bot via @BotFather on Telegram, then copy the token.", + label: "Telegram Bot Token", + userIdEnvKey: "TELEGRAM_ALLOWED_IDS", + userIdHelp: "Send /start to @userinfobot on Telegram to get your numeric user ID.", + userIdLabel: "Telegram User ID (for DM access)", + allowIdsMode: "dm", + }, + discord: { + envKey: "DISCORD_BOT_TOKEN", + description: "Discord bot messaging", + help: "Discord Developer Portal → Applications → Bot → Reset/Copy Token.", + label: "Discord Bot Token", + serverIdEnvKey: "DISCORD_SERVER_ID", + serverIdHelp: + "Enable Developer Mode in Discord, then right-click your server and copy the Server ID.", + serverIdLabel: "Discord Server ID (for guild workspace access)", + requireMentionEnvKey: "DISCORD_REQUIRE_MENTION", + requireMentionHelp: + "Choose whether the bot should reply only when @mentioned or to all messages in this server.", + userIdEnvKey: "DISCORD_USER_ID", + userIdHelp: + "Optional: enable Developer Mode in Discord, then right-click your user/avatar and copy the User ID. Leave blank to allow any member of the configured server to message the bot.", + userIdLabel: "Discord User ID (optional guild allowlist)", + allowIdsMode: "guild", + }, + slack: { + envKey: "SLACK_BOT_TOKEN", + description: "Slack bot messaging", + help: "Slack API → Your Apps → OAuth & Permissions → Bot User OAuth Token (xoxb-...).", + label: "Slack Bot Token", + appTokenEnvKey: "SLACK_APP_TOKEN", + appTokenHelp: "Slack API → Your Apps → Basic Information → App-Level Tokens (xapp-...).", + appTokenLabel: "Slack App Token (Socket Mode)", + }, +}; + +export function getChannelDef(name: string): ChannelDef | undefined { + return KNOWN_CHANNELS[name.trim().toLowerCase()]; +} + +export function knownChannelNames(): string[] { + return Object.keys(KNOWN_CHANNELS); +} + +export function listChannels(): Array<{ name: string } & ChannelDef> { + return Object.entries(KNOWN_CHANNELS).map(([name, def]) => ({ name, ...def })); +} + +export function getChannelTokenKeys(channel: ChannelDef): string[] { + return channel.appTokenEnvKey ? [channel.envKey, channel.appTokenEnvKey] : [channel.envKey]; +} + +export function persistChannelTokens(tokens: Record): void { + for (const [key, value] of Object.entries(tokens)) { + saveCredential(key, value); + } +} + +export function clearChannelTokens(channel: ChannelDef): void { + for (const key of getChannelTokenKeys(channel)) { + deleteCredential(key); + } +} diff --git a/src/nemoclaw.ts b/src/nemoclaw.ts index bec5e942870..41cd97a68a5 100644 --- a/src/nemoclaw.ts +++ b/src/nemoclaw.ts @@ -73,6 +73,16 @@ const { createSystemDeps: createSessionDeps, } = require("./lib/sandbox-session-state"); +import { + KNOWN_CHANNELS, + clearChannelTokens, + getChannelDef, + getChannelTokenKeys, + knownChannelNames, + persistChannelTokens, +} from "./lib/sandbox-channels"; +import { isNonInteractive } from "./lib/onboard"; + // ── Global commands ────────────────────────────────────────────── const GLOBAL_COMMANDS = new Set([ @@ -1626,6 +1636,117 @@ function sandboxPolicyList(sandboxName) { console.log(""); } +// ── Messaging channels ─────────────────────────────────────────── + +function sandboxChannelsList(sandboxName) { + console.log(""); + console.log(` Known messaging channels for sandbox '${sandboxName}':`); + for (const [name, channel] of Object.entries(KNOWN_CHANNELS)) { + console.log(` ${name} — ${channel.description}`); + } + console.log(""); +} + +async function promptAndRebuild(sandboxName, actionDesc) { + if (isNonInteractive()) { + console.log(""); + console.log( + ` Change queued. Run 'nemoclaw ${sandboxName} rebuild' to apply (${actionDesc}).`, + ); + return; + } + const answer = (await askPrompt(` Rebuild '${sandboxName}' now to apply? [Y/n]: `)) + .trim() + .toLowerCase(); + if (answer === "n" || answer === "no") { + console.log( + ` Run 'nemoclaw ${sandboxName} rebuild' when you are ready to apply (${actionDesc}).`, + ); + return; + } + await sandboxRebuild(sandboxName, ["--yes"]); +} + +async function sandboxChannelsAdd(sandboxName, args = []) { + const dryRun = args.includes("--dry-run"); + const channelArg = args.find((arg) => !arg.startsWith("-")); + if (!channelArg) { + console.error(" Usage: nemoclaw channels add [--dry-run]"); + console.error(` Valid channels: ${knownChannelNames().join(", ")}`); + process.exit(1); + } + + const channel = getChannelDef(channelArg); + if (!channel) { + console.error(` Unknown channel '${channelArg}'.`); + console.error(` Valid channels: ${knownChannelNames().join(", ")}`); + process.exit(1); + } + + if (dryRun) { + console.log(` --dry-run: would enable channel '${channelArg}' for '${sandboxName}'.`); + return; + } + + const tokenKeys = getChannelTokenKeys(channel); + const acquired = {}; + for (const envKey of tokenKeys) { + const isPrimary = envKey === channel.envKey; + const help = isPrimary ? channel.help : channel.appTokenHelp; + const label = isPrimary ? channel.label : channel.appTokenLabel; + const existing = getCredential(envKey); + if (existing) { + acquired[envKey] = existing; + continue; + } + if (isNonInteractive()) { + console.error(` Missing ${envKey} for channel '${channelArg}'.`); + console.error( + ` Set ${envKey} in the environment or via 'nemoclaw credentials' before running in non-interactive mode.`, + ); + process.exit(1); + } + console.log(""); + console.log(` ${help}`); + const token = (await askPrompt(` ${label}: `, { secret: true })).trim(); + if (!token) { + console.error(` Aborted — no value entered for ${envKey}.`); + process.exit(1); + } + acquired[envKey] = token; + } + + persistChannelTokens(acquired); + console.log(` ${G}✓${R} Saved ${channelArg} credentials.`); + await promptAndRebuild(sandboxName, `add '${channelArg}'`); +} + +async function sandboxChannelsRemove(sandboxName, args = []) { + const dryRun = args.includes("--dry-run"); + const channelArg = args.find((arg) => !arg.startsWith("-")); + if (!channelArg) { + console.error(" Usage: nemoclaw channels remove [--dry-run]"); + console.error(` Valid channels: ${knownChannelNames().join(", ")}`); + process.exit(1); + } + + const channel = getChannelDef(channelArg); + if (!channel) { + console.error(` Unknown channel '${channelArg}'.`); + console.error(` Valid channels: ${knownChannelNames().join(", ")}`); + process.exit(1); + } + + if (dryRun) { + console.log(` --dry-run: would remove channel '${channelArg}' for '${sandboxName}'.`); + return; + } + + clearChannelTokens(channel); + console.log(` ${G}✓${R} Cleared stored ${channelArg} credentials.`); + await promptAndRebuild(sandboxName, `remove '${channelArg}'`); +} + /** * Install or update a local skill directory into a live sandbox and perform * any agent-specific post-install refresh needed for the new content to load. @@ -2528,6 +2649,11 @@ function help() { nemoclaw policy-remove [preset] Remove an applied policy preset ${D}(--yes, --dry-run)${R} nemoclaw policy-list List presets ${D}(● = applied)${R} + ${G}Messaging Channels:${R} + nemoclaw channels list List supported messaging channels + nemoclaw channels add Save credentials and rebuild ${D}(telegram|discord|slack)${R} + nemoclaw channels remove Clear credentials and rebuild + ${G}Compatibility Commands:${R} nemoclaw setup Deprecated alias for ${B}nemoclaw onboard${R} nemoclaw setup-spark Deprecated alias for ${B}nemoclaw onboard${R} @@ -2638,7 +2764,7 @@ const [cmd, ...args] = process.argv.slice(2); // command, attempt recovery — the sandbox may still be live with a stale registry. if ( !registry.getSandbox(cmd) && - ["connect", "skill", "shields", "config", ""].includes(args[0] || "") + ["connect", "skill", "shields", "config", "channels", ""].includes(args[0] || "") ) { validateName(cmd, "sandbox name"); await recoverRegistryEntries({ requestedSandboxName: cmd }); @@ -2730,6 +2856,31 @@ const [cmd, ...args] = process.argv.slice(2); } break; } + case "channels": { + const channelsSub = actionArgs[0]; + const channelsArgs = actionArgs.slice(1); + switch (channelsSub) { + case "list": + case undefined: + case "": + sandboxChannelsList(cmd); + break; + case "add": + await sandboxChannelsAdd(cmd, channelsArgs); + break; + case "remove": + await sandboxChannelsRemove(cmd, channelsArgs); + break; + default: + console.error(` Unknown channels subcommand: ${channelsSub}`); + console.error(" Usage: nemoclaw channels [args]"); + console.error(" list List supported messaging channels"); + console.error(" add Store credentials and rebuild the sandbox"); + console.error(" remove Clear credentials and rebuild the sandbox"); + process.exit(1); + } + break; + } case "config": { const configSub = actionArgs[0]; switch (configSub) { @@ -2773,7 +2924,7 @@ const [cmd, ...args] = process.argv.slice(2); default: console.error(` Unknown action: ${action}`); console.error( - ` Valid actions: connect, status, logs, policy-add, policy-remove, policy-list, skill, snapshot, rebuild, shields, config, destroy`, + ` Valid actions: connect, status, logs, policy-add, policy-remove, policy-list, skill, snapshot, rebuild, shields, config, channels, destroy`, ); process.exit(1); } diff --git a/test/nemoclaw-start.test.ts b/test/nemoclaw-start.test.ts index a03de02c549..51f1ae6962b 100644 --- a/test/nemoclaw-start.test.ts +++ b/test/nemoclaw-start.test.ts @@ -267,6 +267,24 @@ describe("nemoclaw-start configure guard blocks config set/unset (#1973)", () => }); }); +describe("nemoclaw-start configure guard blocks channels mutators (#2097)", () => { + const src = fs.readFileSync(START_SCRIPT, "utf-8"); + + it("adds a channels) case that allows read-only subcommands through", () => { + expect(src).toMatch(/channels\)\s+case "\$2" in\s+list \| "" \| -h \| --help\)/); + }); + + it("blocks mutating channels subcommands with an actionable error and return 1", () => { + expect(src).toContain("'openclaw channels $2' cannot modify channels inside the sandbox"); + expect(src).toMatch(/channels\)[\s\S]*?\*\)[\s\S]*?return 1/); + }); + + it("redirects users to the host-side channels commands", () => { + expect(src).toMatch(/channels\)[\s\S]*?nemoclaw channels add/); + expect(src).toMatch(/channels\)[\s\S]*?nemoclaw channels remove/); + }); +}); + describe("runtime model override (#759)", () => { const src = fs.readFileSync(START_SCRIPT, "utf-8");