diff --git a/src/lib/actions/sandbox/rebuild.ts b/src/lib/actions/sandbox/rebuild.ts index 63f06a0219..c044e29c58 100644 --- a/src/lib/actions/sandbox/rebuild.ts +++ b/src/lib/actions/sandbox/rebuild.ts @@ -50,7 +50,7 @@ import { toMessagingAgentId, } from "../../messaging"; import type { SandboxMessagingPlan } from "../../messaging/manifest"; -import { pruneDisabledMessagingPolicyPresets } from "../../onboard/messaging-policy-presets"; +import { pruneDisabledMessagingPolicyPresets } from "../../messaging/applier/policy-presets"; import { captureSandboxListWithGatewayRecovery, printSandboxListFailureWithRecoveryContext, diff --git a/src/lib/messaging/applier/index.ts b/src/lib/messaging/applier/index.ts index f9e4dcf486..005ed9759f 100644 --- a/src/lib/messaging/applier/index.ts +++ b/src/lib/messaging/applier/index.ts @@ -6,4 +6,5 @@ export * from "./host-state-applier"; export * from "./agent-config"; export * from "./openshell-provider"; export * from "./policy"; +export * from "./policy-presets"; export type * from "./types"; diff --git a/src/lib/messaging/applier/policy-presets.test.ts b/src/lib/messaging/applier/policy-presets.test.ts new file mode 100644 index 0000000000..89e9fe8fa0 --- /dev/null +++ b/src/lib/messaging/applier/policy-presets.test.ts @@ -0,0 +1,150 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { createChannelManifestRegistry, type ChannelManifest } from "../manifest"; +import { + ALL_MESSAGING_POLICY_PRESET_NAMES, + filterActiveMessagingPresets, + hasDisabledMessagingPreset, + messagingPolicyKeysByChannel, + pruneDisabledMessagingPolicyPresets, + requiredMessagingChannelPolicyPresets, +} from "./policy-presets"; + +function manifest(id: string, policyPresets: ChannelManifest["policyPresets"]): ChannelManifest { + return { + schemaVersion: 1, + id, + displayName: id, + supportedAgents: ["openclaw", "hermes"], + auth: { mode: "token-paste" }, + inputs: [], + credentials: [], + policyPresets, + render: [], + state: {}, + hooks: [], + }; +} + +describe("pruneDisabledMessagingPolicyPresets", () => { + it("removes policy presets for disabled messaging channels", () => { + expect(pruneDisabledMessagingPolicyPresets(["npm", "slack", "pypi"], [" Slack "])).toEqual([ + "npm", + "pypi", + ]); + }); + + it("removes telegram preset when telegram channel is disabled", () => { + expect( + pruneDisabledMessagingPolicyPresets(["telegram", "npm", "pypi"], ["telegram"]), + ).toEqual(["npm", "pypi"]); + }); + + it("removes discord preset when discord channel is disabled", () => { + expect(pruneDisabledMessagingPolicyPresets(["discord", "npm"], ["discord"])).toEqual(["npm"]); + }); + + it("removes wechat preset when wechat channel is disabled", () => { + expect(pruneDisabledMessagingPolicyPresets(["wechat", "npm"], ["wechat"])).toEqual(["npm"]); + }); + + it("removes whatsapp preset when whatsapp channel is disabled", () => { + expect(pruneDisabledMessagingPolicyPresets(["whatsapp", "npm"], ["whatsapp"])).toEqual(["npm"]); + }); + + it("preserves presets for non-messaging same-named items", () => { + expect(pruneDisabledMessagingPolicyPresets(["npm", "pypi"], ["npm"])).toEqual(["npm", "pypi"]); + }); + + it("returns the original list unchanged when no channels are disabled", () => { + expect(pruneDisabledMessagingPolicyPresets(["npm", "slack"], null)).toEqual(["npm", "slack"]); + }); +}); + +describe("requiredMessagingChannelPolicyPresets", () => { + it("derives fallback channel presets from manifests", () => { + const registry = createChannelManifestRegistry([ + manifest("matrix", [{ name: "matrix-policy", policyKeys: ["matrix_bridge"] }]), + ]); + + expect(requiredMessagingChannelPolicyPresets(["matrix"], registry)).toEqual(["matrix-policy"]); + expect(pruneDisabledMessagingPolicyPresets(["npm", "matrix-policy"], ["matrix"], registry)).toEqual([ + "npm", + ]); + }); + + it("derives agent policy keys from manifest aliases", () => { + const registry = createChannelManifestRegistry([ + manifest("matrix", [ + { + name: "matrix-policy", + policyKeys: ["matrix_default"], + agentPolicyKeys: { hermes: ["matrix_hermes"] }, + }, + ]), + ]); + + expect(messagingPolicyKeysByChannel("hermes", registry)).toEqual({ + matrix: ["matrix_hermes"], + }); + }); +}); + +describe("ALL_MESSAGING_POLICY_PRESET_NAMES", () => { + it("includes all messaging channel presets", () => { + expect(ALL_MESSAGING_POLICY_PRESET_NAMES.has("slack")).toBe(true); + expect(ALL_MESSAGING_POLICY_PRESET_NAMES.has("telegram")).toBe(true); + expect(ALL_MESSAGING_POLICY_PRESET_NAMES.has("discord")).toBe(true); + expect(ALL_MESSAGING_POLICY_PRESET_NAMES.has("wechat")).toBe(true); + expect(ALL_MESSAGING_POLICY_PRESET_NAMES.has("whatsapp")).toBe(true); + }); + + it("does not include non-messaging presets", () => { + expect(ALL_MESSAGING_POLICY_PRESET_NAMES.has("npm")).toBe(false); + expect(ALL_MESSAGING_POLICY_PRESET_NAMES.has("pypi")).toBe(false); + expect(ALL_MESSAGING_POLICY_PRESET_NAMES.has("brave")).toBe(false); + }); +}); + +describe("hasDisabledMessagingPreset", () => { + it("returns true when a messaging preset is not in the active set", () => { + expect(hasDisabledMessagingPreset(["slack", "npm"], new Set())).toBe(true); + }); + + it("returns false when all messaging presets are in the active set", () => { + expect(hasDisabledMessagingPreset(["slack", "npm"], new Set(["slack"]))).toBe(false); + }); + + it("returns false for non-messaging presets not in the active set", () => { + expect(hasDisabledMessagingPreset(["npm", "pypi"], new Set())).toBe(false); + }); + + it("detects stale telegram preset", () => { + expect(hasDisabledMessagingPreset(["telegram", "npm"], new Set())).toBe(true); + }); +}); + +describe("filterActiveMessagingPresets", () => { + it("keeps non-messaging presets regardless of plan", () => { + expect(filterActiveMessagingPresets(["npm", "pypi"], new Set())).toEqual(["npm", "pypi"]); + }); + + it("removes messaging presets absent from the plan", () => { + expect(filterActiveMessagingPresets(["npm", "slack", "telegram"], new Set(["slack"]))).toEqual([ + "npm", + "slack", + ]); + }); + + it("retains messaging presets present in the plan", () => { + const active = new Set(["slack", "telegram"]); + expect(filterActiveMessagingPresets(["slack", "telegram", "npm"], active)).toEqual([ + "slack", + "telegram", + "npm", + ]); + }); +}); diff --git a/src/lib/messaging/applier/policy-presets.ts b/src/lib/messaging/applier/policy-presets.ts new file mode 100644 index 0000000000..a93468e0c8 --- /dev/null +++ b/src/lib/messaging/applier/policy-presets.ts @@ -0,0 +1,127 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createBuiltInChannelManifestRegistry } from "../channels"; +import type { + ChannelManifest, + ChannelManifestRegistry, + ChannelPolicyPresetReference, + ChannelPolicyPresetSpec, + MessagingAgentId, +} from "../manifest"; + +const BUILT_IN_CHANNEL_MANIFEST_REGISTRY = createBuiltInChannelManifestRegistry(); + +function normalizePolicyPreset(preset: ChannelPolicyPresetReference): ChannelPolicyPresetSpec { + return typeof preset === "string" ? { name: preset } : preset; +} + +function manifestPolicyPresetNames(manifest: ChannelManifest): string[] { + return (manifest.policyPresets ?? []).map((preset) => normalizePolicyPreset(preset).name); +} + +function manifestPolicyKeys(manifest: ChannelManifest, agent: MessagingAgentId): string[] { + return (manifest.policyPresets ?? []).flatMap((preset) => { + const policy = normalizePolicyPreset(preset); + return [...(policy.agentPolicyKeys?.[agent] ?? policy.policyKeys ?? [policy.name])]; + }); +} + +function getRegistry(registry?: ChannelManifestRegistry | null): ChannelManifestRegistry { + return registry ?? BUILT_IN_CHANNEL_MANIFEST_REGISTRY; +} + +/** All preset names that any messaging channel can require. */ +export const ALL_MESSAGING_POLICY_PRESET_NAMES: ReadonlySet = new Set( + getRegistry() + .list() + .flatMap((manifest) => manifestPolicyPresetNames(manifest)), +); + +function normalizedNames(values: string[] | null | undefined): string[] { + if (!Array.isArray(values)) return []; + const names: string[] = []; + for (const value of values) { + if (typeof value !== "string") continue; + const name = value.trim().toLowerCase(); + if (!name || names.includes(name)) continue; + names.push(name); + } + return names; +} + +export function requiredMessagingChannelPolicyPresets( + channels: string[] | null | undefined, + registry?: ChannelManifestRegistry | null, +): string[] { + const manifestRegistry = getRegistry(registry); + const required: string[] = []; + for (const channel of normalizedNames(channels)) { + const manifest = manifestRegistry.get(channel); + if (!manifest) continue; + for (const preset of manifestPolicyPresetNames(manifest)) { + if (!required.includes(preset)) required.push(preset); + } + } + return required; +} + +export function messagingPolicyKeysByChannel( + agent: MessagingAgentId, + registry?: ChannelManifestRegistry | null, +): Record { + const result: Record = {}; + for (const manifest of getRegistry(registry).listAvailable({ agent })) { + const keys = manifestPolicyKeys(manifest, agent); + if (keys.length > 0) result[manifest.id] = keys; + } + return result; +} + +/** + * Removes from selectedPresets any preset exclusively required by a disabled + * channel. Used when restoring presets from backup manifests where no compiled + * plan is available. For plan-aware paths, use getPolicyPresetsFromPlan which + * derives presets from enabled plan entries only. + */ +export function pruneDisabledMessagingPolicyPresets( + selectedPresets: string[], + disabledChannels: string[] | null | undefined, + registry?: ChannelManifestRegistry | null, +): string[] { + const disabledRequiredPresets = new Set( + requiredMessagingChannelPolicyPresets(disabledChannels, registry), + ); + if (disabledRequiredPresets.size === 0) return selectedPresets; + return selectedPresets.filter( + (preset) => !disabledRequiredPresets.has(preset.trim().toLowerCase()), + ); +} + +/** + * Returns true if any preset in the list is a messaging policy preset that is + * absent from the active plan preset set. Used to detect stale messaging + * presets still applied in the gateway after a channel stop/disable. + */ +export function hasDisabledMessagingPreset( + presets: readonly string[], + activePlanPresets: ReadonlySet, +): boolean { + return presets.some( + (preset) => ALL_MESSAGING_POLICY_PRESET_NAMES.has(preset) && !activePlanPresets.has(preset), + ); +} + +/** + * Filters a preset list to retain only non-messaging presets and messaging + * presets that appear in the active plan preset set. Used to exclude stale + * gateway presets from the resume/preservation set. + */ +export function filterActiveMessagingPresets( + presets: readonly string[], + activePlanPresets: ReadonlySet, +): string[] { + return presets.filter( + (name) => !ALL_MESSAGING_POLICY_PRESET_NAMES.has(name) || activePlanPresets.has(name), + ); +} diff --git a/src/lib/messaging/channels/telegram/manifest.ts b/src/lib/messaging/channels/telegram/manifest.ts index d0af4167bd..788d8327fa 100644 --- a/src/lib/messaging/channels/telegram/manifest.ts +++ b/src/lib/messaging/channels/telegram/manifest.ts @@ -61,7 +61,13 @@ export const telegramManifest = { placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", }, ], - policyPresets: [{ name: "telegram", policyKeys: ["telegram_bot"] }], + policyPresets: [ + { + name: "telegram", + policyKeys: ["telegram_bot"], + agentPolicyKeys: { hermes: ["telegram"] }, + }, + ], render: [ { id: "telegram-openclaw-account", diff --git a/src/lib/messaging/compiler/manifest-compiler.test.ts b/src/lib/messaging/compiler/manifest-compiler.test.ts index 82b5bf8c23..a54b98430b 100644 --- a/src/lib/messaging/compiler/manifest-compiler.test.ts +++ b/src/lib/messaging/compiler/manifest-compiler.test.ts @@ -268,6 +268,12 @@ describe("ManifestCompiler", () => { policyKeys: ["wechat_bridge"], source: "manifest", }); + expect(plan.networkPolicy.entries.find((entry) => entry.channelId === "telegram")).toEqual({ + channelId: "telegram", + presetName: "telegram", + policyKeys: ["telegram"], + source: "agent-alias", + }); expect(plan.agentRender.map((render) => `${render.channelId}:${render.target}`)).toEqual([ "telegram:~/.hermes/.env", "telegram:~/.hermes/config.yaml", diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 3b08dff5f7..6e3405eae2 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -508,7 +508,10 @@ import { setupHermesToolGateways, stringSetsEqual, } from "./onboard/hermes-managed-tools"; -import { mergePolicyMessagingChannels } from "./onboard/messaging-policy-presets"; +import { + getEnabledChannelIdsFromPlan, + getPolicyPresetsFromPlan, +} from "./onboard/messaging-plan-session"; import { filterEnabledChannelsByAgent, resolveQrSelectedChannels, @@ -2725,6 +2728,7 @@ async function createSandbox( sandboxGpuConfig: SandboxGpuConfig | null = null, resourceProfile: import("./resources-cmd").ResourceProfile | null = null, hermesToolGateways: string[] = [], + messagingPlan: import("./messaging/manifest").SandboxMessagingPlan | null = null, ) { step(6, 8, "Creating sandbox"); @@ -3346,13 +3350,16 @@ async function createSandbox( dockerGpuSandboxCreate.resolveDockerGpuSandboxCreatePlan(effectiveSandboxGpuConfig, { dockerDriverGateway: isLinuxDockerDriverGatewayEnabled(), }); + const activeMessagingPlan = messagingPlan ?? readMessagingPlanFromEnv(); + const planEnabledMessagingChannels = getEnabledChannelIdsFromPlan(activeMessagingPlan); const initialSandboxPolicy = prepareInitialSandboxCreatePolicy( basePolicyPath, - activeMessagingChannels, + planEnabledMessagingChannels ?? activeMessagingChannels, { directGpu: effectiveSandboxGpuConfig.sandboxGpuEnabled, dockerGpuPatch: useDockerGpuPatch, additionalPresets: hermesToolGateways, + messagingPolicyPresets: getPolicyPresetsFromPlan(activeMessagingPlan), }, ); if (initialSandboxPolicy.cleanup) { @@ -6548,7 +6555,6 @@ async function onboard(opts: OnboardOptions = {}): Promise { deps: { loadSession: onboardSession.loadSession, getActiveSandbox: (name) => registry.getSandbox(name), - mergePolicyMessagingChannels, verifyCompatibleEndpointSandboxSmoke: (options) => verifyCompatibleEndpointSandboxSmoke({ ...options, diff --git a/src/lib/onboard/initial-policy.test.ts b/src/lib/onboard/initial-policy.test.ts index 5dbfc2fcc8..00364fa720 100644 --- a/src/lib/onboard/initial-policy.test.ts +++ b/src/lib/onboard/initial-policy.test.ts @@ -163,10 +163,14 @@ network_policies: expect(getNetworkPolicyNames("network_policies: [unterminated")).toBeNull(); }); - it("keeps the base policy when no channel needs a create-time preset", () => { + it("keeps the base policy when the compiled plan has no active messaging preset", () => { const basePolicyPath = tmpPolicy("version: 1\nnetwork_policies:\n base: {}\n"); - expect(prepareInitialSandboxCreatePolicy(basePolicyPath, ["telegram"])).toEqual({ + expect( + prepareInitialSandboxCreatePolicy(basePolicyPath, ["telegram"], { + messagingPolicyPresets: [], + }), + ).toEqual({ policyPath: basePolicyPath, appliedPresets: [], }); @@ -175,7 +179,11 @@ network_policies: it("records an existing create-time preset without writing a temp policy", () => { const basePolicyPath = tmpPolicy("version: 1\nnetwork_policies:\n slack: {}\n"); - expect(prepareInitialSandboxCreatePolicy(basePolicyPath, ["slack"])).toEqual({ + expect( + prepareInitialSandboxCreatePolicy(basePolicyPath, ["slack"], { + messagingPolicyPresets: ["slack"], + }), + ).toEqual({ policyPath: basePolicyPath, appliedPresets: ["slack"], }); @@ -237,7 +245,9 @@ network_policies: it("merges missing create-time presets into a temporary policy", () => { const basePolicyPath = tmpPolicy("version: 1\nnetwork_policies:\n base: {}\n"); - const prepared = prepareInitialSandboxCreatePolicy(basePolicyPath, ["slack"]); + const prepared = prepareInitialSandboxCreatePolicy(basePolicyPath, ["slack"], { + messagingPolicyPresets: ["slack"], + }); expect(prepared.policyPath).not.toBe(basePolicyPath); expect(prepared.appliedPresets).toEqual(["slack"]); @@ -250,6 +260,7 @@ network_policies: const basePolicyPath = tmpPolicy("version: 1\nnetwork_policies:\n base: {}\n"); const prepared = prepareInitialSandboxCreatePolicy(basePolicyPath, ["slack"], { + messagingPolicyPresets: ["slack"], additionalPresets: ["nous-web"], }); diff --git a/src/lib/onboard/initial-policy.ts b/src/lib/onboard/initial-policy.ts index 74f5c8dde1..1be5bf4378 100644 --- a/src/lib/onboard/initial-policy.ts +++ b/src/lib/onboard/initial-policy.ts @@ -5,8 +5,11 @@ import fs from "node:fs"; import path from "node:path"; import YAML from "yaml"; +import { + messagingPolicyKeysByChannel, + requiredMessagingChannelPolicyPresets, +} from "../messaging/applier/policy-presets"; import * as policies from "../policy"; -import { requiredMessagingChannelPolicyPresets } from "./messaging-policy-presets"; import { requiredOpenclawOtelPolicyPresets } from "./openclaw-otel-policy-presets"; import { cleanupTempDir, secureTempFile } from "./temp-files"; @@ -16,13 +19,6 @@ export type InitialSandboxPolicy = { cleanup?: () => boolean; }; -const HERMES_MESSAGING_POLICY_KEYS: Record = { - discord: ["discord"], - slack: ["slack"], - telegram: ["telegram"], - wechat: ["wechat_bridge"], -}; - const PROC_PATH = "/proc"; const PROC_COMM_READ_WRITE_PATHS = ["/proc/self/comm", "/proc/self/task/*/comm"]; @@ -182,7 +178,7 @@ function filterHermesInactiveMessagingPolicies( const active = new Set(activeMessagingChannels); let changed = false; - for (const [channel, policyKeys] of Object.entries(HERMES_MESSAGING_POLICY_KEYS)) { + for (const [channel, policyKeys] of Object.entries(messagingPolicyKeysByChannel("hermes"))) { if (active.has(channel)) continue; for (const key of policyKeys) { if (Object.prototype.hasOwnProperty.call(parsed.network_policies, key)) { @@ -211,6 +207,7 @@ export function prepareInitialSandboxCreatePolicy( dockerGpuPatch?: boolean; additionalPresets?: string[]; agentName?: string | null; + messagingPolicyPresets?: string[] | null; } = {}, ): InitialSandboxPolicy { const directGpuPolicy = options.directGpu @@ -225,13 +222,11 @@ export function prepareInitialSandboxCreatePolicy( ? () => cleanupFns.map((cleanup) => cleanup()).every(Boolean) : undefined; const requestedCreateTimePresets = [ - ...new Set( - [ - ...requiredMessagingChannelPolicyPresets(activeMessagingChannels), - ...requiredOpenclawOtelPolicyPresets(options.agentName ?? "openclaw"), - ...(options.additionalPresets || []), - ], - ), + ...new Set([ + ...(options.messagingPolicyPresets ?? requiredMessagingChannelPolicyPresets(activeMessagingChannels)), + ...requiredOpenclawOtelPolicyPresets(options.agentName ?? "openclaw"), + ...(options.additionalPresets || []), + ]), ]; const dedupe = (values: string[]) => [...new Set(values.filter(Boolean))]; diff --git a/src/lib/onboard/machine/handlers/policies.test.ts b/src/lib/onboard/machine/handlers/policies.test.ts index 1ac343030c..c842d6b400 100644 --- a/src/lib/onboard/machine/handlers/policies.test.ts +++ b/src/lib/onboard/machine/handlers/policies.test.ts @@ -3,24 +3,57 @@ import { describe, expect, it, vi } from "vitest"; +import type { SandboxMessagingPlan } from "../../../messaging/manifest"; import { createSession, type Session, type SessionUpdates } from "../../../state/onboard-session"; import { handlePoliciesState, type PoliciesStateOptions } from "./policies"; type Agent = { name: string } | null; type WebSearchConfig = { fetchEnabled: true }; +function makeMessagingPlan( + channelIds: readonly string[], + overrides: Partial = {}, +): SandboxMessagingPlan { + return { + schemaVersion: 1, + sandboxName: "my-assistant", + agent: "openclaw", + workflow: "onboard", + channels: channelIds.map((channelId) => ({ + channelId, + displayName: channelId, + authMode: "token-paste", + active: true, + selected: true, + configured: true, + disabled: false, + inputs: [], + hooks: [], + })), + disabledChannels: [], + credentialBindings: [], + networkPolicy: { + presets: [...channelIds], + entries: channelIds.map((channelId) => ({ + channelId, + presetName: channelId, + policyKeys: [channelId], + source: "manifest", + })), + }, + agentRender: [], + buildSteps: [], + stateUpdates: [], + healthChecks: [], + ...overrides, + }; +} + function createDeps(overrides: Partial["deps"]> = {}) { let session = createSession(); const calls = { load: vi.fn(() => session), - activeSandbox: vi.fn(() => ({ messagingChannels: ["telegram"], disabledChannels: null })), - mergeChannels: vi.fn( - ( - selected: string[], - recorded: string[], - active: string[] | null | undefined, - ) => (selected.length > 0 ? selected : active ?? recorded), - ), + activeSandbox: vi.fn(() => null), smoke: vi.fn(), prepareResume: vi.fn( ( @@ -49,7 +82,6 @@ function createDeps(overrides: Partial { it("runs compatible endpoint smoke before policy selection", async () => { const { deps, calls } = createDeps(); - const result = await handlePoliciesState(baseOptions(deps)); + const result = await handlePoliciesState({ + ...baseOptions(deps), + selectedMessagingChannels: ["slack"], + }); expect(calls.smoke).toHaveBeenCalledWith({ sandboxName: "my-assistant", @@ -101,7 +136,7 @@ describe("handlePoliciesState", () => { model: "model", endpointUrl: "https://example.com/v1", credentialEnv: "NVIDIA_API_KEY", - messagingChannels: ["telegram"], + messagingChannels: ["slack"], agent: null, }); expect(calls.startStep).toHaveBeenCalledWith("policies", { @@ -114,7 +149,8 @@ describe("handlePoliciesState", () => { "my-assistant", expect.objectContaining({ selectedPresets: null, - enabledChannels: ["telegram"], + messagingPolicyPresets: null, + messagingChannelIds: ["slack"], provider: "provider", webSearchSupported: true, }), @@ -132,18 +168,44 @@ describe("handlePoliciesState", () => { }); }); - it("uses recorded messaging channels when no active selection exists", async () => { - const session = createSession({ messagingChannels: ["slack"] }); - const { deps, calls, setSession } = createDeps({ - getActiveSandbox: vi.fn(() => ({ messagingChannels: null, disabledChannels: null })), - }); - setSession(session); + it("passes plan-derived messaging policy presets through to preparePolicyPresetResumeSelection", async () => { + const { deps, calls, setSession } = createDeps(); + setSession(createSession({ messagingPlan: makeMessagingPlan(["slack"]) })); await handlePoliciesState(baseOptions(deps)); + expect(calls.prepareResume).toHaveBeenCalledWith( + "my-assistant", + expect.objectContaining({ + messagingPolicyPresets: ["slack"], + messagingChannelIds: ["slack"], + disabledChannels: [], + }), + ); + }); + + it("uses legacy session messagingChannels when no messagingPlan is available", async () => { + const { deps, calls, setSession } = createDeps(); + setSession(createSession({ messagingChannels: ["slack"], messagingPlan: null })); + + await handlePoliciesState(baseOptions(deps)); + + expect(calls.smoke).toHaveBeenCalledWith( + expect.objectContaining({ messagingChannels: ["slack"] }), + ); + expect(calls.prepareResume).toHaveBeenCalledWith( + "my-assistant", + expect.objectContaining({ + messagingPolicyPresets: null, + messagingChannelIds: ["slack"], + }), + ); expect(calls.setupPolicies).toHaveBeenCalledWith( "my-assistant", - expect.objectContaining({ enabledChannels: ["slack"] }), + expect.objectContaining({ + messagingPolicyPresets: null, + messagingChannelIds: ["slack"], + }), ); }); diff --git a/src/lib/onboard/machine/handlers/policies.ts b/src/lib/onboard/machine/handlers/policies.ts index 0a1c281c35..64d14dfe04 100644 --- a/src/lib/onboard/machine/handlers/policies.ts +++ b/src/lib/onboard/machine/handlers/policies.ts @@ -2,6 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 import type { Session, SessionUpdates } from "../../../state/onboard-session"; +import { + type ActiveSandboxPolicyState, + resolveMessagingPolicyState, +} from "../../messaging-policy-state"; import { advanceTo, type OnboardStateTransitionResult } from "../result"; // Inlined to avoid pulling sandbox-agent's transitive runner.ts deps into @@ -17,11 +21,6 @@ export interface PolicyPresetEntry { [key: string]: unknown; } -export interface ActiveSandboxPolicyState { - messagingChannels?: string[] | null; - disabledChannels?: string[] | null; -} - export interface PolicyResumeSelection { policyPresets: string[]; recordedPolicyPresetsNeedReconcile: boolean; @@ -43,12 +42,6 @@ export interface PoliciesStateOptions { deps: { loadSession(): Session | null; getActiveSandbox(sandboxName: string): ActiveSandboxPolicyState | null | undefined; - mergePolicyMessagingChannels( - selectedMessagingChannels: string[], - recordedMessagingChannels: string[], - activeMessagingChannels: string[] | null | undefined, - disabledChannels: string[] | null | undefined, - ): string[]; verifyCompatibleEndpointSandboxSmoke(options: { sandboxName: string; provider: string; @@ -62,8 +55,9 @@ export interface PoliciesStateOptions { sandboxName: string, options: { recordedPolicyPresets: string[] | null; - disabledChannels: string[] | null | undefined; - enabledChannels: string[]; + messagingPolicyPresets?: string[] | null; + messagingChannelIds?: string[] | null; + disabledChannels?: string[] | null; hermesToolGateways: string[]; agent?: string | null; webSearchConfig: WebSearchConfig | null; @@ -81,7 +75,8 @@ export interface PoliciesStateOptions { sandboxName: string, options: { selectedPresets: string[] | null; - enabledChannels: string[]; + messagingPolicyPresets: string[] | null; + messagingChannelIds: string[] | null; disabledChannels?: string[] | null; webSearchConfig: WebSearchConfig | null; provider: string; @@ -106,7 +101,6 @@ export interface PoliciesStateOptions { export interface PoliciesStateResult { session: Session | null; - recordedMessagingChannels: string[]; appliedPolicyPresets: string[]; stateResult: OnboardStateTransitionResult; } @@ -133,26 +127,29 @@ export async function handlePoliciesState({ ? latestSession.messagingChannels : []; const activeSandbox = deps.getActiveSandbox(sandboxName); - const policyMessagingChannels = deps.mergePolicyMessagingChannels( - selectedMessagingChannels, - recordedMessagingChannels, - activeSandbox?.messagingChannels, - activeSandbox?.disabledChannels, - ); + const messagingPolicyState = resolveMessagingPolicyState({ + plan: latestSession?.messagingPlan, + selectedChannels: selectedMessagingChannels, + recordedChannels: recordedMessagingChannels, + activeSandbox, + sessionDisabledChannels: latestSession?.disabledChannels, + }); + deps.verifyCompatibleEndpointSandboxSmoke({ sandboxName, provider, model, endpointUrl, credentialEnv, - messagingChannels: policyMessagingChannels, + messagingChannels: messagingPolicyState.messagingChannelIds, agent, }); const policyResumeSelection = deps.preparePolicyPresetResumeSelection(sandboxName, { recordedPolicyPresets, - disabledChannels: activeSandbox?.disabledChannels, - enabledChannels: policyMessagingChannels, + messagingPolicyPresets: messagingPolicyState.messagingPolicyPresets, + messagingChannelIds: messagingPolicyState.messagingChannelIds, + disabledChannels: messagingPolicyState.disabledChannels, hermesToolGateways, agent: normalizeAgentName((agent as { name?: string } | null)?.name), webSearchConfig, @@ -205,8 +202,9 @@ export async function handlePoliciesState({ selectedPresets: Array.isArray(recordedPolicyPresets) ? recordedPolicyPresetsForSupport : null, - enabledChannels: policyMessagingChannels, - disabledChannels: activeSandbox?.disabledChannels, + messagingPolicyPresets: messagingPolicyState.messagingPolicyPresets, + messagingChannelIds: messagingPolicyState.messagingChannelIds, + disabledChannels: messagingPolicyState.disabledChannels, webSearchConfig, provider, // selectOnboardAgent returns null for the default OpenClaw path (no @@ -245,7 +243,6 @@ export async function handlePoliciesState({ return { session, - recordedMessagingChannels, appliedPolicyPresets, stateResult: advanceTo("finalizing", { metadata: { state: "policies", policyPresets: appliedPolicyPresets }, diff --git a/src/lib/onboard/machine/handlers/sandbox.test.ts b/src/lib/onboard/machine/handlers/sandbox.test.ts index b52876f9e8..0c0cf36ca2 100644 --- a/src/lib/onboard/machine/handlers/sandbox.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox.test.ts @@ -167,6 +167,7 @@ describe("handleSandboxState", () => { { sandboxGpuEnabled: false, mode: "0" }, null, [], + null, ); expect(calls.updateSandbox).toHaveBeenCalledWith("my-assistant", expect.objectContaining({ model: "model", provider: "provider" })); // Default-marking is deferred to finalization (#4614) — the sandbox step must not set it. @@ -318,6 +319,7 @@ describe("handleSandboxState", () => { { sandboxGpuEnabled: false, mode: "0" }, null, [], + null, ); expect(result.webSearchConfig).toBeNull(); }); diff --git a/src/lib/onboard/machine/handlers/sandbox.ts b/src/lib/onboard/machine/handlers/sandbox.ts index 07d6208d7b..b61613e5ee 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -84,6 +84,7 @@ export interface SandboxStateOptions; updateSandboxRegistry(sandboxName: string, updates: Record): void; getSandboxAgentRegistryFields(agent: Agent, agentVersionKnown: boolean): Record; @@ -331,6 +332,7 @@ export async function handleSandboxState = {}, +): SandboxMessagingPlan { + return { + schemaVersion: 1, + sandboxName: "demo", + agent: "openclaw", + workflow: "onboard", + channels: channelIds.map((channelId) => ({ + channelId, + displayName: channelId, + authMode: "token-paste" as const, + active: true, + selected: true, + configured: true, + disabled: false, + inputs: [], + hooks: [], + })), + disabledChannels: [], + credentialBindings: [], + networkPolicy: { + presets: [...channelIds], + entries: channelIds.map((channelId) => ({ + channelId, + presetName: channelId, + policyKeys: [channelId], + source: "manifest" as const, + })), + }, + agentRender: [], + buildSteps: [], + stateUpdates: [], + healthChecks: [], + ...overrides, + }; +} + +describe("getPolicyPresetsFromPlan", () => { + it("returns null for null/undefined plans", () => { + expect(getPolicyPresetsFromPlan(null)).toBeNull(); + expect(getPolicyPresetsFromPlan(undefined)).toBeNull(); + }); + + it("returns presets for all active channels", () => { + const plan = makePlan(["slack", "telegram"]); + expect(getPolicyPresetsFromPlan(plan)?.sort()).toEqual(["slack", "telegram"]); + }); + + it("excludes presets for disabled channels", () => { + const plan = makePlan(["slack", "telegram"], { + channels: [ + { + channelId: "slack", + displayName: "Slack", + authMode: "token-paste", + active: true, + selected: true, + configured: true, + disabled: false, + inputs: [], + hooks: [], + }, + { + channelId: "telegram", + displayName: "Telegram", + authMode: "token-paste", + active: true, + selected: true, + configured: true, + disabled: true, + inputs: [], + hooks: [], + }, + ], + disabledChannels: ["telegram"], + }); + expect(getPolicyPresetsFromPlan(plan)).toEqual(["slack"]); + }); + + it("ignores networkPolicy.presets — derives from enabled entries only", () => { + // Compiler may leave disabled-channel entries in networkPolicy.presets; + // getPolicyPresetsFromPlan must ignore that field. + const plan = makePlan(["slack"], { + disabledChannels: ["telegram"], + networkPolicy: { + presets: ["slack", "telegram"], + entries: [ + { channelId: "slack", presetName: "slack", policyKeys: ["slack"], source: "manifest" }, + { + channelId: "telegram", + presetName: "telegram", + policyKeys: ["telegram_bot"], + source: "manifest", + }, + ], + }, + }); + expect(getPolicyPresetsFromPlan(plan)).toEqual(["slack"]); + }); + + it("deduplicates preset names", () => { + const plan = makePlan(["slack"], { + networkPolicy: { + presets: ["slack", "slack"], + entries: [ + { channelId: "slack", presetName: "slack", policyKeys: ["slack"], source: "manifest" }, + { channelId: "slack", presetName: "slack", policyKeys: ["slack2"], source: "manifest" }, + ], + }, + }); + expect(getPolicyPresetsFromPlan(plan)).toEqual(["slack"]); + }); +}); + +describe("getEnabledChannelIdsFromPlan", () => { + it("returns null for null/undefined", () => { + expect(getEnabledChannelIdsFromPlan(null)).toBeNull(); + expect(getEnabledChannelIdsFromPlan(undefined)).toBeNull(); + }); + + it("returns only enabled channel IDs", () => { + const plan = makePlan(["slack", "telegram"], { + channels: [ + { + channelId: "slack", + displayName: "Slack", + authMode: "token-paste", + active: true, + selected: true, + configured: true, + disabled: false, + inputs: [], + hooks: [], + }, + { + channelId: "telegram", + displayName: "Telegram", + authMode: "token-paste", + active: true, + selected: true, + configured: true, + disabled: true, + inputs: [], + hooks: [], + }, + ], + disabledChannels: ["telegram"], + }); + expect(getEnabledChannelIdsFromPlan(plan)).toEqual(["slack"]); + }); + + it("returns empty array when a plan has no enabled channels", () => { + const plan = makePlan(["telegram"], { + channels: [ + { + channelId: "telegram", + displayName: "Telegram", + authMode: "token-paste", + active: true, + selected: true, + configured: true, + disabled: true, + inputs: [], + hooks: [], + }, + ], + disabledChannels: ["telegram"], + }); + expect(getEnabledChannelIdsFromPlan(plan)).toEqual([]); + }); +}); + +describe("parseSandboxMessagingPlan", () => { + it("accepts a well-formed plan", () => { + const plan = makePlan(["slack"]); + expect(parseSandboxMessagingPlan(plan)).not.toBeNull(); + }); + + it("rejects a plan with non-string channel IDs", () => { + const plan = makePlan(["slack"]) as unknown as Record; + (plan.channels as Array>)[0].channelId = 42; + expect(parseSandboxMessagingPlan(plan)).toBeNull(); + }); + + it("rejects a plan with non-string disabledChannels entries", () => { + const plan = { ...makePlan(["slack"]), disabledChannels: [42] }; + expect(parseSandboxMessagingPlan(plan)).toBeNull(); + }); + + it("rejects a plan with non-string networkPolicy.presets entries", () => { + const plan = { + ...makePlan(["slack"]), + networkPolicy: { presets: [42], entries: [] }, + }; + expect(parseSandboxMessagingPlan(plan)).toBeNull(); + }); + + it("rejects a plan with networkPolicy.entries missing presetName", () => { + const plan = { + ...makePlan(["slack"]), + networkPolicy: { + presets: ["slack"], + entries: [{ channelId: "slack", policyKeys: ["slack"] }], + }, + }; + expect(parseSandboxMessagingPlan(plan)).toBeNull(); + }); + + it("rejects null", () => { + expect(parseSandboxMessagingPlan(null)).toBeNull(); + }); +}); diff --git a/src/lib/onboard/messaging-plan-session.ts b/src/lib/onboard/messaging-plan-session.ts index bb05f97ea2..29376a8101 100644 --- a/src/lib/onboard/messaging-plan-session.ts +++ b/src/lib/onboard/messaging-plan-session.ts @@ -3,6 +3,7 @@ import type { MessagingChannelConfig } from "../messaging-channel-config"; import type { SandboxMessagingPlan } from "../messaging/manifest"; +import { enabledPlanChannelIds, filterEnabledPlanEntries } from "../messaging/applier/plan-filter"; export function parseSandboxMessagingPlan(value: unknown): SandboxMessagingPlan | null { if ( @@ -12,9 +13,19 @@ export function parseSandboxMessagingPlan(value: unknown): SandboxMessagingPlan typeof value.agent !== "string" || typeof value.workflow !== "string" || !Array.isArray(value.channels) || + !value.channels.every( + (c) => isObject(c) && typeof c.channelId === "string", + ) || !Array.isArray(value.disabledChannels) || + !value.disabledChannels.every((id) => typeof id === "string") || !Array.isArray(value.credentialBindings) || !isObject(value.networkPolicy) || + !Array.isArray(value.networkPolicy.presets) || + !value.networkPolicy.presets.every((p) => typeof p === "string") || + !Array.isArray(value.networkPolicy.entries) || + !value.networkPolicy.entries.every( + (e) => isObject(e) && typeof e.channelId === "string" && typeof e.presetName === "string", + ) || !Array.isArray(value.agentRender) || !Array.isArray(value.buildSteps) || !Array.isArray(value.stateUpdates) || @@ -31,18 +42,43 @@ function isObject(value: unknown): value is Record { /** Derive the equivalent of session.messagingChannels from a plan. */ export function getChannelsFromPlan(plan: SandboxMessagingPlan | null | undefined): string[] | null { - if (!plan || plan.channels.length === 0) return null; + if (!plan) return null; return plan.channels.map((c) => c.channelId); } +/** Derive only enabled channel IDs from a plan (excludes disabled channels). */ +export function getEnabledChannelIdsFromPlan( + plan: SandboxMessagingPlan | null | undefined, +): string[] | null { + if (!plan) return null; + const ids = [...enabledPlanChannelIds(plan)]; + return ids; +} + /** Derive the equivalent of session.disabledChannels from a plan. */ export function getDisabledChannelsFromPlan( plan: SandboxMessagingPlan | null | undefined, ): string[] | null { if (!plan) return null; - return plan.disabledChannels.length > 0 ? [...plan.disabledChannels] : null; + return [...plan.disabledChannels]; } +/** Derive the messaging network policy presets for active channels from a plan. */ +export function getPolicyPresetsFromPlan(plan: SandboxMessagingPlan | null | undefined): string[] | null { + if (!plan) return null; + const activeEntries = filterEnabledPlanEntries(plan, plan.networkPolicy.entries); + const seen = new Set(); + const result: string[] = []; + for (const entry of activeEntries) { + if (entry.presetName && !seen.has(entry.presetName)) { + seen.add(entry.presetName); + result.push(entry.presetName); + } + } + return result; +} + + /** * Derive the equivalent of session.messagingChannelConfig from a plan. * Config inputs (kind === "config") carry their resolved env-key/value pairs diff --git a/src/lib/onboard/messaging-policy-presets.test.ts b/src/lib/onboard/messaging-policy-presets.test.ts deleted file mode 100644 index 43466fa3b2..0000000000 --- a/src/lib/onboard/messaging-policy-presets.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { describe, expect, it } from "vitest"; - -import { - hasDisabledMessagingPolicyPreset, - mergeAppliedPolicyPresetsForDisabledMessagingCleanup, - mergePolicyMessagingChannels, - mergeRequiredMessagingChannelPolicyPresets, - pruneDisabledMessagingPolicyPresets, - requiredMessagingChannelPolicyPresets, -} from "./messaging-policy-presets"; - -describe("messaging policy presets", () => { - it("maps Slack messaging to the Slack network policy preset", () => { - expect(requiredMessagingChannelPolicyPresets(["slack"])).toEqual(["slack"]); - expect(requiredMessagingChannelPolicyPresets([" Slack "])).toEqual(["slack"]); - }); - - it("merges required messaging presets into an existing selection", () => { - expect(mergeRequiredMessagingChannelPolicyPresets(["npm", "pypi"], ["slack"])).toEqual([ - "npm", - "pypi", - "slack", - ]); - }); - - it("does not add a required preset that is not available to the sandbox", () => { - expect( - mergeRequiredMessagingChannelPolicyPresets(["npm"], ["slack"], new Set(["npm"])), - ).toEqual(["npm"]); - }); - - it("merges policy channels while excluding disabled channels", () => { - expect( - mergePolicyMessagingChannels( - ["slack", "telegram"], - [" Slack "], - ["discord", "slack"], - ["slack"], - ), - ).toEqual(["telegram", "discord"]); - }); - - it("removes policy presets for disabled messaging channels", () => { - expect(pruneDisabledMessagingPolicyPresets(["npm", "slack", "pypi"], [" Slack "])).toEqual([ - "npm", - "pypi", - ]); - }); - - it("preserves non-required policy presets when a same-named channel is disabled", () => { - expect( - pruneDisabledMessagingPolicyPresets(["telegram", "npm", "pypi"], ["telegram"]), - ).toEqual(["telegram", "npm", "pypi"]); - }); - - it("detects applied policy presets for disabled messaging channels", () => { - expect(hasDisabledMessagingPolicyPreset(["npm", "slack", "pypi"], ["slack"])).toBe(true); - expect(hasDisabledMessagingPolicyPreset(["telegram", "npm"], ["telegram"])).toBe(false); - }); - - it("preserves unrelated applied presets when cleaning disabled messaging presets", () => { - expect( - mergeAppliedPolicyPresetsForDisabledMessagingCleanup( - ["npm"], - ["npm", "github", "slack"], - ["slack"], - ), - ).toEqual(["npm", "github"]); - expect( - mergeAppliedPolicyPresetsForDisabledMessagingCleanup( - ["npm"], - ["npm", "github"], - ["slack"], - ), - ).toEqual(["npm"]); - }); -}); diff --git a/src/lib/onboard/messaging-policy-presets.ts b/src/lib/onboard/messaging-policy-presets.ts deleted file mode 100644 index c074e31282..0000000000 --- a/src/lib/onboard/messaging-policy-presets.ts +++ /dev/null @@ -1,105 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -const REQUIRED_POLICY_PRESETS_BY_MESSAGING_CHANNEL: Record = { - slack: ["slack"], -}; - -function normalizedNames(values: string[] | null | undefined): string[] { - if (!Array.isArray(values)) return []; - const names: string[] = []; - for (const value of values) { - if (typeof value !== "string") continue; - const name = value.trim().toLowerCase(); - if (!name || names.includes(name)) continue; - names.push(name); - } - return names; -} - -export function mergePolicyMessagingChannels( - selectedChannels: string[] | null | undefined, - recordedChannels: string[] | null | undefined, - activeChannels: string[] | null | undefined, - disabledChannels: string[] | null | undefined = null, -): string[] { - const disabled = new Set(normalizedNames(disabledChannels)); - const merged: string[] = []; - for (const channels of [selectedChannels, recordedChannels, activeChannels]) { - for (const channel of normalizedNames(channels)) { - if (!channel || disabled.has(channel) || merged.includes(channel)) continue; - merged.push(channel); - } - } - return merged; -} - -export function requiredMessagingChannelPolicyPresets( - channels: string[] | null | undefined, -): string[] { - const required: string[] = []; - for (const channel of normalizedNames(channels)) { - for (const preset of REQUIRED_POLICY_PRESETS_BY_MESSAGING_CHANNEL[channel] || []) { - if (!required.includes(preset)) required.push(preset); - } - } - return required; -} - -export function mergeRequiredMessagingChannelPolicyPresets( - selectedPresets: string[], - channels: string[] | null | undefined, - knownPresetNames?: Iterable | null, -): string[] { - const merged = [...selectedPresets]; - const selected = new Set(merged); - const known = knownPresetNames ? new Set(knownPresetNames) : null; - - for (const preset of requiredMessagingChannelPolicyPresets(channels)) { - if (known && !known.has(preset)) continue; - if (selected.has(preset)) continue; - merged.push(preset); - selected.add(preset); - } - - return merged; -} - -export function pruneDisabledMessagingPolicyPresets( - selectedPresets: string[], - disabledChannels: string[] | null | undefined, -): string[] { - const disabledRequiredPresets = new Set( - requiredMessagingChannelPolicyPresets(disabledChannels), - ); - if (disabledRequiredPresets.size === 0) return selectedPresets; - return selectedPresets.filter( - (preset) => !disabledRequiredPresets.has(preset.trim().toLowerCase()), - ); -} - -export function hasDisabledMessagingPolicyPreset( - selectedPresets: string[], - disabledChannels: string[] | null | undefined, -): boolean { - return ( - pruneDisabledMessagingPolicyPresets(selectedPresets, disabledChannels).length !== - selectedPresets.length - ); -} - -export function mergeAppliedPolicyPresetsForDisabledMessagingCleanup( - selectedPresets: string[], - appliedPresets: string[], - disabledChannels: string[] | null | undefined, -): string[] { - if (!hasDisabledMessagingPolicyPreset(appliedPresets, disabledChannels)) { - return selectedPresets; - } - - const merged = [...selectedPresets]; - for (const preset of pruneDisabledMessagingPolicyPresets(appliedPresets, disabledChannels)) { - if (!merged.includes(preset)) merged.push(preset); - } - return merged; -} diff --git a/src/lib/onboard/messaging-policy-state.test.ts b/src/lib/onboard/messaging-policy-state.test.ts new file mode 100644 index 0000000000..0dc2d002b1 --- /dev/null +++ b/src/lib/onboard/messaging-policy-state.test.ts @@ -0,0 +1,127 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import type { SandboxMessagingPlan } from "../messaging/manifest"; +import { + filterMessagingPolicyPresetsForSelection, + mergeRequiredMessagingPolicyPresets, + resolveMessagingPolicyState, +} from "./messaging-policy-state"; + +function makePlan( + channelIds: readonly string[], + overrides: Partial = {}, +): SandboxMessagingPlan { + return { + schemaVersion: 1, + sandboxName: "demo", + agent: "openclaw", + workflow: "onboard", + channels: channelIds.map((channelId) => ({ + channelId, + displayName: channelId, + authMode: "token-paste", + active: true, + selected: true, + configured: true, + disabled: false, + inputs: [], + hooks: [], + })), + disabledChannels: [], + credentialBindings: [], + networkPolicy: { + presets: [...channelIds], + entries: channelIds.map((channelId) => ({ + channelId, + presetName: channelId, + policyKeys: [channelId], + source: "manifest", + })), + }, + agentRender: [], + buildSteps: [], + stateUpdates: [], + healthChecks: [], + ...overrides, + }; +} + +describe("resolveMessagingPolicyState", () => { + it("keeps no-plan legacy channels as fallback source", () => { + expect( + resolveMessagingPolicyState({ + plan: null, + selectedChannels: [], + recordedChannels: ["slack"], + activeSandbox: null, + }), + ).toEqual({ + messagingPolicyPresets: null, + messagingChannelIds: ["slack"], + disabledChannels: null, + }); + }); + + it("treats a compiled plan with no active entries as authoritative", () => { + const plan = makePlan(["slack"], { + channels: [ + { + channelId: "slack", + displayName: "Slack", + authMode: "token-paste", + active: true, + selected: true, + configured: true, + disabled: true, + inputs: [], + hooks: [], + }, + ], + disabledChannels: ["slack"], + }); + + expect( + resolveMessagingPolicyState({ + plan, + recordedChannels: ["slack"], + activeSandbox: { messagingChannels: ["slack"], disabledChannels: [] }, + }), + ).toEqual({ + messagingPolicyPresets: [], + messagingChannelIds: [], + disabledChannels: ["slack"], + }); + }); +}); + +describe("messaging policy selection helpers", () => { + it("adds manifest-derived presets from legacy channel IDs", () => { + expect( + mergeRequiredMessagingPolicyPresets(["npm"], { + messagingPolicyPresets: null, + messagingChannelIds: ["telegram"], + }), + ).toEqual(["npm", "telegram"]); + }); + + it("does not treat no-plan fallback as an all-messaging stale marker", () => { + expect( + filterMessagingPolicyPresetsForSelection(["npm", "slack", "telegram"], { + messagingPolicyPresets: null, + disabledChannels: ["telegram"], + }), + ).toEqual(["npm", "slack"]); + }); + + it("does treat an empty compiled plan preset set as authoritative", () => { + expect( + filterMessagingPolicyPresetsForSelection(["npm", "slack", "telegram"], { + messagingPolicyPresets: [], + disabledChannels: null, + }), + ).toEqual(["npm"]); + }); +}); diff --git a/src/lib/onboard/messaging-policy-state.ts b/src/lib/onboard/messaging-policy-state.ts new file mode 100644 index 0000000000..ecd637e7ea --- /dev/null +++ b/src/lib/onboard/messaging-policy-state.ts @@ -0,0 +1,144 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + filterActiveMessagingPresets, + hasDisabledMessagingPreset, + pruneDisabledMessagingPolicyPresets, + requiredMessagingChannelPolicyPresets, +} from "../messaging/applier/policy-presets"; +import type { SandboxMessagingPlan } from "../messaging/manifest"; +import { + getDisabledChannelsFromPlan, + getEnabledChannelIdsFromPlan, + getPolicyPresetsFromPlan, +} from "./messaging-plan-session"; + +export interface ActiveSandboxPolicyState { + messagingChannels?: string[] | null; + disabledChannels?: string[] | null; +} + +export interface ResolvedMessagingPolicyState { + /** Null means no compiled plan source is available. [] means the plan has no active presets. */ + messagingPolicyPresets: string[] | null; + messagingChannelIds: string[]; + disabledChannels: string[] | null; +} + +function normalizedNames(values: string[] | null | undefined): string[] { + if (!Array.isArray(values)) return []; + const names: string[] = []; + for (const value of values) { + if (typeof value !== "string") continue; + const name = value.trim().toLowerCase(); + if (!name || names.includes(name)) continue; + names.push(name); + } + return names; +} + +export function mergePolicyMessagingChannels( + selectedChannels: string[] | null | undefined, + recordedChannels: string[] | null | undefined, + activeChannels: string[] | null | undefined, + disabledChannels: string[] | null | undefined = null, +): string[] { + const disabled = new Set(normalizedNames(disabledChannels)); + const merged: string[] = []; + for (const channels of [selectedChannels, recordedChannels, activeChannels]) { + for (const channel of normalizedNames(channels)) { + if (!channel || disabled.has(channel) || merged.includes(channel)) continue; + merged.push(channel); + } + } + return merged; +} + +export function resolveMessagingPolicyState(options: { + plan?: SandboxMessagingPlan | null; + selectedChannels?: string[] | null; + recordedChannels?: string[] | null; + activeSandbox?: ActiveSandboxPolicyState | null; + sessionDisabledChannels?: string[] | null; +}): ResolvedMessagingPolicyState { + const planPolicyPresets = getPolicyPresetsFromPlan(options.plan); + if (planPolicyPresets !== null) { + return { + messagingPolicyPresets: planPolicyPresets, + messagingChannelIds: getEnabledChannelIdsFromPlan(options.plan) ?? [], + disabledChannels: getDisabledChannelsFromPlan(options.plan), + }; + } + + const disabledChannels = + options.activeSandbox?.disabledChannels ?? options.sessionDisabledChannels ?? null; + return { + messagingPolicyPresets: null, + messagingChannelIds: mergePolicyMessagingChannels( + options.selectedChannels, + options.recordedChannels, + options.activeSandbox?.messagingChannels, + disabledChannels, + ), + disabledChannels, + }; +} + +export function requiredMessagingPolicyPresets(options: { + messagingPolicyPresets?: string[] | null; + messagingChannelIds?: string[] | null; +}): string[] { + const merged: string[] = []; + for (const preset of options.messagingPolicyPresets ?? []) { + if (!merged.includes(preset)) merged.push(preset); + } + for (const preset of requiredMessagingChannelPolicyPresets(options.messagingChannelIds)) { + if (!merged.includes(preset)) merged.push(preset); + } + return merged; +} + +export function mergeRequiredMessagingPolicyPresets( + selectedPresets: string[], + options: { + messagingPolicyPresets?: string[] | null; + messagingChannelIds?: string[] | null; + knownPresetNames?: Iterable | null; + }, +): string[] { + const known = options.knownPresetNames ? new Set(options.knownPresetNames) : null; + const merged = [...selectedPresets]; + for (const preset of requiredMessagingPolicyPresets(options)) { + if (known && !known.has(preset)) continue; + if (merged.includes(preset)) continue; + merged.push(preset); + } + return merged; +} + +export function filterMessagingPolicyPresetsForSelection( + presets: readonly string[], + options: { + messagingPolicyPresets?: string[] | null; + disabledChannels?: string[] | null; + }, +): string[] { + if (Array.isArray(options.messagingPolicyPresets)) { + return filterActiveMessagingPresets(presets, new Set(options.messagingPolicyPresets)); + } + return pruneDisabledMessagingPolicyPresets([...presets], options.disabledChannels); +} + +export function hasMessagingPolicyPresetNeedingReconcile( + presets: readonly string[], + options: { + messagingPolicyPresets?: string[] | null; + disabledChannels?: string[] | null; + }, +): boolean { + if (Array.isArray(options.messagingPolicyPresets)) { + return hasDisabledMessagingPreset(presets, new Set(options.messagingPolicyPresets)); + } + return filterMessagingPolicyPresetsForSelection(presets, options).length !== presets.length; +} diff --git a/src/lib/onboard/openclaw-otel-policy-presets.test.ts b/src/lib/onboard/openclaw-otel-policy-presets.test.ts index af799fc2fa..d4e548889a 100644 --- a/src/lib/onboard/openclaw-otel-policy-presets.test.ts +++ b/src/lib/onboard/openclaw-otel-policy-presets.test.ts @@ -3,12 +3,12 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -vi.mock("./messaging-policy-presets", () => ({ - mergeRequiredMessagingChannelPolicyPresets: (presets: string[]) => presets, - requiredMessagingChannelPolicyPresets: () => [], +vi.mock("../messaging/applier/policy-presets", () => ({ + ALL_MESSAGING_POLICY_PRESET_NAMES: new Set(["slack"]), + filterActiveMessagingPresets: (presets: string[]) => presets, + hasDisabledMessagingPreset: () => false, pruneDisabledMessagingPolicyPresets: (presets: string[]) => presets, - mergeAppliedPolicyPresetsForDisabledMessagingCleanup: (presets: string[]) => presets, - hasDisabledMessagingPolicyPreset: () => false, + requiredMessagingChannelPolicyPresets: () => [], })); vi.mock("./hermes-managed-tools", () => ({ diff --git a/src/lib/onboard/policy-selection.ts b/src/lib/onboard/policy-selection.ts index 21848e246f..c8ca670702 100644 --- a/src/lib/onboard/policy-selection.ts +++ b/src/lib/onboard/policy-selection.ts @@ -13,12 +13,11 @@ import { mergeRequiredHermesToolGatewayPolicyPresets, } from "./hermes-managed-tools"; import { - hasDisabledMessagingPolicyPreset, - mergeAppliedPolicyPresetsForDisabledMessagingCleanup, - mergeRequiredMessagingChannelPolicyPresets, - pruneDisabledMessagingPolicyPresets, - requiredMessagingChannelPolicyPresets, -} from "./messaging-policy-presets"; + filterMessagingPolicyPresetsForSelection, + hasMessagingPolicyPresetNeedingReconcile, + mergeRequiredMessagingPolicyPresets, + requiredMessagingPolicyPresets, +} from "./messaging-policy-state"; import { isOpenclawAgent, mergeRequiredOpenclawOtelPolicyPresets, @@ -46,7 +45,10 @@ type TiersApi = { }; export type SetupPresetSuggestionOptions = { - enabledChannels?: string[] | null; + messagingPolicyPresets?: string[] | null; + /** Channel IDs whose names map to known policy presets (e.g. "telegram", "discord"). */ + messagingChannelIds?: string[] | null; + disabledChannels?: string[] | null; webSearchConfig?: WebSearchConfig | null; provider?: string | null; agent?: string | null; @@ -60,13 +62,15 @@ export type SetupPolicySelectionOptions = { selectedPresets?: string[] | null; onSelection?: ((policyPresets: string[]) => void) | null; webSearchConfig?: WebSearchConfig | null; - enabledChannels?: string[] | null; + messagingPolicyPresets?: string[] | null; + /** Channel IDs whose names map to known policy presets (e.g. "telegram", "discord"). */ + messagingChannelIds?: string[] | null; + disabledChannels?: string[] | null; provider?: string | null; agent?: string | null; knownPresetNames?: string[]; webSearchSupported?: boolean | null; hermesToolGateways?: string[] | null; - disabledChannels?: string[] | null; }; export type SetupPolicySelectionDeps = { @@ -103,33 +107,32 @@ export type PreparedPolicyResumeSelection = { export function mergeRequiredSetupPolicyPresets( policyPresets: string[], options: { - enabledChannels?: string[] | null; + messagingPolicyPresets?: string[] | null; + messagingChannelIds?: string[] | null; hermesToolGateways?: string[] | null; agent?: string | null; knownPresetNames?: string[] | Set | null; env?: NodeJS.ProcessEnv; } = {}, ): string[] { - const agentFilteredPresets = filterSetupPolicyPresetNamesForAgent( - policyPresets, - options.agent, - ); - const mergedPresets = mergeRequiredOpenclawOtelPolicyPresets( - mergeRequiredMessagingChannelPolicyPresets( - mergeRequiredHermesToolGatewayPolicyPresets( - agentFilteredPresets, - options.hermesToolGateways, - options.knownPresetNames, - ), - options.enabledChannels, + const agentFilteredPresets = filterSetupPolicyPresetNamesForAgent(policyPresets, options.agent); + const withMessaging = mergeRequiredMessagingPolicyPresets( + mergeRequiredHermesToolGatewayPolicyPresets( + agentFilteredPresets, + options.hermesToolGateways, options.knownPresetNames, ), { - agent: options.agent, + messagingPolicyPresets: options.messagingPolicyPresets, + messagingChannelIds: options.messagingChannelIds, knownPresetNames: options.knownPresetNames, - env: options.env, }, ); + const mergedPresets = mergeRequiredOpenclawOtelPolicyPresets(withMessaging, { + agent: options.agent, + knownPresetNames: options.knownPresetNames, + env: options.env, + }); return filterSetupPolicyPresetNamesForAgent(mergedPresets, options.agent); } @@ -140,11 +143,7 @@ export function isStaleBuiltinBravePolicyPreset( customPresetNames?: ReadonlySet | null; } = {}, ): boolean { - return ( - name === "brave" && - !options.webSearchConfig && - !options.customPresetNames?.has(name) - ); + return name === "brave" && !options.webSearchConfig && !options.customPresetNames?.has(name); } export function computeSetupPresetSuggestions( @@ -158,7 +157,7 @@ export function computeSetupPresetSuggestions( options: SetupPresetSuggestionOptions = {}, ): string[] { const { - enabledChannels = null, + messagingPolicyPresets = null, webSearchConfig = null, provider = null, agent = null, @@ -189,16 +188,21 @@ export function computeSetupPresetSuggestions( if (tierName === "open" && typeof agent === "string" && agent.trim().toLowerCase() === "hermes") { 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); + for (const preset of requiredMessagingPolicyPresets({ + messagingPolicyPresets, + messagingChannelIds: options.messagingChannelIds, + })) { + add(preset); } if (Array.isArray(options.hermesToolGateways)) { for (const preset of options.hermesToolGateways) { if (HERMES_TOOL_GATEWAY_PRESET_NAMES.has(preset)) add(preset); } } - return suggestions; + return filterMessagingPolicyPresetsForSelection(suggestions, { + messagingPolicyPresets, + disabledChannels: options.disabledChannels, + }); } export function preparePolicyPresetResumeSelection( @@ -206,8 +210,9 @@ export function preparePolicyPresetResumeSelection( sandboxName: string, options: { recordedPolicyPresets: string[] | null; + messagingPolicyPresets?: string[] | null; + messagingChannelIds?: string[] | null; disabledChannels?: string[] | null; - enabledChannels?: string[] | null; hermesToolGateways?: string[] | null; agent?: string | null; webSearchConfig?: WebSearchConfig | null; @@ -240,9 +245,13 @@ export function preparePolicyPresetResumeSelection( webSearchConfig: options.webSearchConfig, customPresetNames: customPolicyPresetNames, }); - let policyPresets = pruneDisabledMessagingPolicyPresets( + const messagingSelection = { + messagingPolicyPresets: options.messagingPolicyPresets, + disabledChannels: options.disabledChannels, + }; + let policyPresets = filterMessagingPolicyPresetsForSelection( clampedRecordedPolicyPresets.filter((name) => !isStaleBuiltinBrave(name)), - options.disabledChannels, + messagingSelection, ); const recordedPolicyPresetsNeedReconcile = Array.isArray(options.recordedPolicyPresets) && @@ -255,18 +264,24 @@ export function preparePolicyPresetResumeSelection( customPolicyPresetNames, ) .filter((name) => !isStaleBuiltinBrave(name)); - const disabledMessagingPolicyPresetApplied = hasDisabledMessagingPolicyPreset( + + const disabledMessagingPolicyPresetApplied = hasMessagingPolicyPresetNeedingReconcile( appliedPolicyPresetsForSupport, - options.disabledChannels, + messagingSelection, ); - policyPresets = mergeAppliedPolicyPresetsForDisabledMessagingCleanup( - policyPresets, + + const appliedToPreserve = filterMessagingPolicyPresetsForSelection( appliedPolicyPresetsForSupport, - options.disabledChannels, + messagingSelection, ); + for (const preset of appliedToPreserve) { + if (!policyPresets.includes(preset)) policyPresets.push(preset); + } + if (Array.isArray(options.recordedPolicyPresets)) { policyPresets = mergeRequiredSetupPolicyPresets(policyPresets, { - enabledChannels: options.enabledChannels, + messagingPolicyPresets: options.messagingPolicyPresets, + messagingChannelIds: options.messagingChannelIds, hermesToolGateways: options.hermesToolGateways, agent: options.agent, knownPresetNames: selectablePolicyPresets.map((preset) => preset.name), @@ -299,15 +314,18 @@ async function setupPoliciesWithSelectionInner( const selectedPresets = Array.isArray(options.selectedPresets) ? options.selectedPresets : null; const onSelection = typeof options.onSelection === "function" ? options.onSelection : null; const webSearchConfig = options.webSearchConfig || null; - const enabledChannels = Array.isArray(options.enabledChannels) ? options.enabledChannels : null; + const messagingPolicyPresets = Array.isArray(options.messagingPolicyPresets) + ? options.messagingPolicyPresets + : null; + const messagingChannelIds = Array.isArray(options.messagingChannelIds) + ? options.messagingChannelIds + : null; + const disabledChannels = Array.isArray(options.disabledChannels) ? options.disabledChannels : null; const provider = options.provider || null; const agent = options.agent || null; const hermesToolGateways = Array.isArray(options.hermesToolGateways) ? options.hermesToolGateways : null; - const disabledChannels = Array.isArray(options.disabledChannels) - ? options.disabledChannels - : null; deps.step(8, 8, "Policy presets"); @@ -335,12 +353,13 @@ async function setupPoliciesWithSelectionInner( ); const isStaleBuiltinBrave = (name: string) => isStaleBuiltinBravePolicyPreset(name, { webSearchConfig, customPresetNames }); - const appliedForPreservation = pruneDisabledMessagingPolicyPresets( - applied, - disabledChannels, - ).filter((name) => !isStaleBuiltinBrave(name)); - const pruneDisabledPresets = (presetNames: string[]) => - pruneDisabledMessagingPolicyPresets(presetNames, disabledChannels); + const messagingSelection = { messagingPolicyPresets, disabledChannels }; + const filterMessagingSelection = (presetNames: readonly string[]) => + filterMessagingPolicyPresetsForSelection(presetNames, messagingSelection); + const appliedForPreservation = filterMessagingPolicyPresetsForSelection( + applied.filter((name) => !isStaleBuiltinBrave(name)), + messagingSelection, + ); const filterSupportedPresetNames = (presetNames: string[]) => filterSetupPolicyPresetNamesForAgent(presetNames, agent).filter( (name) => @@ -359,13 +378,14 @@ async function setupPoliciesWithSelectionInner( if (chosen !== null) { const knownSelectablePresets = new Set(selectablePresets.map((preset) => preset.name)); chosen = mergeRequiredSetupPolicyPresets(chosen, { - enabledChannels, + messagingPolicyPresets, + messagingChannelIds, hermesToolGateways, agent, knownPresetNames: knownSelectablePresets, env: deps.env, }); - chosen = pruneDisabledPresets(chosen); + chosen = filterMessagingSelection(chosen); } if (selectedPresets !== null) { @@ -382,18 +402,18 @@ async function setupPoliciesWithSelectionInner( const tierName = await deps.selectPolicyTier(); deps.setPolicyTier?.(sandboxName, tierName); - const suggestions = pruneDisabledPresets( - computeSetupPresetSuggestions(deps, tierName, { - enabledChannels, - webSearchConfig, - provider, - agent, - knownPresetNames: allPresets.map((preset) => preset.name), - webSearchSupported: options.webSearchSupported, - hermesToolGateways, - env: deps.env, - }), - ); + const suggestions = computeSetupPresetSuggestions(deps, tierName, { + messagingPolicyPresets, + messagingChannelIds, + disabledChannels, + webSearchConfig, + provider, + agent, + knownPresetNames: allPresets.map((preset) => preset.name), + webSearchSupported: options.webSearchSupported, + hermesToolGateways, + env: deps.env, + }); if (deps.isNonInteractive()) { const policyMode = (deps.env?.NEMOCLAW_POLICY_MODE || "suggested").trim().toLowerCase(); @@ -430,13 +450,14 @@ async function setupPoliciesWithSelectionInner( } chosen = mergeRequiredSetupPolicyPresets(chosen, { - enabledChannels, + messagingPolicyPresets, + messagingChannelIds, hermesToolGateways, agent, knownPresetNames: knownPresets, env: deps.env, }); - chosen = pruneDisabledPresets(chosen); + chosen = filterMessagingSelection(chosen); const invalidPresets = chosen.filter((name) => !knownPresets.has(name)); if (invalidPresets.length > 0) { @@ -455,7 +476,9 @@ async function setupPoliciesWithSelectionInner( preserved.push(name); } if (preserved.length > 0) { - deps.note(` [non-interactive] Preserving previously-applied presets: ${preserved.join(", ")}`); + deps.note( + ` [non-interactive] Preserving previously-applied presets: ${preserved.join(", ")}`, + ); } } @@ -475,17 +498,15 @@ async function setupPoliciesWithSelectionInner( ...suggestions.filter((name) => knownNames.has(name) && !applied.includes(name)), ]; const resolvedPresets = await deps.selectTierPresetsAndAccess(tierName, allPresets, extraSelected); - const interactiveChoice = pruneDisabledPresets( - mergeRequiredSetupPolicyPresets( - resolvedPresets.map((preset) => preset.name), - { - enabledChannels, - hermesToolGateways, - agent, - knownPresetNames: knownNames, - env: deps.env, - }, - ), + const interactiveChoice = filterMessagingSelection( + mergeRequiredSetupPolicyPresets(resolvedPresets.map((preset) => preset.name), { + messagingPolicyPresets, + messagingChannelIds, + hermesToolGateways, + agent, + knownPresetNames: knownNames, + env: deps.env, + }), ); if (onSelection) onSelection(interactiveChoice); diff --git a/test/onboard-messaging.test.ts b/test/onboard-messaging.test.ts index 687d874e14..562d2becb0 100644 --- a/test/onboard-messaging.test.ts +++ b/test/onboard-messaging.test.ts @@ -115,7 +115,7 @@ childProcess.spawn = (...args) => { const { createSandbox, setupMessagingChannels } = require(${onboardPath}); (async () => { - process.env.OPENSHELL_GATEWAY = "nemoclaw"; + process.env.OPENSHELL_GATEWAY = "nemoclaw"; process.env.NEMOCLAW_SKIP_SLACK_AUTH_VALIDATION = "1"; process.env.NEMOCLAW_SKIP_TELEGRAM_REACHABILITY = "1"; process.env.DISCORD_BOT_TOKEN = "test-discord-token-value"; process.env.SLACK_BOT_TOKEN = "xoxb-test-slack-token-value"; diff --git a/test/onboard-policy-suggestions.test.ts b/test/onboard-policy-suggestions.test.ts index ed990340a0..23b68f196a 100644 --- a/test/onboard-policy-suggestions.test.ts +++ b/test/onboard-policy-suggestions.test.ts @@ -11,7 +11,8 @@ const { computeSetupPresetSuggestions: ( tierName: string, options: { - enabledChannels?: string[] | null; + messagingPolicyPresets?: string[] | null; + messagingChannelIds?: string[] | null; knownPresetNames: string[]; provider?: string | null; agent?: string | null; @@ -170,14 +171,14 @@ describe("onboard policy preset suggestions", () => { it("adds openclaw-pricing to tier suggestions when agent is openclaw", () => { const knownWithPricing = [...known, "openclaw-pricing"]; const openclawSuggestions = computeSetupPresetSuggestions("balanced", { - enabledChannels: [], + messagingPolicyPresets: [], knownPresetNames: knownWithPricing, agent: "openclaw", }); expect(openclawSuggestions).toContain("openclaw-pricing"); const hermesSuggestions = computeSetupPresetSuggestions("balanced", { - enabledChannels: [], + messagingPolicyPresets: [], knownPresetNames: knownWithPricing, agent: "hermes", }); @@ -185,20 +186,20 @@ describe("onboard policy preset suggestions", () => { // Default/blank agents are OpenClaw in the lower-level helpers too. const nullAgentSuggestions = computeSetupPresetSuggestions("balanced", { - enabledChannels: [], + messagingPolicyPresets: [], knownPresetNames: knownWithPricing, agent: null, }); expect(nullAgentSuggestions).toContain("openclaw-pricing"); const omittedAgentSuggestions = computeSetupPresetSuggestions("balanced", { - enabledChannels: [], + messagingPolicyPresets: [], knownPresetNames: knownWithPricing, }); expect(omittedAgentSuggestions).toContain("openclaw-pricing"); const blankAgentSuggestions = computeSetupPresetSuggestions("balanced", { - enabledChannels: [], + messagingPolicyPresets: [], knownPresetNames: knownWithPricing, agent: " ", }); @@ -208,7 +209,7 @@ describe("onboard policy preset suggestions", () => { it("adds local OTEL policy to tier suggestions only when OpenClaw OTEL is enabled", () => { const knownWithOtel = [...known, "openclaw-pricing", "openclaw-diagnostics-otel-local"]; const openclawSuggestions = computeSetupPresetSuggestions("balanced", { - enabledChannels: [], + messagingPolicyPresets: [], knownPresetNames: knownWithOtel, agent: "openclaw", env: { NEMOCLAW_OPENCLAW_OTEL: "1" }, @@ -216,7 +217,7 @@ describe("onboard policy preset suggestions", () => { expect(openclawSuggestions).toContain("openclaw-diagnostics-otel-local"); const remoteSuggestions = computeSetupPresetSuggestions("balanced", { - enabledChannels: [], + messagingPolicyPresets: [], knownPresetNames: knownWithOtel, agent: "openclaw", env: { @@ -227,7 +228,7 @@ describe("onboard policy preset suggestions", () => { expect(remoteSuggestions).not.toContain("openclaw-diagnostics-otel-local"); const disabledSuggestions = computeSetupPresetSuggestions("balanced", { - enabledChannels: [], + messagingPolicyPresets: [], knownPresetNames: knownWithOtel, agent: "openclaw", env: { NEMOCLAW_OPENCLAW_OTEL: "0" }, @@ -237,7 +238,7 @@ describe("onboard policy preset suggestions", () => { it("returns balanced tier defaults without messaging presets when no channels enabled", () => { const suggestions = computeSetupPresetSuggestions("balanced", { - enabledChannels: [], + messagingPolicyPresets: [], knownPresetNames: known, }); expect(suggestions).toEqual(["npm", "pypi", "huggingface", "brew", "weather"]); @@ -245,7 +246,7 @@ describe("onboard policy preset suggestions", () => { it("adds Brave to balanced tier defaults only when web search is configured", () => { const suggestions = computeSetupPresetSuggestions("balanced", { - enabledChannels: [], + messagingPolicyPresets: [], knownPresetNames: known, webSearchConfig: { fetchEnabled: true }, webSearchSupported: true, @@ -255,7 +256,7 @@ describe("onboard policy preset suggestions", () => { it("filters tier defaults to known presets for agent-specific onboarding", () => { const suggestions = computeSetupPresetSuggestions("balanced", { - enabledChannels: [], + messagingPolicyPresets: [], knownPresetNames: known.filter((name) => name !== "brave"), }); expect(suggestions).toEqual(["npm", "pypi", "huggingface", "brew", "weather"]); @@ -275,7 +276,7 @@ describe("onboard policy preset suggestions", () => { it("drops Brave tier defaults when web search is unsupported", () => { const suggestions = computeSetupPresetSuggestions("balanced", { - enabledChannels: [], + messagingPolicyPresets: [], knownPresetNames: known, webSearchSupported: false, }); @@ -285,7 +286,7 @@ describe("onboard policy preset suggestions", () => { it("adds all Hermes Nous tool policy presets for Hermes open tier only", () => { const knownWithPricing = [...known, "openclaw-pricing"]; const hermesOpen = computeSetupPresetSuggestions("open", { - enabledChannels: [], + messagingPolicyPresets: [], knownPresetNames: knownWithPricing, agent: "hermes", }); @@ -297,7 +298,7 @@ describe("onboard policy preset suggestions", () => { expect(hermesOpen).not.toContain("openclaw-pricing"); const openclawOpen = computeSetupPresetSuggestions("open", { - enabledChannels: [], + messagingPolicyPresets: [], knownPresetNames: knownWithPricing, agent: "openclaw", }); @@ -332,7 +333,7 @@ describe("onboard policy preset suggestions", () => { it("does not add explicitly requested Hermes Nous presets to OpenClaw suggestions", () => { const suggestions = computeSetupPresetSuggestions("balanced", { - enabledChannels: [], + messagingPolicyPresets: [], knownPresetNames: known, agent: "openclaw", hermesToolGateways: ["nous-web", "nous-code"], @@ -343,7 +344,7 @@ describe("onboard policy preset suggestions", () => { it("forwards enabled messaging channels into tier suggestions", () => { const suggestions = computeSetupPresetSuggestions("balanced", { - enabledChannels: ["telegram"], + messagingChannelIds: ["telegram"], knownPresetNames: known, }); expect(suggestions).toContain("telegram"); @@ -351,7 +352,7 @@ describe("onboard policy preset suggestions", () => { expect(suggestions).not.toContain("brave"); const multi = computeSetupPresetSuggestions("balanced", { - enabledChannels: ["discord", "slack"], + messagingChannelIds: ["discord", "slack"], knownPresetNames: known, }); expect(multi).toContain("discord"); @@ -360,7 +361,7 @@ describe("onboard policy preset suggestions", () => { it("does not duplicate channels already present in the tier", () => { const suggestions = computeSetupPresetSuggestions("open", { - enabledChannels: ["telegram", "slack"], + messagingChannelIds: ["telegram", "slack"], knownPresetNames: known, }); expect(suggestions.filter((name: string) => name === "telegram")).toHaveLength(1); @@ -369,7 +370,7 @@ describe("onboard policy preset suggestions", () => { it("drops channel names that are not known presets", () => { const suggestions = computeSetupPresetSuggestions("balanced", { - enabledChannels: ["telegram", "not-a-real-preset"], + messagingChannelIds: ["telegram", "not-a-real-preset"], knownPresetNames: known, }); expect(suggestions).toContain("telegram"); @@ -401,9 +402,9 @@ describe("onboard policy preset suggestions", () => { ).toContain("local-inference"); }); - it("ignores enabledChannels when null", () => { + it("ignores messagingChannelIds when null", () => { const suggestions = computeSetupPresetSuggestions("balanced", { - enabledChannels: null, + messagingChannelIds: null, knownPresetNames: known, }); expect(suggestions).not.toContain("telegram"); diff --git a/test/onboard-preset-diff.test.ts b/test/onboard-preset-diff.test.ts index 9391ecc199..7c9be07f0e 100644 --- a/test/onboard-preset-diff.test.ts +++ b/test/onboard-preset-diff.test.ts @@ -356,7 +356,7 @@ console.log = () => {}; try { const chosen = await setupPoliciesWithSelection("test-sb", { selectedPresets: ["npm", "pypi"], - enabledChannels: ["slack"], + messagingPolicyPresets: ["slack"], }); process.stdout.write(JSON.stringify({ chosen, appliedCalls, removedCalls, finalApplied: appliedState }) + "\n"); } catch (err) { @@ -390,7 +390,7 @@ console.log = () => {}; (async () => { try { const chosen = await setupPoliciesWithSelection("test-sb", { - enabledChannels: ["slack"], + messagingPolicyPresets: ["slack"], }); process.stdout.write(JSON.stringify({ chosen, appliedCalls, removedCalls, finalApplied: appliedState }) + "\n"); } catch (err) { @@ -424,7 +424,7 @@ console.log = () => {}; (async () => { try { const chosen = await setupPoliciesWithSelection("test-sb", { - disabledChannels: ["slack"], + messagingPolicyPresets: [], }); process.stdout.write(JSON.stringify({ chosen, appliedCalls, removedCalls, finalApplied: appliedState }) + "\n"); } catch (err) { @@ -455,7 +455,7 @@ console.log = () => {}; (async () => { try { const chosen = await setupPoliciesWithSelection("test-sb", { - disabledChannels: ["slack"], + messagingPolicyPresets: [], }); process.stdout.write(JSON.stringify({ chosen, appliedCalls, removedCalls, finalApplied: appliedState }) + "\n"); } catch (err) { diff --git a/test/policies.test.ts b/test/policies.test.ts index 9ece74dc2c..ae98551183 100644 --- a/test/policies.test.ts +++ b/test/policies.test.ts @@ -794,18 +794,16 @@ exit 1 describe("applyPreset disclosure logging", () => { it("logs egress endpoints before applying", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-log-")); + const previousHome = process.env.HOME; + process.env.HOME = tmpDir; + const resolveSpy = vi + .spyOn(resolveOpenshellModule, "resolveOpenshell") + .mockReturnValue("/bin/true"); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { - throw new Error("exit"); - }); try { - try { - policies.applyPreset("test-sandbox", "npm"); - } catch { - /* applyPreset may throw if sandbox not running — we only care about the log */ - } + try { policies.applyPreset("test-sandbox", "npm"); } catch { /* log assertion only */ } const messages = logSpy.mock.calls.map((call) => typeof call[0] === "string" ? call[0] : undefined, ); @@ -814,8 +812,10 @@ exit 1 ).toBe(true); } finally { logSpy.mockRestore(); - errSpy.mockRestore(); - exitSpy.mockRestore(); + resolveSpy.mockRestore(); + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + fs.rmSync(tmpDir, { recursive: true, force: true }); } }); diff --git a/test/rebuild-policy-presets.test.ts b/test/rebuild-policy-presets.test.ts index d47d188fb9..1b435cdc6c 100644 --- a/test/rebuild-policy-presets.test.ts +++ b/test/rebuild-policy-presets.test.ts @@ -12,7 +12,7 @@ import os from "node:os"; import path from "node:path"; import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { pruneDisabledMessagingPolicyPresets } from "../src/lib/onboard/messaging-policy-presets"; +import { pruneDisabledMessagingPolicyPresets } from "../src/lib/messaging/applier/policy-presets"; type ManifestWithOptionalPresets = { version: number; @@ -178,13 +178,22 @@ describe("rebuild policy preset restoration (#1952)", () => { expect(savedPresets).toEqual(["npm", "pypi"]); }); - it("preserves non-required channel presets for later start and rebuild", () => { + it("removes telegram preset when telegram channel is disabled", () => { const manifest = { policyPresets: ["telegram", "npm", "pypi"] }; const savedPresets = pruneDisabledMessagingPolicyPresets( manifest.policyPresets || [], ["telegram"], ); - expect(savedPresets).toEqual(["telegram", "npm", "pypi"]); + expect(savedPresets).toEqual(["npm", "pypi"]); + }); + + it("preserves non-messaging custom presets when a channel is disabled", () => { + const manifest = { policyPresets: ["custom-api", "npm", "pypi"] }; + const savedPresets = pruneDisabledMessagingPolicyPresets( + manifest.policyPresets || [], + ["telegram"], + ); + expect(savedPresets).toEqual(["custom-api", "npm", "pypi"]); }); }); }); diff --git a/test/secret-redaction.test.ts b/test/secret-redaction.test.ts index 417aada56e..878410466c 100644 --- a/test/secret-redaction.test.ts +++ b/test/secret-redaction.test.ts @@ -81,6 +81,7 @@ describe("secret redaction consistency (#1736)", () => { encoding: "utf-8", env: { ...process.env, + HOME: tmp, NEMOCLAW_NODE: process.execPath, TMPDIR: tmp, PATH: `${fakeBin}:${process.env.PATH || ""}`, @@ -132,6 +133,7 @@ describe("secret redaction consistency (#1736)", () => { encoding: "utf-8", env: { ...process.env, + HOME: tmp, NEMOCLAW_NODE: process.execPath, TMPDIR: tmp, PATH: fakeBin,