diff --git a/src/lib/policies.ts b/src/lib/policies.ts index d4ae3a861c2..e6702975057 100644 --- a/src/lib/policies.ts +++ b/src/lib/policies.ts @@ -95,6 +95,27 @@ function getPresetEndpoints(content: string): string[] { return hosts; } +/** + * Messaging channel presets only open network egress to the provider's API; + * the bot token, channel configuration, and in-sandbox bridge are wired up at + * `nemoclaw onboard` time, so applying these presets after onboarding without + * having enabled the channel opens the firewall but leaves the sandbox + * without a running bridge. See #1691. + */ +const MESSAGING_PRESET_NAMES = new Set(["telegram", "discord", "slack"]); + +function getMessagingPresetWarning(presetName: string): string | null { + if (!MESSAGING_PRESET_NAMES.has(presetName)) return null; + const label = + presetName === "telegram" ? "Telegram" : presetName === "discord" ? "Discord" : "Slack"; + return [ + `Note: the '${presetName}' preset only opens network egress to the ${label} API.`, + `To actually enable ${label} messaging, re-run 'nemoclaw onboard' and select ${label}`, + "in the messaging channels step. The bot token and channel bridge are wired", + "up at onboard time and are not added by applying this preset alone.", + ].join("\n "); +} + /** * Extract just the network_policies entries (indented content under * the `network_policies:` key) from a preset file, stripping the @@ -919,6 +940,7 @@ export { listPresets, loadPreset, getPresetEndpoints, + getMessagingPresetWarning, extractPresetEntries, parseCurrentPolicy, buildPolicySetCommand, diff --git a/src/lib/policy-channel-actions.ts b/src/lib/policy-channel-actions.ts index 490d5429cff..15a6bed6b4e 100644 --- a/src/lib/policy-channel-actions.ts +++ b/src/lib/policy-channel-actions.ts @@ -141,6 +141,13 @@ export async function addSandboxPolicy(sandboxName: string, args: string[] = []) console.log(` Endpoints that would be opened: ${endpoints.join(", ")}`); } + const messagingWarning = policies.getMessagingPresetWarning(answer); + if (messagingWarning) { + console.log(""); + console.log(` ${messagingWarning}`); + console.log(""); + } + if (dryRun) { console.log(" --dry-run: no changes applied."); return; diff --git a/test/policies.test.ts b/test/policies.test.ts index a11f76349f6..a80f1f563a6 100644 --- a/test/policies.test.ts +++ b/test/policies.test.ts @@ -49,6 +49,7 @@ function runPolicyAdd( confirmAnswer: string, extraArgs: string[] = [], envOverrides: Record = {}, + presetName: string = "pypi", ) { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-add-")); const scriptPath = path.join(tmpDir, "policy-add-check.js"); @@ -57,9 +58,9 @@ const registry = require(${REGISTRY_PATH}); const policies = require(${POLICIES_PATH}); const credentials = require(${CREDENTIALS_PATH}); const calls = []; -policies.selectFromList = async () => "pypi"; -policies.loadPreset = () => "network_policies:\n pypi:\n host: pypi.org\n"; -policies.getPresetEndpoints = () => ["pypi.org"]; +policies.selectFromList = async () => ${JSON.stringify(presetName)}; +policies.loadPreset = () => "network_policies:\n example:\n host: example.com\n"; +policies.getPresetEndpoints = () => ["example.com"]; credentials.prompt = async (message) => { calls.push({ type: "prompt", message }); return ${JSON.stringify(confirmAnswer)}; @@ -82,15 +83,19 @@ Promise.resolve(require(${CLI_PATH}).mainPromise).finally(() => { fs.writeFileSync(scriptPath, script); - return spawnSync(process.execPath, [scriptPath], { - cwd: REPO_ROOT, - encoding: "utf-8", - env: { - ...process.env, - HOME: tmpDir, - ...envOverrides, - }, - }); + try { + return spawnSync(process.execPath, [scriptPath], { + cwd: REPO_ROOT, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + ...envOverrides, + }, + }); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } } function runSelectFromList(input: string, { applied = [] }: AppliedOptions = {}) { @@ -250,6 +255,33 @@ describe("policies", () => { }); }); + describe("getMessagingPresetWarning", () => { + it("returns a warning for the telegram preset that mentions re-running onboard", () => { + const warning = policies.getMessagingPresetWarning("telegram"); + expect(warning).toBeTruthy(); + expect(warning).toContain("telegram"); + expect(warning).toContain("Telegram"); + expect(warning).toContain("nemoclaw onboard"); + }); + + it("returns a warning for discord and slack", () => { + expect(policies.getMessagingPresetWarning("discord")).toContain("Discord"); + expect(policies.getMessagingPresetWarning("slack")).toContain("Slack"); + }); + + it("returns null for non-messaging presets", () => { + expect(policies.getMessagingPresetWarning("npm")).toBeNull(); + expect(policies.getMessagingPresetWarning("pypi")).toBeNull(); + expect(policies.getMessagingPresetWarning("github")).toBeNull(); + expect(policies.getMessagingPresetWarning("brew")).toBeNull(); + }); + + it("returns null for unknown preset names", () => { + expect(policies.getMessagingPresetWarning("")).toBeNull(); + expect(policies.getMessagingPresetWarning("nonexistent")).toBeNull(); + }); + }); + describe("applyPreset disclosure logging", () => { it("logs egress endpoints before applying", () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); @@ -1035,7 +1067,7 @@ selectForRemoval(items, options) const calls = JSON.parse(result.stdout.split("__CALLS__")[1].trim()) as PolicyCall[]; expect(calls.some((call: PolicyCall) => call.type === "prompt")).toBeFalsy(); expect(calls.some((call: PolicyCall) => call.type === "apply")).toBeFalsy(); - expect(result.stdout).toMatch(/Endpoints that would be opened: pypi\.org/); + expect(result.stdout).toMatch(/Endpoints that would be opened: example\.com/); expect(result.stdout).toMatch(/--dry-run: no changes applied\./); }); @@ -1073,6 +1105,24 @@ selectForRemoval(items, options) /Non-interactive mode requires a preset name/, ); }); + + it("warns the user that the telegram preset alone does not enable Telegram messaging", () => { + const result = runPolicyAdd("y", [], {}, "telegram"); + + expect(result.status).toBe(0); + expect(result.stdout).toMatch( + /Note: the 'telegram' preset only opens network egress to the Telegram API\./, + ); + expect(result.stdout).toMatch(/re-run 'nemoclaw onboard' and select Telegram/); + }); + + it("does not warn about messaging when a non-messaging preset is selected", () => { + const result = runPolicyAdd("y"); + + expect(result.status).toBe(0); + expect(result.stdout).not.toMatch(/only opens network egress to the/); + expect(result.stdout).not.toMatch(/re-run 'nemoclaw onboard' and select/); + }); }); describe("policy-remove confirmation", () => {