diff --git a/src/lib/actions/sandbox/policy-list-render.test.ts b/src/lib/actions/sandbox/policy-list-render.test.ts new file mode 100644 index 00000000000..574bb1c6420 --- /dev/null +++ b/src/lib/actions/sandbox/policy-list-render.test.ts @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Regression for #5967: `nemoclaw policy-list` must render `● discord` +// (and any enabled messaging channel preset) once it is recorded in the registry +// and active on the gateway. This is the reporter's observation step — the +// rendered marker the operator actually reads — complementing the merge/persist +// tests that cover the upstream state policy-list consumes. + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../policy", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + listPresets: vi.fn(), + listCustomPresets: vi.fn(), + getAppliedPresets: vi.fn(), + getGatewayPresets: vi.fn(), + }; +}); + +import * as policies from "../../policy"; +import { listSandboxPolicies } from "./policy-channel"; + +const mocked = vi.mocked(policies); + +describe("listSandboxPolicies rendering (#5967)", () => { + let logSpy: ReturnType; + let lines: string[]; + + beforeEach(() => { + lines = []; + logSpy = vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => { + lines.push(args.join(" ")); + }); + mocked.listPresets.mockReturnValue([ + { + name: "discord", + description: "Discord API, gateway, and CDN access", + file: "discord.yaml", + }, + { + name: "slack", + description: "Slack API, Socket Mode, and webhooks access", + file: "slack.yaml", + }, + { name: "npm", description: "npm and Yarn registry access", file: "npm.yaml" }, + ]); + mocked.listCustomPresets.mockReturnValue([]); + }); + + afterEach(() => { + logSpy.mockRestore(); + vi.clearAllMocks(); + }); + + // Match the rendered marker + preset name directly. The row may carry a + // provenance tag (e.g. `● discord [user-added] — …`) between the name and the + // description, so keying off the marker+name is robust to that suffix. + const lineFor = (preset: string) => + lines.find((line) => new RegExp(`[●○] ${preset}\\b`).test(line)) ?? ""; + + it("marks an enabled Discord preset applied (●) when it is in both registry and gateway", () => { + // The #5967 fix persists `discord` to registry.policies AND applies it to the + // gateway, so policy-list must render it as applied. + mocked.getAppliedPresets.mockReturnValue(["discord", "npm"]); + mocked.getGatewayPresets.mockReturnValue(["discord", "npm"]); + + listSandboxPolicies("nemoclaw-5967"); + + expect(lineFor("discord")).toContain("● discord"); + expect(lineFor("npm")).toContain("● npm"); + // A channel that was never configured stays unapplied. + expect(lineFor("slack")).toContain("○ slack"); + expect(lineFor("slack")).not.toContain("● slack"); + }); + + it("renders the pre-fix regression (○ discord) when Discord is dropped from registry and gateway", () => { + // Before the fix the explicit-selection path dropped discord from both the + // persisted registry list and the reconciled gateway set. + mocked.getAppliedPresets.mockReturnValue(["npm", "pypi"]); + mocked.getGatewayPresets.mockReturnValue(["npm", "pypi"]); + + listSandboxPolicies("nemoclaw-5967"); + + expect(lineFor("discord")).toContain("○ discord"); + expect(lineFor("discord")).not.toContain("● discord"); + }); + + it("flags a registry/gateway mismatch when Discord is recorded but not active on the gateway", () => { + mocked.getAppliedPresets.mockReturnValue(["discord", "npm"]); + mocked.getGatewayPresets.mockReturnValue(["npm"]); + + listSandboxPolicies("nemoclaw-5967"); + + expect(lineFor("discord")).toContain("○ discord"); + expect(lineFor("discord")).toContain("recorded locally, not active on gateway"); + }); +}); diff --git a/src/lib/onboard/messaging-policy-presets.test.ts b/src/lib/onboard/messaging-policy-presets.test.ts index a64c812374e..b06ca068f8a 100644 --- a/src/lib/onboard/messaging-policy-presets.test.ts +++ b/src/lib/onboard/messaging-policy-presets.test.ts @@ -7,9 +7,9 @@ import { allMessagingChannelPolicyPresets, hasDisabledMessagingPolicyPreset, mergeAppliedPolicyPresetsForDisabledMessagingCleanup, + mergeEnabledMessagingChannelPolicyPresets, mergePolicyMessagingChannels, mergeRebuildMessagingPolicyPresets, - mergeRequiredMessagingChannelPolicyPresets, pruneDisabledMessagingPolicyPresets, requiredMessagingChannelPolicyPresets, } from "./messaging-policy-presets"; @@ -21,16 +21,35 @@ describe("messaging policy presets", () => { }); it("merges required messaging presets into an existing selection", () => { - expect(mergeRequiredMessagingChannelPolicyPresets(["npm", "pypi"], ["slack"])).toEqual([ + expect(mergeEnabledMessagingChannelPolicyPresets(["npm", "pypi"], ["slack"])).toEqual([ "npm", "pypi", "slack", ]); }); - it("does not add a required preset that is not available to the sandbox", () => { + // #5967: a channel that is not flagged requiredAtCreate (Discord, Telegram, + // WhatsApp, Teams, WeChat) still needs its egress preset merged so policy + // finalization persists it and policy-list marks it applied. + it("merges an enabled channel preset that is not required at create time", () => { + expect(mergeEnabledMessagingChannelPolicyPresets(["npm"], ["discord"])).toEqual([ + "npm", + "discord", + ]); + expect(requiredMessagingChannelPolicyPresets(["discord"])).toEqual([]); + expect(mergeEnabledMessagingChannelPolicyPresets(["npm"], ["slack", "discord"])).toEqual([ + "npm", + "slack", + "discord", + ]); + }); + + it("does not add a channel preset that is not available to the sandbox", () => { + expect(mergeEnabledMessagingChannelPolicyPresets(["npm"], ["slack"], new Set(["npm"]))).toEqual( + ["npm"], + ); expect( - mergeRequiredMessagingChannelPolicyPresets(["npm"], ["slack"], new Set(["npm"])), + mergeEnabledMessagingChannelPolicyPresets(["npm"], ["discord"], new Set(["npm"])), ).toEqual(["npm"]); }); @@ -103,4 +122,51 @@ describe("messaging policy presets", () => { mergeAppliedPolicyPresetsForDisabledMessagingCleanup(["npm"], ["npm", "github"], ["slack"]), ).toEqual(["npm"]); }); + + // #5967 is channel-agnostic: every non-`requiredAtCreate` channel (Telegram, + // Teams, WhatsApp, WeChat) must merge and prune exactly like Discord. Cover the + // remaining channels explicitly so a future channel-table regression cannot pass + // on Slack/Discord alone. + it("merges every enabled non-required channel preset, not just Slack and Discord (#5967)", () => { + expect(mergeEnabledMessagingChannelPolicyPresets(["npm"], ["telegram"])).toEqual([ + "npm", + "telegram", + ]); + expect(mergeEnabledMessagingChannelPolicyPresets(["npm"], ["teams"])).toEqual(["npm", "teams"]); + expect(mergeEnabledMessagingChannelPolicyPresets(["npm"], ["whatsapp"])).toEqual([ + "npm", + "whatsapp", + ]); + expect(mergeEnabledMessagingChannelPolicyPresets(["npm"], ["wechat"])).toEqual([ + "npm", + "wechat", + ]); + }); + + it("prunes every disabled non-required channel preset (#5967)", () => { + expect(pruneDisabledMessagingPolicyPresets(["npm", "whatsapp"], ["whatsapp"])).toEqual(["npm"]); + expect(pruneDisabledMessagingPolicyPresets(["npm", "wechat"], ["wechat"])).toEqual(["npm"]); + }); + + it("leaves the selection untouched when no channels are enabled (#5967)", () => { + expect(mergeEnabledMessagingChannelPolicyPresets(["npm"], [])).toEqual(["npm"]); + expect(mergeEnabledMessagingChannelPolicyPresets(["npm"], null)).toEqual(["npm"]); + expect(mergeEnabledMessagingChannelPolicyPresets(["npm"], undefined)).toEqual(["npm"]); + }); + + it("yields no preset for an unknown channel name (#5967)", () => { + expect(allMessagingChannelPolicyPresets(["nonexistent"])).toEqual([]); + expect(mergeEnabledMessagingChannelPolicyPresets(["npm"], ["nonexistent"])).toEqual(["npm"]); + }); + + // Drift guard (#5967): the suggestion path's `add(channel)` shortcut was + // removed in favor of resolving presets through the channel→preset registry, + // and several call sites assume a channel's egress preset shares its name. + // Pin that 1:1 mapping for every shipped channel so a future preset rename + // (which would silently desync suggestions from finalization) fails here. + it("maps each messaging channel to a same-named egress preset (#5967)", () => { + for (const channel of ["slack", "discord", "telegram", "teams", "whatsapp", "wechat"]) { + expect(allMessagingChannelPolicyPresets([channel])).toEqual([channel]); + } + }); }); diff --git a/src/lib/onboard/messaging-policy-presets.ts b/src/lib/onboard/messaging-policy-presets.ts index 955073448b2..57c573bda3b 100644 --- a/src/lib/onboard/messaging-policy-presets.ts +++ b/src/lib/onboard/messaging-policy-presets.ts @@ -52,7 +52,19 @@ export function requiredMessagingChannelPolicyPresets( return required; } -export function mergeRequiredMessagingChannelPolicyPresets( +// Merge the policy presets every enabled messaging channel needs into a +// selection. An enabled channel cannot function without its network-egress +// preset, so that preset must survive policy finalization regardless of how the +// operator arrived at the selection (interactive tier, env-driven custom list, +// or a recorded resume set). We intentionally merge *all* of a channel's +// presets, not just the create-time `requiredAtCreate` ones: `requiredAtCreate` +// governs whether a preset is injected into the boot policy at sandbox-create +// time (only Slack today), while finalization applies any newly-merged preset +// to the live gateway itself. Using only the create-time-required set here drops +// every other channel's preset (Discord, Telegram, WhatsApp, Teams, WeChat) from +// the persisted selection, so `policy-list` shows them unapplied even though the +// channel was configured during onboard. See #5967. +export function mergeEnabledMessagingChannelPolicyPresets( selectedPresets: string[], channels: string[] | null | undefined, knownPresetNames?: Iterable | null, @@ -61,7 +73,7 @@ export function mergeRequiredMessagingChannelPolicyPresets( const selected = new Set(merged); const known = knownPresetNames ? new Set(knownPresetNames) : null; - for (const preset of requiredMessagingChannelPolicyPresets(channels)) { + for (const preset of allMessagingChannelPolicyPresets(channels)) { if (known && !known.has(preset)) continue; if (selected.has(preset)) continue; merged.push(preset); diff --git a/src/lib/onboard/openclaw-otel-policy-presets.test.ts b/src/lib/onboard/openclaw-otel-policy-presets.test.ts index af799fc2fae..dcd473d1921 100644 --- a/src/lib/onboard/openclaw-otel-policy-presets.test.ts +++ b/src/lib/onboard/openclaw-otel-policy-presets.test.ts @@ -4,7 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; vi.mock("./messaging-policy-presets", () => ({ - mergeRequiredMessagingChannelPolicyPresets: (presets: string[]) => presets, + mergeEnabledMessagingChannelPolicyPresets: (presets: string[]) => presets, requiredMessagingChannelPolicyPresets: () => [], pruneDisabledMessagingPolicyPresets: (presets: string[]) => presets, mergeAppliedPolicyPresetsForDisabledMessagingCleanup: (presets: string[]) => presets, @@ -16,14 +16,13 @@ vi.mock("./hermes-managed-tools", () => ({ HERMES_TOOL_GATEWAY_PRESET_NAMES: new Set(), })); -import { mergeRequiredSetupPolicyPresets } from "./policy-selection"; - import { - OPENCLAW_OTEL_LOCAL_POLICY_PRESET, isOpenclawOtelEnabled, mergeRequiredOpenclawOtelPolicyPresets, + OPENCLAW_OTEL_LOCAL_POLICY_PRESET, requiredOpenclawOtelPolicyPresets, } from "./openclaw-otel-policy-presets"; +import { mergeRequiredSetupPolicyPresets } from "./policy-selection"; describe("openclaw-otel-policy-presets", () => { const originalOtel = process.env.NEMOCLAW_OPENCLAW_OTEL; diff --git a/src/lib/onboard/policy-preset-persistence.test.ts b/src/lib/onboard/policy-preset-persistence.test.ts index 5a241bc6990..7098aa8aa8b 100644 --- a/src/lib/onboard/policy-preset-persistence.test.ts +++ b/src/lib/onboard/policy-preset-persistence.test.ts @@ -218,4 +218,35 @@ describe("persistFinalizedPolicyPresets (#4621)", () => { policyPresetsFinalized: true, }); }); + + // #5967 was a registry-persistence regression: an enabled messaging channel's + // preset reached the live gateway but was dropped from the registry `policies` + // write, so `policy-list` (which reads registry.policies) rendered `○`. Using + // the REAL built-in preset catalog proves Discord and Slack are recognized as + // built-ins and are written back to the registry, not filtered out. + it("persists enabled messaging channel presets (Discord, Slack) to the registry (#5967)", () => { + // Model the registry as observable state and read it back through the same + // boundary policy-list uses (registry.getSandbox().policies) rather than + // inspecting updateSandbox's call shape. + const entry = { name: "sb", policies: ["npm"] } as Partial & { + policies: string[]; + policyPresetsFinalized?: boolean; + }; + vi.spyOn(registry, "getCustomPolicies").mockReturnValue([]); + vi.spyOn(registry, "getSandbox").mockImplementation((name) => + name === "sb" ? (entry as registry.SandboxEntry) : null, + ); + vi.spyOn(registry, "updateSandbox").mockImplementation((_name, fields) => { + Object.assign(entry, fields); + return true; + }); + + persistFinalizedPolicyPresets("sb", ["npm", "pypi", "discord", "slack"]); + + const stored = registry.getSandbox("sb"); + expect(stored?.policyPresetsFinalized).toBe(true); + // Discord and Slack survive the built-in filter and are stored where + // policy-list reads them — the #5967 registry-persistence guarantee. + expect([...(stored?.policies ?? [])].sort()).toEqual(["discord", "npm", "pypi", "slack"]); + }); }); diff --git a/src/lib/onboard/policy-selection.ts b/src/lib/onboard/policy-selection.ts index 804a4723577..ac4a1f39500 100644 --- a/src/lib/onboard/policy-selection.ts +++ b/src/lib/onboard/policy-selection.ts @@ -13,9 +13,9 @@ import { mergeRequiredHermesToolGatewayPolicyPresets, } from "./hermes-managed-tools"; import { - mergeRequiredMessagingChannelPolicyPresets, + allMessagingChannelPolicyPresets, + mergeEnabledMessagingChannelPolicyPresets, pruneDisabledMessagingPolicyPresets, - requiredMessagingChannelPolicyPresets, } from "./messaging-policy-presets"; import { mergeRequiredOpenclawOtelPolicyPresets } from "./openclaw-otel-policy-presets"; import { seedInitialPolicyContext } from "./policy-context-seed"; @@ -118,7 +118,7 @@ export function mergeRequiredSetupPolicyPresets( ): string[] { const agentFilteredPresets = filterSetupPolicyPresetNamesForAgent(policyPresets, options.agent); const mergedPresets = mergeRequiredOpenclawOtelPolicyPresets( - mergeRequiredMessagingChannelPolicyPresets( + mergeEnabledMessagingChannelPolicyPresets( mergeRequiredHermesToolGatewayPolicyPresets( agentFilteredPresets, options.hermesToolGateways, @@ -189,8 +189,13 @@ export function computeSetupPresetSuggestions( for (const preset of allHermesToolGatewayPolicyPresets()) add(preset); } if (Array.isArray(enabledChannels)) { - for (const channel of enabledChannels) add(channel); - for (const preset of requiredMessagingChannelPolicyPresets(enabledChannels)) add(preset); + // Suggest every enabled channel's egress preset, matching the set + // finalization merges via `mergeEnabledMessagingChannelPolicyPresets`. + // Resolving through the channel→preset registry keeps the suggestion path + // correct for any channel (and any future preset rename) without relying on + // the channel name coinciding with its preset name or on `requiredAtCreate` + // (#5967). + for (const preset of allMessagingChannelPolicyPresets(enabledChannels)) add(preset); } if (Array.isArray(options.hermesToolGateways)) { for (const preset of options.hermesToolGateways) { diff --git a/test/onboard-policy-suggestions.test.ts b/test/onboard-policy-suggestions.test.ts index 07e8b8cef40..0cc829592ea 100644 --- a/test/onboard-policy-suggestions.test.ts +++ b/test/onboard-policy-suggestions.test.ts @@ -2,7 +2,14 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, expect, it } from "vitest"; - +import { filterSetupPolicyPresetsForAgent } from "../src/lib/onboard/agent-policy-presets"; +import { + allMessagingChannelPolicyPresets, + mergeEnabledMessagingChannelPolicyPresets, +} from "../src/lib/onboard/messaging-policy-presets"; + +// `../src/lib/onboard` is a CommonJS module (`module.exports = {}`), so it is +// loaded via `require` per the documented CJS exception for the onboard module. const { computeSetupPresetSuggestions, filterSetupPolicyPresets, getSuggestedPolicyPresets } = require("../src/lib/onboard") as { computeSetupPresetSuggestions: ( @@ -78,12 +85,6 @@ function withOpenclawOtelEnv(value: string | undefined, body: () => T): T { setOrUnset(endpointKey, originalEndpoint); } } -const { filterSetupPolicyPresetsForAgent } = require("../src/lib/onboard/agent-policy-presets") as { - filterSetupPolicyPresetsForAgent: ( - presets: T[], - agent?: string | null, - ) => T[]; -}; describe("onboard policy preset suggestions", () => { const known = [ @@ -149,6 +150,44 @@ describe("onboard policy preset suggestions", () => { } }); + // Cross-verification (#5967): the suggestion path + // (`computeSetupPresetSuggestions`) and the finalization merge + // (`mergeEnabledMessagingChannelPolicyPresets`) must contribute the SAME + // channel egress presets for a given `enabledChannels` set. If they diverged, + // an operator could be suggested a preset finalization later drops (or finalize + // one never suggested). Assert both paths yield exactly + // `allMessagingChannelPolicyPresets` for every channel individually and combined. + it("suggestion and finalization paths contribute identical channel presets for all channels (#5967)", () => { + const channels = ["slack", "discord", "telegram", "teams", "whatsapp", "wechat"]; + const knownNames = [...known, "teams", "whatsapp", "wechat"]; + const channelPresetSet = new Set(allMessagingChannelPolicyPresets(channels)); + const channelPresetsFromSuggestions = (enabled: string[]) => + computeSetupPresetSuggestions("balanced", { + enabledChannels: enabled, + knownPresetNames: knownNames, + }).filter((name) => channelPresetSet.has(name)); + + for (const channel of channels) { + // Compare set equality (sorted) rather than incidental array order, so a + // channel later expanding to multiple presets can't fail this guard on a + // harmless ordering difference between the two internal paths. + const expected = allMessagingChannelPolicyPresets([channel]).slice().sort(); + // Finalization merge contributes exactly the channel's egress presets... + expect( + mergeEnabledMessagingChannelPolicyPresets([], [channel], knownNames).slice().sort(), + ).toEqual(expected); + // ...and the suggestion path surfaces the same set. + expect(channelPresetsFromSuggestions([channel]).slice().sort()).toEqual(expected); + } + + // All channels enabled together: both paths agree on the full set. + const expectedAll = allMessagingChannelPolicyPresets(channels).slice().sort(); + expect( + mergeEnabledMessagingChannelPolicyPresets([], channels, knownNames).slice().sort(), + ).toEqual(expectedAll); + expect(channelPresetsFromSuggestions(channels).slice().sort()).toEqual(expectedAll); + }); + it("never auto-detects WhatsApp because the channel has no host env key", () => { const originalTelegramBotToken = process.env.TELEGRAM_BOT_TOKEN; delete process.env.TELEGRAM_BOT_TOKEN; diff --git a/test/onboard-preset-diff.test.ts b/test/onboard-preset-diff.test.ts index be0c09420d7..7cef9f85924 100644 --- a/test/onboard-preset-diff.test.ts +++ b/test/onboard-preset-diff.test.ts @@ -26,6 +26,12 @@ function runScript(scriptBody: string): SpawnSyncReturns { key.startsWith("DISCORD_") || key.startsWith("SLACK_") || key.startsWith("TELEGRAM_") || + // Teams credentials span both prefixes: the core bot credentials use + // `MSTEAMS_*` (MSTEAMS_APP_ID/APP_PASSWORD/TENANT_ID/PORT) while a couple + // of config keys use `TEAMS_*` (TEAMS_ALLOWED_USERS/REQUIRE_MENTION). + // Scrub both so a real `MSTEAMS_*` token can't activate Teams in the child. + key.startsWith("TEAMS_") || + key.startsWith("MSTEAMS_") || key.startsWith("WECHAT_") || key.startsWith("WHATSAPP_") ) { @@ -128,29 +134,59 @@ const { setupPoliciesWithSelection } = require(${onboardPath}); `; } -describe("setupPoliciesWithSelection preset diff (#2177)", () => { - // In non-interactive mode a user who runs onboard twice — first with Balanced - // defaults (applies 5 presets), second with NEMOCLAW_POLICY_PRESETS=npm — - // expects the final sandbox to have ONLY npm. Previously-applied presets - // must be removed. - it("non-interactive narrow selection removes previously-applied presets", () => { - const script = - buildPreamble({ policyMode: "custom", policyPresets: "npm" }) + - String.raw` +/** + * Run one `setupPoliciesWithSelection` scenario end-to-end in a child process: + * build the stub preamble, drive the call with `selectionOptions`, and return + * the parsed `{ chosen, appliedCalls, removedCalls, finalApplied }` payload after + * asserting the script ran cleanly. Collapses the identical preamble + IIFE + + * run/parse boilerplate each scenario would otherwise repeat; callers keep only + * their scenario-specific assertions. + */ +function runPolicyScenario({ + tierEnv, + policyMode, + policyPresets, + alreadyApplied, + selectionOptions = {}, +}: { + tierEnv?: string; + policyMode?: string; + policyPresets?: string; + alreadyApplied?: string[]; + selectionOptions?: Record; +} = {}): { + chosen: string[]; + appliedCalls: string[]; + removedCalls: string[]; + finalApplied: string[]; +} { + const script = + buildPreamble({ tierEnv, policyMode, policyPresets, alreadyApplied }) + + String.raw` console.log = () => {}; (async () => { try { - const chosen = await setupPoliciesWithSelection("test-sb", {}); + const chosen = await setupPoliciesWithSelection("test-sb", ${JSON.stringify(selectionOptions)}); process.stdout.write(JSON.stringify({ chosen, appliedCalls, removedCalls, finalApplied: appliedState }) + "\n"); } catch (err) { process.stdout.write(JSON.stringify({ error: err.message }) + "\n"); } })(); `; - const result = runScript(script); - assert.equal(result.status, 0, result.stderr); - const payload = JSON.parse(result.stdout.trim()); - assert.ok(!payload.error, `unexpected error: ${payload.error}`); + const result = runScript(script); + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim()); + assert.ok(!payload.error, `unexpected error: ${payload.error}`); + return payload; +} + +describe("setupPoliciesWithSelection preset diff (#2177)", () => { + // In non-interactive mode a user who runs onboard twice — first with Balanced + // defaults (applies 5 presets), second with NEMOCLAW_POLICY_PRESETS=npm — + // expects the final sandbox to have ONLY npm. Previously-applied presets + // must be removed. + it("non-interactive narrow selection removes previously-applied presets", () => { + const payload = runPolicyScenario({ policyMode: "custom", policyPresets: "npm" }); // User asked for only npm. assert.deepEqual(payload.chosen, ["npm"]); @@ -178,28 +214,13 @@ console.log = () => {}; // user-added preset such as `local-inference` is not in `suggestions` on a // cloud-provider sandbox — without the additive guard it would be removed. it("non-interactive suggested re-onboard preserves user-added presets", () => { - const script = - buildPreamble({ - policyMode: "suggested", - policyPresets: "", - // Balanced defaults plus a manually-added preset. - alreadyApplied: ["npm", "pypi", "huggingface", "brew", "brave", "local-inference"], - }) + - String.raw` -console.log = () => {}; -(async () => { - try { - const chosen = await setupPoliciesWithSelection("test-sb", { provider: "openai" }); - process.stdout.write(JSON.stringify({ chosen, appliedCalls, removedCalls, finalApplied: appliedState }) + "\n"); - } catch (err) { - process.stdout.write(JSON.stringify({ error: err.message }) + "\n"); - } -})(); -`; - const result = runScript(script); - assert.equal(result.status, 0, result.stderr); - const payload = JSON.parse(result.stdout.trim()); - assert.ok(!payload.error, `unexpected error: ${payload.error}`); + const payload = runPolicyScenario({ + policyMode: "suggested", + policyPresets: "", + // Balanced defaults plus a manually-added preset. + alreadyApplied: ["npm", "pypi", "huggingface", "brew", "brave", "local-inference"], + selectionOptions: { provider: "openai" }, + }); // The user-added preset must still be in the chosen list. assert.ok( @@ -232,27 +253,12 @@ console.log = () => {}; // non-interactive re-onboard the same way named built-ins do — even though // they do not appear in `policies.listPresets()`. it("non-interactive suggested re-onboard preserves custom presets", () => { - const script = - buildPreamble({ - policyMode: "suggested", - policyPresets: "", - alreadyApplied: ["npm", "pypi", "huggingface", "brew", "brave", "my-internal-api"], - }) + - String.raw` -console.log = () => {}; -(async () => { - try { - const chosen = await setupPoliciesWithSelection("test-sb", { provider: "openai" }); - process.stdout.write(JSON.stringify({ chosen, appliedCalls, removedCalls, finalApplied: appliedState }) + "\n"); - } catch (err) { - process.stdout.write(JSON.stringify({ error: err.message }) + "\n"); - } -})(); -`; - const result = runScript(script); - assert.equal(result.status, 0, result.stderr); - const payload = JSON.parse(result.stdout.trim()); - assert.ok(!payload.error, `unexpected error: ${payload.error}`); + const payload = runPolicyScenario({ + policyMode: "suggested", + policyPresets: "", + alreadyApplied: ["npm", "pypi", "huggingface", "brew", "brave", "my-internal-api"], + selectionOptions: { provider: "openai" }, + }); assert.ok( payload.chosen.includes("my-internal-api"), @@ -266,30 +272,12 @@ console.log = () => {}; }); it("non-interactive suggested re-onboard removes unsupported Brave preset", () => { - const script = - buildPreamble({ - policyMode: "suggested", - policyPresets: "", - alreadyApplied: ["npm", "pypi", "huggingface", "brew", "brave", "my-internal-api"], - }) + - String.raw` -console.log = () => {}; -(async () => { - try { - const chosen = await setupPoliciesWithSelection("test-sb", { - provider: "openai", - webSearchSupported: false, + const payload = runPolicyScenario({ + policyMode: "suggested", + policyPresets: "", + alreadyApplied: ["npm", "pypi", "huggingface", "brew", "brave", "my-internal-api"], + selectionOptions: { provider: "openai", webSearchSupported: false }, }); - process.stdout.write(JSON.stringify({ chosen, appliedCalls, removedCalls, finalApplied: appliedState }) + "\n"); - } catch (err) { - process.stdout.write(JSON.stringify({ error: err.message }) + "\n"); - } -})(); -`; - const result = runScript(script); - assert.equal(result.status, 0, result.stderr); - const payload = JSON.parse(result.stdout.trim()); - assert.ok(!payload.error, `unexpected error: ${payload.error}`); assert.ok( !payload.chosen.includes("brave"), @@ -311,30 +299,12 @@ console.log = () => {}; }); it("resume selection removes unsupported Brave preset", () => { - const script = - buildPreamble({ - policyMode: "suggested", - policyPresets: "", - alreadyApplied: ["npm", "brave"], - }) + - String.raw` -console.log = () => {}; -(async () => { - try { - const chosen = await setupPoliciesWithSelection("test-sb", { - selectedPresets: ["npm", "brave"], - webSearchSupported: false, + const payload = runPolicyScenario({ + policyMode: "suggested", + policyPresets: "", + alreadyApplied: ["npm", "brave"], + selectionOptions: { selectedPresets: ["npm", "brave"], webSearchSupported: false }, }); - process.stdout.write(JSON.stringify({ chosen, appliedCalls, removedCalls, finalApplied: appliedState }) + "\n"); - } catch (err) { - process.stdout.write(JSON.stringify({ error: err.message }) + "\n"); - } -})(); -`; - const result = runScript(script); - assert.equal(result.status, 0, result.stderr); - const payload = JSON.parse(result.stdout.trim()); - assert.ok(!payload.error, `unexpected error: ${payload.error}`); assert.deepEqual(payload.chosen, ["npm"]); assert.deepEqual(payload.removedCalls, ["brave"]); @@ -342,30 +312,12 @@ console.log = () => {}; }); it("resume selection preserves the Slack policy required by a recorded Slack channel", () => { - const script = - buildPreamble({ - policyMode: "suggested", - policyPresets: "", - alreadyApplied: ["slack"], - }) + - String.raw` -console.log = () => {}; -(async () => { - try { - const chosen = await setupPoliciesWithSelection("test-sb", { - selectedPresets: ["npm", "pypi"], - enabledChannels: ["slack"], + const payload = runPolicyScenario({ + policyMode: "suggested", + policyPresets: "", + alreadyApplied: ["slack"], + selectionOptions: { selectedPresets: ["npm", "pypi"], enabledChannels: ["slack"] }, }); - process.stdout.write(JSON.stringify({ chosen, appliedCalls, removedCalls, finalApplied: appliedState }) + "\n"); - } catch (err) { - process.stdout.write(JSON.stringify({ error: err.message }) + "\n"); - } -})(); -`; - const result = runScript(script); - assert.equal(result.status, 0, result.stderr); - const payload = JSON.parse(result.stdout.trim()); - assert.ok(!payload.error, `unexpected error: ${payload.error}`); assert.deepEqual(payload.chosen.slice().sort(), ["npm", "pypi", "slack"]); assert.deepEqual( @@ -377,29 +329,12 @@ console.log = () => {}; }); it("custom non-interactive selection preserves the Slack policy required by Slack messaging", () => { - const script = - buildPreamble({ - policyMode: "custom", - policyPresets: "npm,pypi", - alreadyApplied: ["slack"], - }) + - String.raw` -console.log = () => {}; -(async () => { - try { - const chosen = await setupPoliciesWithSelection("test-sb", { - enabledChannels: ["slack"], + const payload = runPolicyScenario({ + policyMode: "custom", + policyPresets: "npm,pypi", + alreadyApplied: ["slack"], + selectionOptions: { enabledChannels: ["slack"] }, }); - process.stdout.write(JSON.stringify({ chosen, appliedCalls, removedCalls, finalApplied: appliedState }) + "\n"); - } catch (err) { - process.stdout.write(JSON.stringify({ error: err.message }) + "\n"); - } -})(); -`; - const result = runScript(script); - assert.equal(result.status, 0, result.stderr); - const payload = JSON.parse(result.stdout.trim()); - assert.ok(!payload.error, `unexpected error: ${payload.error}`); assert.deepEqual(payload.chosen.slice().sort(), ["npm", "pypi", "slack"]); assert.deepEqual( @@ -410,30 +345,139 @@ console.log = () => {}; assert.deepEqual(payload.finalApplied.slice().sort(), ["npm", "pypi", "slack"]); }); - it("custom non-interactive selection removes disabled Slack while honoring the explicit preset list", () => { - const script = - buildPreamble({ + // Regression for #5967: Discord (and every messaging channel other than + // Slack) is not flagged `requiredAtCreate`, so its policy preset is never + // injected into the create-time boot policy. The policy finalization step + // must still merge the enabled channel's preset into the effective selection + // so it is applied to the gateway and persisted to the registry — otherwise + // `policy-list` shows `○ discord` even though Discord was configured during + // onboard. The Slack tests above pass purely because Slack happens to be + // requiredAtCreate; these tests guard the channels that are not. + it("resume selection applies the Discord policy required by a configured Discord channel (#5967)", () => { + const payload = runPolicyScenario({ + policyMode: "suggested", + policyPresets: "", + // Discord is not injected at create time, so it is absent from the + // already-applied boot presets — unlike Slack. + alreadyApplied: [], + selectionOptions: { selectedPresets: ["npm", "pypi"], enabledChannels: ["discord"] }, + }); + + assert.deepEqual(payload.chosen.slice().sort(), ["discord", "npm", "pypi"]); + assert.ok( + payload.appliedCalls.includes("discord"), + `Discord must be applied to the gateway when the channel is enabled; got applied ${JSON.stringify(payload.appliedCalls)}`, + ); + assert.deepEqual(payload.finalApplied.slice().sort(), ["discord", "npm", "pypi"]); + }); + + it("custom non-interactive selection applies the Discord policy required by Discord messaging (#5967)", () => { + const payload = runPolicyScenario({ + policyMode: "custom", + policyPresets: "npm,pypi", + alreadyApplied: [], + selectionOptions: { enabledChannels: ["discord"] }, + }); + + assert.deepEqual(payload.chosen.slice().sort(), ["discord", "npm", "pypi"]); + assert.ok( + payload.appliedCalls.includes("discord"), + `Discord must be applied while Discord messaging is enabled; got applied ${JSON.stringify(payload.appliedCalls)}`, + ); + assert.deepEqual(payload.finalApplied.slice().sort(), ["discord", "npm", "pypi"]); + }); + + it("custom non-interactive selection removes disabled Discord while honoring the explicit preset list (#5967)", () => { + const payload = runPolicyScenario({ + policyMode: "custom", + policyPresets: "npm", + alreadyApplied: ["npm", "pypi", "discord"], + selectionOptions: { disabledChannels: ["discord"] }, + }); + + assert.deepEqual(payload.chosen, ["npm"]); + assert.deepEqual(payload.removedCalls.slice().sort(), ["discord", "pypi"]); + assert.deepEqual(payload.finalApplied, ["npm"]); + }); + + // The #5967 fix is channel-agnostic — it iterates the channel→preset registry + // rather than special-casing Slack/Discord. Telegram is another channel that is + // not `requiredAtCreate`, so its egress preset is never injected at create time; + // exercising it end-to-end through the real `setupPoliciesWithSelection` path + // guards the security-critical egress-policy application for a second, distinct + // non-required channel (not just Discord). + it("resume selection applies the Telegram policy required by a configured Telegram channel (#5967)", () => { + const payload = runPolicyScenario({ + policyMode: "suggested", + policyPresets: "", + alreadyApplied: [], + selectionOptions: { selectedPresets: ["npm", "pypi"], enabledChannels: ["telegram"] }, + }); + + assert.deepEqual(payload.chosen.slice().sort(), ["npm", "pypi", "telegram"]); + assert.ok( + payload.appliedCalls.includes("telegram"), + `Telegram must be applied to the gateway when the channel is enabled; got applied ${JSON.stringify(payload.appliedCalls)}`, + ); + assert.deepEqual(payload.finalApplied.slice().sort(), ["npm", "pypi", "telegram"]); + }); + + it("custom non-interactive selection removes disabled Telegram while honoring the explicit preset list (#5967)", () => { + const payload = runPolicyScenario({ + policyMode: "custom", + policyPresets: "npm", + alreadyApplied: ["npm", "pypi", "telegram"], + selectionOptions: { disabledChannels: ["telegram"] }, + }); + + assert.deepEqual(payload.chosen, ["npm"]); + assert.deepEqual(payload.removedCalls.slice().sort(), ["pypi", "telegram"]); + assert.deepEqual(payload.finalApplied, ["npm"]); + }); + + // Cover the remaining non-`requiredAtCreate` channels end-to-end through the + // real `setupPoliciesWithSelection` path. They flow through the same + // channel→preset registry iteration as Discord/Telegram, so each apply/remove + // case guards the egress-policy application for every shipped channel — not + // only the two already covered above (#5967). + for (const channel of ["teams", "whatsapp", "wechat"]) { + it(`resume selection applies the ${channel} policy required by a configured ${channel} channel (#5967)`, () => { + const payload = runPolicyScenario({ + policyMode: "suggested", + policyPresets: "", + alreadyApplied: [], + selectionOptions: { selectedPresets: ["npm", "pypi"], enabledChannels: [channel] }, + }); + + assert.deepEqual(payload.chosen.slice().sort(), ["npm", "pypi", channel].sort()); + assert.ok( + payload.appliedCalls.includes(channel), + `${channel} must be applied to the gateway when the channel is enabled; got applied ${JSON.stringify(payload.appliedCalls)}`, + ); + assert.deepEqual(payload.finalApplied.slice().sort(), ["npm", "pypi", channel].sort()); + }); + + it(`custom non-interactive selection removes disabled ${channel} while honoring the explicit preset list (#5967)`, () => { + const payload = runPolicyScenario({ policyMode: "custom", policyPresets: "npm", - alreadyApplied: ["npm", "pypi", "slack"], - }) + - String.raw` -console.log = () => {}; -(async () => { - try { - const chosen = await setupPoliciesWithSelection("test-sb", { - disabledChannels: ["slack"], + alreadyApplied: ["npm", "pypi", channel], + selectionOptions: { disabledChannels: [channel] }, + }); + + assert.deepEqual(payload.chosen, ["npm"]); + assert.deepEqual(payload.removedCalls.slice().sort(), ["pypi", channel].sort()); + assert.deepEqual(payload.finalApplied, ["npm"]); }); - process.stdout.write(JSON.stringify({ chosen, appliedCalls, removedCalls, finalApplied: appliedState }) + "\n"); - } catch (err) { - process.stdout.write(JSON.stringify({ error: err.message }) + "\n"); } -})(); -`; - const result = runScript(script); - assert.equal(result.status, 0, result.stderr); - const payload = JSON.parse(result.stdout.trim()); - assert.ok(!payload.error, `unexpected error: ${payload.error}`); + + it("custom non-interactive selection removes disabled Slack while honoring the explicit preset list", () => { + const payload = runPolicyScenario({ + policyMode: "custom", + policyPresets: "npm", + alreadyApplied: ["npm", "pypi", "slack"], + selectionOptions: { disabledChannels: ["slack"] }, + }); assert.deepEqual(payload.chosen, ["npm"]); assert.deepEqual(payload.removedCalls.slice().sort(), ["pypi", "slack"]); @@ -441,30 +485,13 @@ console.log = () => {}; }); it("suggested non-interactive selection removes disabled Slack from tier defaults", () => { - const script = - buildPreamble({ - tierEnv: "open", - policyMode: "suggested", - policyPresets: "", - alreadyApplied: ["slack"], - }) + - String.raw` -console.log = () => {}; -(async () => { - try { - const chosen = await setupPoliciesWithSelection("test-sb", { - disabledChannels: ["slack"], + const payload = runPolicyScenario({ + tierEnv: "open", + policyMode: "suggested", + policyPresets: "", + alreadyApplied: ["slack"], + selectionOptions: { disabledChannels: ["slack"] }, }); - process.stdout.write(JSON.stringify({ chosen, appliedCalls, removedCalls, finalApplied: appliedState }) + "\n"); - } catch (err) { - process.stdout.write(JSON.stringify({ error: err.message }) + "\n"); - } -})(); -`; - const result = runScript(script); - assert.equal(result.status, 0, result.stderr); - const payload = JSON.parse(result.stdout.trim()); - assert.ok(!payload.error, `unexpected error: ${payload.error}`); assert.ok( !payload.chosen.includes("slack"), @@ -480,27 +507,11 @@ console.log = () => {}; // Widening the selection (user re-enables a preset they'd previously dropped) // must apply the new one and not re-apply things that are already applied. it("non-interactive widen selection applies only new presets", () => { - const script = - buildPreamble({ - policyMode: "custom", - policyPresets: "npm,pypi", - alreadyApplied: ["npm"], - }) + - String.raw` -console.log = () => {}; -(async () => { - try { - const chosen = await setupPoliciesWithSelection("test-sb", {}); - process.stdout.write(JSON.stringify({ chosen, appliedCalls, removedCalls, finalApplied: appliedState }) + "\n"); - } catch (err) { - process.stdout.write(JSON.stringify({ error: err.message }) + "\n"); - } -})(); -`; - const result = runScript(script); - assert.equal(result.status, 0, result.stderr); - const payload = JSON.parse(result.stdout.trim()); - assert.ok(!payload.error, `unexpected error: ${payload.error}`); + const payload = runPolicyScenario({ + policyMode: "custom", + policyPresets: "npm,pypi", + alreadyApplied: ["npm"], + }); assert.deepEqual(payload.chosen.sort(), ["npm", "pypi"]); // Only pypi should be newly applied (npm was already there).