Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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.
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, NemoClaw rolls back the bridge providers, the `messagingChannels` entry, and the persisted credentials, then exits 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.
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, NemoClaw rolls back the bridge providers, the `messagingChannels` entry, and the persisted credentials, then exits 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
41 changes: 30 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,21 @@ export async function addSandboxChannel(
return;
}

const presetContent = policies.loadPreset(canonical);
const presetEntries =
presetContent === null ? null : policies.extractPresetEntries(presetContent);
if (presetContent === null || presetEntries === null) {
if (presetContent !== null && presetEntries === null) {
console.error(
` Preset YAML for channel '${canonical}' is missing a 'network_policies:' section.`,
);
}
console.error(
` Restore the preset YAML and re-run: ${CLI_NAME} ${sandboxName} channels add ${canonical}`,
);
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 +848,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.`,
);
clearChannelTokens(channel);
await applyChannelRemoveToGatewayAndRegistry(
sandboxName,
canonical,
getChannelTokenKeys(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 +883,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
216 changes: 196 additions & 20 deletions test/channels-add-preset.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ function buildPreamble({
sessionLoadThrows = false,
sessionUpdateThrows = false,
sessionMissing = false,
presetFileMissing = false,
presetMissingNetworkPolicies = false,
}: {
presetNamesAvailable?: string[];
applyPresetResult?: boolean;
Expand All @@ -65,6 +67,8 @@ function buildPreamble({
sessionLoadThrows?: boolean;
sessionUpdateThrows?: boolean;
sessionMissing?: boolean;
presetFileMissing?: boolean;
presetMissingNetworkPolicies?: boolean;
} = {}): string {
const j = (p: string) => JSON.stringify(path.join(repoRoot, "dist", "lib", p));
return String.raw`
Expand All @@ -86,9 +90,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 +123,11 @@ const appliedCalls = [];
const removedCalls = [];
const callOrder = [];
policies.listPresets = () => ${JSON.stringify(presetNamesAvailable.map((name) => ({ name })))};
policies.loadPreset = (name) => {
if (${JSON.stringify(presetFileMissing)}) return null;
if (${JSON.stringify(presetMissingNetworkPolicies)}) return "name: " + name + "\ndescription: \"stub preset without network_policies\"\n";
return "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 +188,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 +348,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 +384,153 @@ 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)}`,
);
assert.ok(
result.stderr.includes(`Restore the preset YAML and re-run: nemoclaw test-sb channels add telegram`),
`expected restore-and-re-run hint on stderr; got:\n${result.stderr}`,
);
});

it("aborts non-QR channel when policy preset YAML has no network_policies section", () => {
const script = `${buildPreamble({ presetMissingNetworkPolicies: 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" });
} 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,
exitCodes,
}) + "\\n");
})();
`;
const result = runScript(script);
assert.equal(result.status, 0, `script failed: ${result.stderr}\n${result.stdout}`);
const marker = result.stdout.lastIndexOf("__RESULT__");
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, []);
assert.deepEqual(payload.providerCalls, []);
assert.deepEqual(payload.registryUpdates, []);
assert.deepEqual(payload.savedCredentialKeys, []);
assert.deepEqual(payload.deletedCredentialKeys, []);
assert.ok(
!payload.callOrder.includes("promptAndRebuild"),
`invalid preset must not prompt for rebuild; got order: ${JSON.stringify(payload.callOrder)}`,
);
assert.ok(
result.stderr.includes("missing a 'network_policies:' section"),
`expected diagnostic about missing network_policies section; got:\n${result.stderr}`,
);
assert.ok(
result.stderr.includes("Restore the preset YAML and re-run: nemoclaw test-sb channels add telegram"),
`expected restore-and-re-run hint on stderr; got:\n${result.stderr}`,
);
});

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 +1109,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