Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions docs/manage-sandboxes/messaging-channels.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -164,8 +164,11 @@ $ 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.
If the file is missing, the command exits with a non-zero status before any token prompt, registry write, or rebuild prompt, so the sandbox is never left advertising a channel without a matching network policy.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
If the file is present, `channels add` applies the preset to the sandbox automatically before the rebuild so the bridge has egress to its upstream API.
If the apply itself fails, the command exits with a non-zero status without prompting for rebuild.
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
6 changes: 5 additions & 1 deletion docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -723,7 +723,11 @@ 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.
If the file is missing, `channels add` exits with a non-zero status before any token prompt, registry write, or rebuild prompt.
When the file is present, NemoClaw applies the preset to the sandbox before the rebuild so the bridge has egress to its upstream API.
If the apply itself fails, the command exits with a non-zero status without prompting for rebuild.
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 Down
30 changes: 19 additions & 11 deletions src/lib/actions/sandbox/policy-channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -793,6 +793,10 @@ export async function addSandboxChannel(
return;
}

if (policies.loadPreset(canonical) === null) {
process.exit(1);
}

// QR-paired channels that own their session inside the sandbox have no
// host-side credential to acquire; register the bridge now and let the
// operator complete pairing after rebuild.
Expand Down Expand Up @@ -833,28 +837,32 @@ 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)) {
console.error(
` ${YW}⚠${R} Rolling back '${canonical}' bridge registration to keep messagingChannels and policy state aligned.`,
);
await applyChannelRemoveToGatewayAndRegistry(
sandboxName,
canonical,
getChannelTokenKeys(channel),
);
clearChannelTokens(channel);
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;
}
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 +872,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
149 changes: 129 additions & 20 deletions test/channels-add-preset.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ function buildPreamble({
sessionLoadThrows = false,
sessionUpdateThrows = false,
sessionMissing = false,
presetFileMissing = false,
}: {
presetNamesAvailable?: string[];
applyPresetResult?: boolean;
Expand All @@ -65,6 +66,7 @@ function buildPreamble({
sessionLoadThrows?: boolean;
sessionUpdateThrows?: boolean;
sessionMissing?: boolean;
presetFileMissing?: boolean;
} = {}): string {
const j = (p: string) => JSON.stringify(path.join(repoRoot, "dist", "lib", p));
return String.raw`
Expand All @@ -86,9 +88,11 @@ const gatewayRuntime = require(${j("gateway-runtime-action.js")});
gatewayRuntime.recoverNamedGatewayRuntime = async () => ({ recovered: true });

const credentials = require(${j("credentials/store.js")});
const savedCredentialKeys = [];
const deletedCredentialKeys = [];
credentials.getCredential = (key) => process.env[key] || null;
credentials.saveCredential = () => true;
credentials.deleteCredential = () => true;
credentials.saveCredential = (key) => { savedCredentialKeys.push(key); return true; };
credentials.deleteCredential = (key) => { deletedCredentialKeys.push(key); return true; };
credentials.prompt = async (msg) => { throw new Error("unexpected prompt: " + msg); };

const onboard = require(${j("onboard.js")});
Expand Down Expand Up @@ -117,6 +121,7 @@ const appliedCalls = [];
const removedCalls = [];
const callOrder = [];
policies.listPresets = () => ${JSON.stringify(presetNamesAvailable.map((name) => ({ name })))};
policies.loadPreset = (name) => ${JSON.stringify(presetFileMissing)} ? null : "network_policies:\n " + name + ":\n egress:\n - host: example.com";
policies.applyPreset = (sandboxName, presetName) => {
appliedCalls.push({ sandboxName, presetName });
callOrder.push("applyPreset:" + presetName);
Expand Down Expand Up @@ -177,7 +182,7 @@ console.log = (...args) => {

const channelModule = require(${j("actions/sandbox/policy-channel.js")});

module.exports = { channelModule, appliedCalls, removedCalls, callOrder, providerCalls, registryUpdates, sessionUpdates, getSessionState: () => sessionState };
module.exports = { channelModule, appliedCalls, removedCalls, callOrder, providerCalls, registryUpdates, sessionUpdates, savedCredentialKeys, deletedCredentialKeys, getSessionState: () => sessionState };
`;
}

Expand Down Expand Up @@ -337,24 +342,33 @@ process.exit = (code) => {
);
});

