Skip to content
Open
8 changes: 6 additions & 2 deletions docs/manage-sandboxes/messaging-channels.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -164,8 +164,12 @@ $ nemoclaw my-assistant channels add whatsapp
It prompts for Telegram, Discord, and Slack tokens, runs an interactive host-side QR scan for WeChat, and collects nothing for WhatsApp because pairing happens in-sandbox after rebuild.
It registers bridge providers with the OpenShell gateway when tokens were captured, records the channel in the sandbox registry, and asks whether to rebuild immediately.
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 <sandbox> policy-add <channel>` after the rebuild.
`channels add` requires the matching built-in network policy preset YAML to be present.
A missing or malformed preset YAML (no `network_policies:` section) aborts the command before any token prompt, registry write, or rebuild prompt, so the sandbox never advertises a channel without a matching network policy.
With the preset file in place, `channels add` applies it to the sandbox before the rebuild so the bridge has egress to its upstream API.
When the apply step itself fails after the registry write on a fresh add, NemoClaw attempts to roll back the bridge providers, the `messagingChannels` entry, and the persisted credentials, then exits without prompting for a rebuild; if any gateway-side step (provider detach or delete) fails the rollback continues and prints a `Rollback could not fully clean <surfaces>` warning so the operator can clean up manually.
When the same failure happens on a re-add of an already-enabled channel, NemoClaw restores the prior `messagingChannels` entry and the on-disk credentials and attempts to re-upsert the prior bridge providers, but flags `gateway-providers` as residual because the in-flight upsert may have left the gateway with the new token; verify the gateway bridge before relying on the channel.
Restore the preset YAML and re-run `nemoclaw <sandbox> channels add <channel>`.
Choose the rebuild so the running sandbox image picks up the new channel.
For Telegram, Discord, and Slack, `channels add` also checks the rebuilt runtime for the selected bridge and reports startup, credential, or missing-plugin warnings before returning.
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.
Expand Down
9 changes: 7 additions & 2 deletions docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -723,7 +723,12 @@ Channels fall into three login modes:
After registering the channel, NemoClaw asks whether to rebuild immediately.
Running `add` for an already-configured channel simply overwrites the stored credentials where applicable — the operation is idempotent.
Channel names are trimmed and lowercased before NemoClaw stores credentials, names bridge providers, or prints rebuild messages.
If a matching built-in network policy preset exists, NemoClaw applies it to the sandbox 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 <name> policy-add <channel>`.
NemoClaw requires the matching built-in network policy preset YAML to be present.
A missing or malformed preset YAML (no `network_policies:` section) aborts `channels add` before any token prompt, registry write, or rebuild prompt.
With the preset file in place, NemoClaw applies it to the sandbox before the rebuild so the bridge has egress to its upstream API.
When the apply step itself fails after the registry write on a fresh add, NemoClaw attempts to roll back the bridge providers, the `messagingChannels` entry, and the persisted credentials, then exits without prompting for a rebuild; if any gateway-side step (provider detach or delete) fails the rollback continues and prints a `Rollback could not fully clean <surfaces>` warning so the operator can clean up manually.
When the same failure happens on a re-add of an already-enabled channel, NemoClaw restores the prior `messagingChannels` entry and the on-disk credentials and attempts to re-upsert the prior bridge providers, but flags `gateway-providers` as residual because the in-flight upsert may have left the gateway with the new token; verify the gateway bridge before relying on the channel.
Restore the preset YAML and re-run `nemoclaw <name> channels add <channel>`.
For Telegram, Discord, and Slack, a rebuild triggered by `channels add` also verifies that the selected bridge starts and reports credential, startup, or plugin discovery warnings.

