diff --git a/docs/manage-sandboxes/enable-channels-during-onboarding.mdx b/docs/manage-sandboxes/enable-channels-during-onboarding.mdx index d9aef0eb64f..e26f84210b8 100644 --- a/docs/manage-sandboxes/enable-channels-during-onboarding.mdx +++ b/docs/manage-sandboxes/enable-channels-during-onboarding.mdx @@ -4,8 +4,8 @@ title: "Enable Channels During Onboarding" sidebar-title: "Enable Channels During Onboarding" description: "Select messaging channels and supply their credentials or pairing inputs during NemoClaw onboarding." -description-agent: "Explains the interactive and scripted onboarding flows for selecting messaging channels and creating OpenShell bridge providers. Use when enabling channels on a new sandbox." -keywords: ["nemoclaw onboard messaging", "messaging channel picker", "channel environment variables"] +description-agent: "Explains the interactive and scripted onboarding flows for selecting messaging channels, creating OpenShell bridge providers, and removing a channel by clearing its host inputs. Use when enabling or disabling channels during onboarding." +keywords: ["nemoclaw onboard messaging", "messaging channel picker", "channel environment variables", "disable messaging channel"] content: type: "how_to" agent-variants: ["openclaw", "hermes"] @@ -25,7 +25,7 @@ Refer to [Set Up Google Chat](set-up-google-chat) before selecting it. If you select no channels, pressing **Enter** skips messaging setup. -If a token-based channel token is not already in the environment or credential store, the wizard prompts for it and saves it. +If the current host inputs do not include a token-based channel token, the wizard prompts for it and stages it for the current onboarding process. If you enable WeChat, the wizard renders a QR code, polls Tencent's iLink gateway, and captures the bot token after you scan the QR with WeChat on your phone. The login has an eight-minute deadline, refreshes the QR up to three times on expiry, and follows iLink's IDC redirects automatically. @@ -78,6 +78,28 @@ $$nemoclaw onboard Complete the wizard so the blueprint can create OpenShell providers where needed, such as `-telegram-bridge`, `-teams-bridge`, or `-wechat-bridge`. The wizard writes channel configuration into the image through `NEMOCLAW_MESSAGING_CHANNELS_B64` and starts the sandbox. +## Stop Configuring a Channel + +Onboarding reads the host inputs on every run, so clearing a channel's inputs and re-onboarding removes it. +Unset the channel's environment variables. +Run onboarding again. + +NemoClaw reports the removal and drops the channel's network policy preset with it, so the sandbox does not keep the wider egress of a channel it no longer serves. + +Expected output: + +```text + No host inputs configure discord; disabling the channel and its network egress. + [non-interactive] Applying policy presets: npm, pypi +``` + +Onboarding uses the current host inputs to determine whether a token-based channel remains configured. +`$$nemoclaw credentials reset` takes an OpenShell provider name and does not change those host inputs. + +A QR-paired channel such as WhatsApp is exempt. +The host holds no value that reports whether the pairing is still live, so an absent host input is not evidence that you removed the channel. +Use [`channels remove`](manage-messaging-channels) for those. + ## Verify the Result After the sandbox is running, send a message to the configured bot or app. diff --git a/src/lib/onboard/machine/handlers/policies.test.ts b/src/lib/onboard/machine/handlers/policies.test.ts index c51468885b6..e966c69c484 100644 --- a/src/lib/onboard/machine/handlers/policies.test.ts +++ b/src/lib/onboard/machine/handlers/policies.test.ts @@ -100,6 +100,47 @@ describe("handlePoliciesState", () => { ); }); + it("disables a channel whose preset is applied but which no plan still names (#9283)", async () => { + const { deps, calls } = createDeps({ + getActiveSandbox: vi.fn(() => ({ + messaging: null, + policies: ["npm", "pypi", "discord"], + })), + detectUnconfiguredMessagingChannels: vi.fn( + (planChannels: readonly string[]) => [...planChannels], + ), + }); + + await handlePoliciesState({ ...baseOptions(deps), selectedMessagingChannels: [] }); + + expect(deps.detectUnconfiguredMessagingChannels).toHaveBeenCalledWith(["discord"], [], null); + expect(calls.setupPolicies).toHaveBeenCalledWith( + "my-assistant", + expect.objectContaining({ enabledChannels: [], disabledChannels: ["discord"] }), + ); + }); + + it("leaves a still-configured channel enabled when its preset is applied (#9283)", async () => { + const { deps, calls } = createDeps({ + getActiveSandbox: vi.fn(() => ({ + messaging: null, + policies: ["npm", "discord"], + })), + }); + + await handlePoliciesState({ ...baseOptions(deps), selectedMessagingChannels: ["discord"] }); + + expect(deps.detectUnconfiguredMessagingChannels).toHaveBeenCalledWith( + ["discord"], + ["discord"], + null, + ); + expect(calls.setupPolicies).toHaveBeenCalledWith( + "my-assistant", + expect.objectContaining({ enabledChannels: ["discord"], disabledChannels: [] }), + ); + }); + it("keeps a still-configured channel enabled", async () => { const { deps, calls } = createDeps({ getActiveSandbox: vi.fn(() => ({ diff --git a/src/lib/onboard/machine/handlers/policies.ts b/src/lib/onboard/machine/handlers/policies.ts index 524fee7eb44..a54b20a3029 100644 --- a/src/lib/onboard/machine/handlers/policies.ts +++ b/src/lib/onboard/machine/handlers/policies.ts @@ -7,6 +7,7 @@ import { getActiveChannelsFromPlan, getDisabledChannelsFromPlan, } from "../../messaging-plan-session"; +import { messagingChannelsForPolicyPresets } from "../../messaging-policy-presets"; import type { HostLocalInferenceSandboxProofAuthority } from "../../runtime-provider/host-local-inference-routing"; import { advanceTo, type OnboardStateTransitionResult } from "../result"; @@ -26,6 +27,8 @@ export interface PolicyPresetEntry { export interface ActiveSandboxPolicyState { messaging?: { plan: SandboxMessagingPlan } | null; policyTier?: string | null; + /** Preset names already applied to the sandbox, as recorded in the registry. */ + policies?: string[] | null; } export interface PolicyResumeSelection { @@ -177,8 +180,16 @@ export async function handlePoliciesState({ // run re-applies its egress preset. Adding it to `disabledChannels` here lets // the existing disabled-channel pruning drop the preset from both the merged // selection and the previously-applied set. + // + // The applied preset list is the third candidate source because it outlives + // the plans: a sandbox can carry a channel's egress in `policies` after every + // plan that named the channel is gone, and only a candidate here can retire + // it. + const appliedPresetMessagingChannels = messagingChannelsForPolicyPresets( + activeSandbox?.policies, + ); const unconfiguredMessagingChannels = deps.detectUnconfiguredMessagingChannels( - [...recordedMessagingChannels, ...activeMessagingChannels], + [...recordedMessagingChannels, ...activeMessagingChannels, ...appliedPresetMessagingChannels], selectedMessagingChannels, agent, ); diff --git a/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts b/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts index 2467c86c288..bd8adc08d8d 100644 --- a/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-messaging.test.ts @@ -16,6 +16,7 @@ import { } from "../../../state/onboard-checkpoint-types"; import { createSession, type Session } from "../../../state/onboard-session"; import { setupMessagingChannels } from "../../messaging-channel-setup"; +import { getActiveChannelsFromPlan } from "../../messaging-plan-session"; import { hasMessagingCredentialDrift, reconcileReusedSandboxMessaging, @@ -186,6 +187,21 @@ function discordPlan(credentialHash: string): SandboxMessagingPlan { }; } +function withChannelDisabled( + plan: SandboxMessagingPlan, + channelId: string, +): SandboxMessagingPlan { + return { + ...plan, + channels: plan.channels.map((channel) => + channel.channelId === channelId + ? { ...channel, active: false, selected: false, disabled: true } + : channel, + ), + disabledChannels: [...new Set([...plan.disabledChannels, channelId])], + }; +} + function whatsappPlan(): SandboxMessagingPlan { return { ...telegramPlan(""), @@ -359,14 +375,12 @@ describe("reconcileReusedSandboxMessaging", () => { it("does not clear an equal recorded plan from a different authority", () => { const plan = telegramPlan(hashCredential("123456:registry-token") ?? ""); const clearPlanEnv = vi.fn(); - // Keep the channel host-configured so this case stays about plan equality, - // not the #9283 unconfigured-channel selection filter. vi.stubEnv("TELEGRAM_BOT_TOKEN", "123456:registry-token"); const result = reconcileReusedSandboxMessaging( structuredClone(plan), { name: "openclaw" }, - { clearPlanEnv }, + { clearPlanEnv, note: vi.fn(), writePlanToEnv: vi.fn() }, plan, ); @@ -386,10 +400,13 @@ describe("reconcileReusedSandboxMessaging", () => { plan, ); - // The plan still records the channel — only the reported selection drops - // it, so the policies handler classifies it as unconfigured and prunes its - // egress preset instead of re-applying it on every later onboarding run. - expect(result).toEqual({ plan, selectedChannels: [], changed: false }); + // Persist the removal so later readers cannot re-enable the channel and + // re-apply its egress preset. + expect(result).toEqual({ + plan: withChannelDisabled(plan, "discord"), + selectedChannels: [], + changed: true, + }); expect(clearPlanEnv).not.toHaveBeenCalled(); }); @@ -425,13 +442,11 @@ describe("reconcileReusedSandboxMessaging", () => { }); it("removes every unsupported channel artifact from a reused plan", () => { - // Keep the channel host-configured so this case stays about unsupported - // artifact removal, not the #9283 unconfigured-channel selection filter. vi.stubEnv("TELEGRAM_BOT_TOKEN", "123456:registry-token"); const result = reconcileReusedSandboxMessaging( mixedChannelPlan(), { name: "openclaw" }, - { clearPlanEnv() {} }, + { clearPlanEnv() {}, note() {}, writePlanToEnv() {} }, ); const filtered = result.plan; @@ -466,6 +481,25 @@ describe("reconcileReusedSandboxMessaging", () => { healthChecks: ["telegram"], }); }); + + it("disables and stages an unconfigured host-backed channel for Ready sandbox reuse (#9283)", () => { + const plan = discordPlan(hashCredential("previous-discord-token") ?? ""); + const deps = reconcileDeps([]); + vi.stubEnv("DISCORD_BOT_TOKEN", ""); + + const result = reconcileReusedSandboxMessaging( + plan, + { name: "openclaw" }, + deps, + structuredClone(plan), + ); + const disabledPlan = withChannelDisabled(plan, "discord"); + + expect(result).toEqual({ plan: disabledPlan, selectedChannels: [], changed: true }); + expect(deps.writePlanToEnv).toHaveBeenLastCalledWith(disabledPlan); + expect(deps.clearPlanEnv).not.toHaveBeenCalled(); + expect(deps.note).toHaveBeenCalledWith(expect.stringContaining("No host inputs configure")); + }); }); describe("reconcileSandboxMessaging plan authority", () => { @@ -536,7 +570,86 @@ describe("reconcileSandboxMessaging plan authority", () => { }); expect(deps.setupMessagingChannels).not.toHaveBeenCalled(); - expect(result).toEqual({ plan: registryPlan, selectedChannels: [] }); + expect(result).toEqual({ + plan: withChannelDisabled(registryPlan, "discord"), + selectedChannels: [], + }); + }); + + it("records the removal in the plan so a later reader cannot re-enable it (#9283)", async () => { + const registryPlan = discordPlan(hashCredential("previous-discord-token") ?? ""); + const disabledPlan = withChannelDisabled(registryPlan, "discord"); + const deps = reconcileDeps([]); + deps.getRegistrySandboxMessagingAuthority.mockReturnValue({ + authoritative: true, + plan: registryPlan, + }); + vi.stubEnv("DISCORD_BOT_TOKEN", ""); + + const result = await reconcileSandboxMessaging({ + resume: false, + session: null, + sandboxName: "alpha", + agent: { name: "openclaw" }, + deps, + }); + + expect(getActiveChannelsFromPlan(result.plan)).toEqual([]); + expect(result.plan?.disabledChannels).toEqual(["discord"]); + expect(deps.writePlanToEnv).toHaveBeenLastCalledWith(disabledPlan); + expect(deps.note).toHaveBeenCalledWith(expect.stringContaining("No host inputs configure")); + }); + + it("omits a removed host-backed channel from a lifecycle-workflow registry plan (#9283)", async () => { + const registryPlan = { + ...discordPlan(hashCredential("previous-discord-token") ?? ""), + workflow: "add-channel" as const, + }; + const deps = reconcileDeps([]); + deps.getRegistrySandboxMessagingAuthority.mockReturnValue({ + authoritative: true, + plan: registryPlan, + }); + vi.stubEnv("DISCORD_BOT_TOKEN", ""); + + const result = await reconcileSandboxMessaging({ + resume: false, + session: null, + sandboxName: "alpha", + agent: { name: "openclaw" }, + deps, + }); + + expect(deps.setupMessagingChannels).not.toHaveBeenCalled(); + expect(result).toEqual({ + plan: withChannelDisabled(registryPlan, "discord"), + selectedChannels: [], + }); + }); + + it("keeps a still-configured channel in a lifecycle-workflow registry plan (#9283)", async () => { + const token = "still-configured-discord-token"; + const registryPlan = { + ...discordPlan(hashCredential(token) ?? ""), + workflow: "add-channel" as const, + }; + const deps = reconcileDeps([]); + deps.getRegistrySandboxMessagingAuthority.mockReturnValue({ + authoritative: true, + plan: registryPlan, + }); + vi.stubEnv("DISCORD_BOT_TOKEN", token); + + const result = await reconcileSandboxMessaging({ + resume: false, + session: null, + sandboxName: "alpha", + agent: { name: "openclaw" }, + deps, + }); + + expect(result.selectedChannels).toEqual(["discord"]); + expect(result.plan?.disabledChannels).toEqual([]); }); it("omits a removed host-backed channel from a completed registry resume (#9109)", async () => { @@ -557,7 +670,10 @@ describe("reconcileSandboxMessaging plan authority", () => { }); expect(deps.setupMessagingChannels).not.toHaveBeenCalled(); - expect(result).toEqual({ plan: registryPlan, selectedChannels: [] }); + expect(result).toEqual({ + plan: withChannelDisabled(registryPlan, "discord"), + selectedChannels: [], + }); }); it("omits a retired host-backed channel from recorded resume channels (#9283)", async () => { diff --git a/src/lib/onboard/machine/handlers/sandbox-messaging.ts b/src/lib/onboard/machine/handlers/sandbox-messaging.ts index ad6efb84acd..c8bc01d15ca 100644 --- a/src/lib/onboard/machine/handlers/sandbox-messaging.ts +++ b/src/lib/onboard/machine/handlers/sandbox-messaging.ts @@ -225,6 +225,7 @@ function selectionFromReusablePlan( function filterUnconfiguredHostChannelsFromSelection( selection: SandboxMessagingSelection, agent: Agent, + deps: Pick, "clearPlanEnv" | "note" | "writePlanToEnv">, ): SandboxMessagingSelection { // A registry plan records the previous selection, not the current host // input. Rebuild the host-backed selection so policy reconciliation can @@ -238,14 +239,43 @@ function filterUnconfiguredHostChannelsFromSelection( ), ); if (unconfiguredChannels.size === 0) return selection; + deps.note( + ` No host inputs configure ${[...unconfiguredChannels].join(", ")}; disabling the channel and its network egress.`, + ); + const plan = disableChannelsInPlan(selection.plan, unconfiguredChannels); + if (plan) deps.writePlanToEnv(plan); + else deps.clearPlanEnv(); return { - ...selection, + plan, selectedChannels: selection.selectedChannels.filter( (channelId) => !unconfiguredChannels.has(channelId), ), }; } +/** + * Record the removal in the plan itself, not only in the selection derived from + * it. The plan is what reaches the registry and the next run, so a selection + * that alone drops the channel leaves every later reader to rediscover the + * removal from host inputs — and a reader that cannot, keeps the channel's + * network egress applied. + */ +function disableChannelsInPlan( + plan: SandboxMessagingPlan | null, + channelIds: ReadonlySet, +): SandboxMessagingPlan | null { + if (!plan) return null; + return { + ...plan, + channels: plan.channels.map((channel) => + channelIds.has(channel.channelId) + ? { ...channel, active: false, selected: false, disabled: true } + : channel, + ), + disabledChannels: [...new Set([...plan.disabledChannels, ...channelIds])], + }; +} + function requireValidatedActiveChannels( selection: SandboxMessagingSelection, requiredChannels: readonly string[], @@ -386,7 +416,14 @@ async function selectionFromRegistryPlan( options: ReconcileSandboxMessagingOptions, ): Promise { if (registryPlanRecordsLifecycleSelection(registryPlan)) { - return selectionFromReusablePlan(registryPlan, options.agent, true, options.deps); + // A lifecycle command owns which channels the operator asked for, but not + // whether the host still configures them. Onboarding re-reads the host + // either way, so the same removal check applies here. + return filterUnconfiguredHostChannelsFromSelection( + selectionFromReusablePlan(registryPlan, options.agent, true, options.deps), + options.agent, + options.deps, + ); } const activeChannels = filterChannelNamesForCurrentAgent( getActiveChannelsFromPlan(registryPlan), @@ -413,6 +450,7 @@ async function selectionFromRegistryPlan( return filterUnconfiguredHostChannelsFromSelection( selectionFromReusablePlan(registryPlan, options.agent, true, options.deps), options.agent, + options.deps, ); } options.deps.note( @@ -429,21 +467,19 @@ async function selectionFromRegistryPlan( export function reconcileReusedSandboxMessaging( plan: SandboxMessagingPlan | null, agent: Agent, - deps: Pick, "clearPlanEnv">, + deps: Pick, "clearPlanEnv" | "note" | "writePlanToEnv">, recordedPlan: SandboxMessagingPlan | null = plan, ): SandboxMessagingSelection & { readonly changed: boolean } { const filtered = plan ? filterMessagingPlanForCurrentAgent(plan, agent) : null; - const changed = !isDeepStrictEqual(filtered, recordedPlan); - if (changed) deps.clearPlanEnv(); - // The reused plan records the previous selection, not the current host - // input. Report only channels the environment still configures so the - // policies handler can classify a retired channel as unconfigured and drop - // its egress preset (#9283). The plan itself stays untouched. + const selection = filterUnconfiguredHostChannelsFromSelection( + { plan: filtered, selectedChannels: getActiveChannelsFromPlan(filtered) }, + agent, + deps, + ); + const changed = !isDeepStrictEqual(selection.plan, recordedPlan); + if (changed && isDeepStrictEqual(selection.plan, filtered)) deps.clearPlanEnv(); return { - ...filterUnconfiguredHostChannelsFromSelection( - { plan: filtered, selectedChannels: getActiveChannelsFromPlan(filtered) }, - agent, - ), + ...selection, changed, }; } @@ -614,7 +650,11 @@ async function selectionFromRegistryAuthority( authority.plan, false, ); - return filterUnconfiguredHostChannelsFromSelection(selection, options.agent); + return filterUnconfiguredHostChannelsFromSelection( + selection, + options.agent, + options.deps, + ); } if (authority.plan) return selectionFromRegistryPlan(authority.plan, options); options.deps.clearPlanEnv(); diff --git a/src/lib/onboard/machine/handlers/sandbox-ready-messaging.test.ts b/src/lib/onboard/machine/handlers/sandbox-ready-messaging.test.ts new file mode 100644 index 00000000000..1459d7f557d --- /dev/null +++ b/src/lib/onboard/machine/handlers/sandbox-ready-messaging.test.ts @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { createSession } from "../../../state/onboard-session"; +import { detectUnconfiguredMessagingChannels } from "../../messaging-channel-setup"; +import { handleSandboxState } from "./sandbox"; +import { baseOptions, createDeps, makeMinimalPlan } from "./sandbox-test-fixtures"; + +vi.mock("../../messaging-channel-setup", () => ({ + detectMessagingChannelsFromEnv: vi.fn(() => []), + detectUnconfiguredMessagingChannels: vi.fn(() => []), +})); + +const detectUnconfiguredMessagingChannelsMock = vi.mocked(detectUnconfiguredMessagingChannels); + +describe("handleSandboxState Ready sandbox messaging", () => { + beforeEach(() => { + detectUnconfiguredMessagingChannelsMock.mockReturnValue([]); + }); + + it("omits an unconfigured host-backed channel when reusing a Ready sandbox (#9283)", async () => { + const registryPlan = makeMinimalPlan("saved", "openclaw", ["discord"]); + const disabledPlan = { + ...registryPlan, + channels: registryPlan.channels.map((channel) => ({ + ...channel, + active: false, + selected: false, + disabled: true, + })), + disabledChannels: ["discord"], + }; + const session = createSession({ sandboxName: "saved", messagingPlan: registryPlan }); + session.steps.sandbox.status = "complete"; + const writePlanToEnv = vi.fn(); + detectUnconfiguredMessagingChannelsMock.mockReturnValue(["discord"]); + const { deps, calls, getSession } = createDeps({ + getSandboxReuseState: () => "ready", + getSandboxRegistryEntry: () => ({ + name: "saved", + pendingRouteReservation: true, + provider: "provider", + model: "model", + endpointUrl: null, + preferredInferenceApi: "openai-completions", + toolDisclosure: "progressive", + fromDockerfile: null, + hermesAuthMethod: null, + }), + getRegistrySandboxMessagingAuthority: () => ({ authoritative: true, plan: registryPlan }), + writePlanToEnv, + }); + + const result = await handleSandboxState({ + ...baseOptions(deps, session), + resume: true, + sandboxName: "saved", + }); + + expect(calls.createSandbox).not.toHaveBeenCalled(); + expect(result.selectedMessagingChannels).toEqual([]); + expect(writePlanToEnv).toHaveBeenLastCalledWith(disabledPlan); + expect(getSession().messagingPlan).toEqual(disabledPlan); + }); +}); diff --git a/src/lib/onboard/messaging-policy-presets.test.ts b/src/lib/onboard/messaging-policy-presets.test.ts index 8485f0156fa..f8792e543c6 100644 --- a/src/lib/onboard/messaging-policy-presets.test.ts +++ b/src/lib/onboard/messaging-policy-presets.test.ts @@ -10,6 +10,7 @@ import { mergeEnabledMessagingChannelPolicyPresets, mergePolicyMessagingChannels, mergeRebuildMessagingPolicyPresets, + messagingChannelsForPolicyPresets, pruneDisabledMessagingPolicyPresets, requiredMessagingChannelPolicyPresets, } from "./messaging-policy-presets"; @@ -20,6 +21,14 @@ describe("messaging policy presets", () => { expect(requiredMessagingChannelPolicyPresets([" Slack "])).toEqual(["slack"]); }); + it("names the channel behind an applied network policy preset (#9283)", () => { + expect(messagingChannelsForPolicyPresets(["npm", "pypi", "discord"])).toEqual(["discord"]); + expect(messagingChannelsForPolicyPresets([" Slack "])).toEqual(["slack"]); + expect(messagingChannelsForPolicyPresets(["npm", "pypi"])).toEqual([]); + expect(messagingChannelsForPolicyPresets([])).toEqual([]); + expect(messagingChannelsForPolicyPresets(null)).toEqual([]); + }); + it("merges required messaging presets into an existing selection", () => { expect(mergeEnabledMessagingChannelPolicyPresets(["npm", "pypi"], ["slack"])).toEqual([ "npm", diff --git a/src/lib/onboard/messaging-policy-presets.ts b/src/lib/onboard/messaging-policy-presets.ts index 57c573bda3b..fd61ca0feef 100644 --- a/src/lib/onboard/messaging-policy-presets.ts +++ b/src/lib/onboard/messaging-policy-presets.ts @@ -93,6 +93,26 @@ export function allMessagingChannelPolicyPresets(channels: string[] | null | und return all; } +/** + * Name the channels a set of preset names carries network egress for. The + * applied preset list outlives the messaging plan that justified it, so a + * caller holding only preset names can still ask whether the host still + * configures the channel behind each one. + */ +export function messagingChannelsForPolicyPresets( + presetNames: string[] | null | undefined, +): string[] { + const presets = new Set(normalizedNames(presetNames)); + if (presets.size === 0) return []; + const channels: string[] = []; + for (const [channel, channelPresets] of Object.entries(ALL_POLICY_PRESETS_BY_MESSAGING_CHANNEL)) { + if (channelPresets.some((preset) => presets.has(preset.trim().toLowerCase()))) { + channels.push(channel); + } + } + return channels; +} + export function pruneDisabledMessagingPolicyPresets( selectedPresets: string[], disabledChannels: string[] | null | undefined, diff --git a/src/lib/onboard/policy-selection-application.test.ts b/src/lib/onboard/policy-selection-application.test.ts index b6d3f791469..76709b67fb7 100644 --- a/src/lib/onboard/policy-selection-application.test.ts +++ b/src/lib/onboard/policy-selection-application.test.ts @@ -149,6 +149,25 @@ describe("onboarding policy application", () => { ); }); + // NEMOCLAW_POLICY_PRESETS alone runs in suggested mode, which is not + // authoritative, so the applied set is preserved on top of it. Nothing + // downstream can retire the channel's egress; only a caller that names the + // channel in disabledChannels can. This is why handlePoliciesState derives + // that list from the applied presets as well as the messaging plans. + it("preserves an applied channel preset when no caller disables the channel (#9283)", async () => { + const application = createApplication({ NEMOCLAW_POLICY_PRESETS: "npm,pypi" }); + + await expect( + application.setupPoliciesWithSelection("alpha", { + selectedPresets: null, + enabledChannels: [], + disabledChannels: [], + webSearchSupported: false, + hermesToolGateways: [], + }), + ).resolves.toEqual(["npm", "pypi", "discord"]); + }); + it("drops the disabled channel preset when policy selection is skipped (#9109)", async () => { const application = createApplication({ NEMOCLAW_POLICY_MODE: "skip" });