// Negative: when the channel name does not match any built-in preset,
// the helper short-circuits via listPresets() and applyPreset is not
// invoked at all. This guards against a future channel name that happens
// to collide with no preset (or a typo) from spamming "Cannot load preset"
// errors out of policies.applyPreset.
it("skips applyPreset when no matching built-in preset exists", () => {
const script = `${buildPreamble({ presetNamesAvailable: ["npm", "github"] })}
it("aborts non-QR channel when policy preset YAML is missing", () => {
const script = `${buildPreamble({ presetFileMissing: true })}
const ctx = module.exports;
const exitCodes = [];
const originalExit = process.exit;
process.exit = (code) => {
exitCodes.push(code ?? 0);
throw new Error("__EXIT__" + (code ?? 0));
};
(async () => {
try {
await ctx.channelModule.addSandboxChannel("test-sb", { channel: "telegram" });
process.stdout.write("\\n__RESULT__" + JSON.stringify({
appliedCalls: ctx.appliedCalls,
callOrder: ctx.callOrder,
}) + "\\n");
} catch (err) {
process.stdout.write("\\n__RESULT__" + JSON.stringify({ error: err.message, stack: err.stack }) + "\\n");
if (!String(err && err.message).startsWith("__EXIT__")) {
process.stdout.write("\\n__RESULT__" + JSON.stringify({ error: err.message, stack: err.stack }) + "\\n");
return;
}
} finally {
process.exit = originalExit;
}
process.stdout.write("\\n__RESULT__" + JSON.stringify({
appliedCalls: ctx.appliedCalls,
callOrder: ctx.callOrder,
providerCalls: ctx.providerCalls,
registryUpdates: ctx.registryUpdates,
exitCodes,
}) + "\\n");
})();
`;
const result = runScript(script);
Expand All @@ -364,16 +378,92 @@ const ctx = module.exports;
const payload = JSON.parse(result.stdout.slice(marker + "__RESULT__".length).trim());
assert.ok(!payload.error, `unexpected error: ${payload.error}\n${payload.stack || ""}`);

assert.deepEqual(payload.exitCodes, [1]);
assert.deepEqual(
payload.appliedCalls,
[],
`expected applyPreset NOT to be called when no built-in preset matches; got ${JSON.stringify(payload.appliedCalls)}`,
`missing preset YAML must abort before applyPreset; got ${JSON.stringify(payload.appliedCalls)}`,
);
assert.deepEqual(
payload.providerCalls,
[],
`missing preset YAML must not register host-side providers; got ${JSON.stringify(payload.providerCalls)}`,
);
assert.deepEqual(
payload.registryUpdates,
[],
`missing preset YAML must not register telegram in messagingChannels; got ${JSON.stringify(payload.registryUpdates)}`,
);
// Rebuild should still be triggered — channel registration succeeded,
// only the preset path was skipped.
assert.ok(
payload.callOrder.includes("promptAndRebuild"),
`expected promptAndRebuild to still run; got order: ${JSON.stringify(payload.callOrder)}`,
!payload.callOrder.includes("promptAndRebuild"),
`missing preset YAML must not prompt for rebuild; got order: ${JSON.stringify(payload.callOrder)}`,
);
});

it("rolls back providers, registry, and credentials when applyPreset fails after a successful loadPreset", () => {
const script = `${buildPreamble({ applyPresetResult: false })}
const ctx = module.exports;
const exitCodes = [];
const originalExit = process.exit;
process.exit = (code) => {
exitCodes.push(code ?? 0);
throw new Error("__EXIT__" + (code ?? 0));
};
(async () => {
try {
await ctx.channelModule.addSandboxChannel("test-sb", { channel: "telegram" });
} catch (err) {
if (!String(err && err.message).startsWith("__EXIT__")) {
process.stdout.write("\\n__RESULT__" + JSON.stringify({ error: err.message, stack: err.stack }) + "\\n");
return;
}
} finally {
process.exit = originalExit;
}
process.stdout.write("\\n__RESULT__" + JSON.stringify({
appliedCalls: ctx.appliedCalls,
callOrder: ctx.callOrder,
providerCalls: ctx.providerCalls,
registryUpdates: ctx.registryUpdates,
savedCredentialKeys: ctx.savedCredentialKeys,
deletedCredentialKeys: ctx.deletedCredentialKeys,
sessionUpdates: ctx.sessionUpdates,
exitCodes,
}) + "\\n");
})();
`;
const result = runScript(script);
assert.equal(result.status, 0, `script failed: ${result.stderr}\n${result.stdout}`);
const marker = result.stdout.lastIndexOf("__RESULT__");
assert.ok(marker >= 0, `no __RESULT__ marker in stdout:\n${result.stdout}`);
const payload = JSON.parse(result.stdout.slice(marker + "__RESULT__".length).trim());
assert.ok(!payload.error, `unexpected error: ${payload.error}\n${payload.stack || ""}`);

assert.deepEqual(payload.exitCodes, [1]);
assert.deepEqual(
payload.appliedCalls,
[{ sandboxName: "test-sb", presetName: "telegram" }],
`expected one failed applyPreset call; got ${JSON.stringify(payload.appliedCalls)}`,
);
assert.ok(
payload.registryUpdates.length === 2,
`expected one add update and one rollback update; got ${JSON.stringify(payload.registryUpdates)}`,
);
assert.deepEqual(payload.registryUpdates[0].updates.messagingChannels, ["telegram"]);
assert.deepEqual(payload.registryUpdates[1].updates.messagingChannels, []);
assert.deepEqual(
payload.deletedCredentialKeys,
["TELEGRAM_BOT_TOKEN"],
`expected rollback to clear persisted credentials; got ${JSON.stringify(payload.deletedCredentialKeys)}`,
);
assert.deepEqual(
payload.sessionUpdates,
[],
`applyPreset returned false before syncSessionPolicyPresetsWithRegistry; session must stay untouched; got ${JSON.stringify(payload.sessionUpdates)}`,
);
assert.ok(
!payload.callOrder.includes("promptAndRebuild"),
`apply failure must not prompt for rebuild; got order: ${JSON.stringify(payload.callOrder)}`,
);
});
});
Expand Down Expand Up @@ -952,3 +1042,22 @@ global.__testLog = "";
);
});
});

describe("channel preset source-of-truth", () => {
it("every channel registered in KNOWN_CHANNELS ships a matching preset YAML on disk", () => {
const { knownChannelNames } = require(path.join(repoRoot, "dist", "lib", "sandbox", "channels.js")) as {
knownChannelNames: () => string[];
};
const presetDir = path.join(repoRoot, "nemoclaw-blueprint", "policies", "presets");
const missing: string[] = [];
for (const name of knownChannelNames()) {
const file = path.join(presetDir, `${name}.yaml`);
if (!fs.existsSync(file)) missing.push(file);
}
assert.deepEqual(
missing,
[],
`every channel in KNOWN_CHANNELS must have a matching preset YAML; missing: ${missing.join(", ")}`,
);
});
});
Loading