```console
Expand All @@ -732,7 +737,7 @@ $ nemoclaw my-assistant channels add telegram

| Flag | Description |
|------|-------------|
| `--dry-run` | Validate the channel and token inputs without saving credentials or rebuilding |
| `--dry-run` | Validate the channel name and matching policy preset without prompting for credentials, contacting the gateway, 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.
Expand Down
221 changes: 168 additions & 53 deletions src/lib/actions/sandbox/policy-channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,12 @@ async function applyChannelRemoveToGatewayAndRegistry(
sandboxName: string,
channelName: string,
channelTokenKeys: string[],
): Promise<void> {
options: { bestEffort?: boolean } = {},
): Promise<{ ok: boolean; residual: string[] }> {
const bestEffort = Boolean(options.bestEffort);
const residual: string[] = [];
let gatewayReachable = true;

if (channelTokenKeys.length > 0) {
const recovery = await recoverNamedGatewayRuntime();
if (!recovery.recovered) {
Expand All @@ -376,7 +381,9 @@ async function applyChannelRemoveToGatewayAndRegistry(
console.error(
" Re-run after starting the gateway, or run 'openshell gateway start --name nemoclaw'.",
);
process.exit(1);
if (!bestEffort) process.exit(1);
gatewayReachable = false;
residual.push("gateway-providers");
}
}

Expand All @@ -389,28 +396,33 @@ async function applyChannelRemoveToGatewayAndRegistry(
// previous run may have already detached, or the channel may have been
// configured for a sandbox that is no longer alive.
const detachFailures: Array<{ name: string; output: string }> = [];
for (const envKey of channelTokenKeys) {
const name = bridgeProviderName(sandboxName, channelName, envKey);
const result = runOpenshell(["sandbox", "provider", "detach", sandboxName, name], {
ignoreError: true,
stdio: ["ignore", "pipe", "pipe"],
});
if (result.status !== 0) {
const output = `${result.stdout || ""}${result.stderr || ""}`;
if (!/\bNotFound\b|not found|not attached/i.test(output)) {
detachFailures.push({ name, output: output.trim() });
if (gatewayReachable) {
for (const envKey of channelTokenKeys) {
const name = bridgeProviderName(sandboxName, channelName, envKey);
const result = runOpenshell(["sandbox", "provider", "detach", sandboxName, name], {
ignoreError: true,
stdio: ["ignore", "pipe", "pipe"],
});
if (result.status !== 0) {
const output = `${result.stdout || ""}${result.stderr || ""}`;
if (!/\bNotFound\b|not found|not attached/i.test(output)) {
detachFailures.push({ name, output: output.trim() });
}
}
}
}
if (detachFailures.length > 0) {
console.error(
` Failed to detach bridge provider(s) from sandbox '${sandboxName}': ${detachFailures.map((f) => f.name).join(", ")}.`,
);
for (const f of detachFailures) {
console.error(` [${f.name}] ${f.output.split("\n").join("\n ")}`);
if (detachFailures.length > 0) {
console.error(
` Failed to detach bridge provider(s) from sandbox '${sandboxName}': ${detachFailures.map((f) => f.name).join(", ")}.`,
);
for (const f of detachFailures) {
console.error(` [${f.name}] ${f.output.split("\n").join("\n ")}`);
}
if (!bestEffort) {
console.error(" Registry not updated; re-run after resolving the gateway error.");
process.exit(1);
}
if (!residual.includes("gateway-providers")) residual.push("gateway-providers");
}
console.error(" Registry not updated; re-run after resolving the gateway error.");
process.exit(1);
}

// Capture each delete's outcome. If any non-NotFound failure surfaces
Expand All @@ -420,30 +432,35 @@ async function applyChannelRemoveToGatewayAndRegistry(
// can't easily recover. Surface the underlying openshell output so the
// operator can see exactly why the delete was rejected.
const deleteFailures: Array<{ name: string; output: string }> = [];
for (const envKey of channelTokenKeys) {
const name = bridgeProviderName(sandboxName, channelName, envKey);
const result = runOpenshell(["provider", "delete", name], {
ignoreError: true,
stdio: ["ignore", "pipe", "pipe"],
});
if (result.status !== 0) {
const output = `${result.stdout || ""}${result.stderr || ""}`;
// Treat "not found" as success-equivalent — a previous run may
// have already deleted the provider.
if (!/\bNotFound\b|not found/i.test(output)) {
deleteFailures.push({ name, output: output.trim() });
if (gatewayReachable) {
const detachFailedSet = new Set(detachFailures.map((f) => f.name));
for (const envKey of channelTokenKeys) {
const name = bridgeProviderName(sandboxName, channelName, envKey);
if (!bestEffort && detachFailedSet.has(name)) continue;
const result = runOpenshell(["provider", "delete", name], {
ignoreError: true,
stdio: ["ignore", "pipe", "pipe"],
});
if (result.status !== 0) {
const output = `${result.stdout || ""}${result.stderr || ""}`;
if (!/\bNotFound\b|not found/i.test(output)) {
deleteFailures.push({ name, output: output.trim() });
}
}
}
}
if (deleteFailures.length > 0) {
console.error(
` Failed to delete bridge provider(s) from the OpenShell gateway: ${deleteFailures.map((f) => f.name).join(", ")}.`,
);
for (const f of deleteFailures) {
console.error(` [${f.name}] ${f.output.split("\n").join("\n ")}`);
if (deleteFailures.length > 0) {
console.error(
` Failed to delete bridge provider(s) from the OpenShell gateway: ${deleteFailures.map((f) => f.name).join(", ")}.`,
);
for (const f of deleteFailures) {
console.error(` [${f.name}] ${f.output.split("\n").join("\n ")}`);
}
if (!bestEffort) {
console.error(" Registry not updated; re-run after resolving the gateway error.");
process.exit(1);
}
if (!residual.includes("gateway-providers")) residual.push("gateway-providers");
}
console.error(" Registry not updated; re-run after resolving the gateway error.");
process.exit(1);
}

const entry = registry.getSandbox(sandboxName);
Expand All @@ -459,6 +476,8 @@ async function applyChannelRemoveToGatewayAndRegistry(
Object.keys(providerCredentialHashes).length > 0 ? providerCredentialHashes : undefined,
});
}

return { ok: residual.length === 0, residual };
}

async function promptAndRebuild(sandboxName: string, actionDesc: string): Promise<boolean> {
Expand Down Expand Up @@ -788,6 +807,21 @@ export async function addSandboxChannel(
process.exit(1);
}

const presetContent = policies.loadPreset(canonical);
const presetPolicyKeys =
presetContent === null ? [] : policies.parsePresetPolicyKeys(presetContent);
if (presetContent === null || presetPolicyKeys.length === 0) {
if (presetContent !== null && presetPolicyKeys.length === 0) {
console.error(
` Preset YAML for channel '${canonical}' has no parseable entries under 'network_policies:'.`,
);
}
console.error(
` Restore the preset YAML and re-run: ${CLI_NAME} ${sandboxName} channels add ${canonical}`,
);
process.exit(1);
}

if (dryRun) {
console.log(` --dry-run: would enable channel '${canonical}' for '${sandboxName}'.`);
return;
Expand Down Expand Up @@ -817,6 +851,21 @@ export async function addSandboxChannel(
return;
}

const priorEntry = registry.getSandbox(sandboxName);
const priorMessagingChannels: string[] = priorEntry?.messagingChannels
? [...priorEntry.messagingChannels]
: [];
const wasAlreadyEnabled = priorMessagingChannels.includes(canonical);
const priorHashes: Record<string, string> = {
...((priorEntry?.providerCredentialHashes as Record<string, string>) || {}),
};
const channelTokenKeys = getChannelTokenKeys(channel);
const priorCreds: Record<string, string> = {};
for (const key of channelTokenKeys) {
const existing = getCredential(key);
if (existing != null) priorCreds[key] = existing;
}

const acquired: Record<string, string> = {};
if (channel.loginMethod === "host-qr") {
await acquireHostQrChannel(sandboxName, canonical, channel, acquired);
Expand All @@ -833,28 +882,94 @@ export async function addSandboxChannel(
await applyChannelAddToGatewayAndRegistry(sandboxName, canonical, acquired);
console.log(` ${G}✓${R} Registered ${canonical} bridge with the OpenShell gateway.`);

applyChannelPresetIfAvailable(sandboxName, canonical);
if (!applyChannelPresetIfAvailable(sandboxName, canonical)) {
await rollbackChannelAdd(sandboxName, channel, canonical, {
wasAlreadyEnabled,
priorMessagingChannels,
priorHashes,
priorCreds,
});
process.exit(1);
}

const rebuilt = await promptAndRebuild(sandboxName, `add '${canonical}'`);
if (rebuilt) verifyChannelBridgeAfterRebuild(sandboxName, canonical);
}

// Must run before promptAndRebuild — the rebuild's backup manifest only
// captures presets already applied (#3437). Without this, channel bridges
// boot without egress to their upstream API after rebuild.
function applyChannelPresetIfAvailable(sandboxName: string, channelName: string): boolean {
const builtinPresets = new Set(policies.listPresets().map((p) => p.name));
if (!builtinPresets.has(channelName)) {
return true;
async function rollbackChannelAdd(
sandboxName: string,
channel: ChannelDef,
canonical: string,
snapshot: {
wasAlreadyEnabled: boolean;
priorMessagingChannels: string[];
priorHashes: Record<string, string>;
priorCreds: Record<string, string>;
},
): Promise<{ ok: boolean; residual: string[] }> {
if (snapshot.wasAlreadyEnabled) {
console.error(
` ${YW}⚠${R} Restoring prior '${canonical}' configuration; new token rotation aborted.`,
);
registry.updateSandbox(sandboxName, {
messagingChannels: snapshot.priorMessagingChannels,
providerCredentialHashes:
Object.keys(snapshot.priorHashes).length > 0 ? snapshot.priorHashes : undefined,
});
clearChannelTokens(channel);
if (Object.keys(snapshot.priorCreds).length > 0) {
persistChannelTokens(snapshot.priorCreds);
}
const residual: string[] = ["gateway-providers"];
console.error(
` ${YW}⚠${R} Rollback could not fully clean ${residual.join(", ")}; run '${CLI_NAME} ${sandboxName} channels remove ${canonical}' once the gateway is reachable.`,
);
if (Object.keys(snapshot.priorCreds).length > 0) {
try {
const priorTokenDefs = Object.entries(snapshot.priorCreds).map(([envKey, token]) => ({
name: bridgeProviderName(sandboxName, canonical, envKey),
envKey,
token,
}));
onboardProviders.upsertMessagingProviders(priorTokenDefs, runOpenshell);
} catch (err) {
console.error(
` ${YW}⚠${R} Failed to restore gateway providers for '${canonical}': ${
err instanceof Error ? err.message : String(err)
}`,
);
}
}
return { ok: false, residual };
}

console.error(
` ${YW}⚠${R} Rolling back '${canonical}' bridge registration to keep messagingChannels and policy state aligned.`,
);
clearChannelTokens(channel);
const result = await applyChannelRemoveToGatewayAndRegistry(
sandboxName,
canonical,
getChannelTokenKeys(channel),
{ bestEffort: true },
);
if (!result.ok) {
console.error(
` ${YW}⚠${R} Rollback could not fully clean ${result.residual.join(", ")}; run '${CLI_NAME} ${sandboxName} channels remove ${canonical}' once the gateway is reachable.`,
);
}
return result;
}

function applyChannelPresetIfAvailable(sandboxName: string, channelName: string): boolean {
try {
const applied = policies.applyPreset(sandboxName, channelName);
if (!applied) {
console.error(
` ${YW}⚠${R} Channel '${channelName}' bridge registered but its policy preset failed to apply.`,
` ${YW}⚠${R} Cannot enable channel '${channelName}': policy preset failed to apply.`,
);
console.error(
` Re-apply manually after rebuild with: ${CLI_NAME} ${sandboxName} policy-add ${channelName}`,
` Restore the preset YAML and re-run: ${CLI_NAME} ${sandboxName} channels add ${channelName}`,
);
return false;
}
Expand All @@ -864,7 +979,7 @@ function applyChannelPresetIfAvailable(sandboxName: string, channelName: string)
const msg = err instanceof Error ? err.message : String(err);
console.error(` ${YW}⚠${R} Failed to apply '${channelName}' policy preset: ${msg}`);
console.error(
` Re-apply manually after rebuild with: ${CLI_NAME} ${sandboxName} policy-add ${channelName}`,
` Restore the preset YAML and re-run: ${CLI_NAME} ${sandboxName} channels add ${channelName}`,
);
return false;
}
Expand Down
1 change: 1 addition & 0 deletions src/lib/policy/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1283,6 +1283,7 @@ export {
listSetupPolicyPresets,
clampSetupPolicyPresetNames,
extractPresetEntries,
parsePresetPolicyKeys,
parseCurrentPolicy,
buildPolicySetCommand,
buildPolicyGetCommand,
Expand Down
Loading
Loading