diff --git a/src/lib/messaging/compiler/manifest-compiler.test.ts b/src/lib/messaging/compiler/manifest-compiler.test.ts index 08b20dd93cb..51467c79b5f 100644 --- a/src/lib/messaging/compiler/manifest-compiler.test.ts +++ b/src/lib/messaging/compiler/manifest-compiler.test.ts @@ -1158,4 +1158,17 @@ describe("ManifestCompiler", () => { expect(hookCalls).toEqual(["enroll", "reachability:!room:example.com"]); expect(JSON.stringify(plan)).not.toContain("raw-matrix-token"); }); + + it("treats supportedChannelIds: [] as deny-all and reports the requested channel as missing", async () => { + await expect( + compiler().compile({ + sandboxName: "demo", + agent: "openclaw", + workflow: "onboard", + isInteractive: false, + configuredChannels: ["telegram"], + supportedChannelIds: [], + }), + ).rejects.toThrow("Missing messaging channel manifest(s): telegram"); + }); }); diff --git a/src/lib/messaging/compiler/manifest-compiler.ts b/src/lib/messaging/compiler/manifest-compiler.ts index 30f7758c230..778744cc73c 100644 --- a/src/lib/messaging/compiler/manifest-compiler.ts +++ b/src/lib/messaging/compiler/manifest-compiler.ts @@ -118,10 +118,9 @@ export class ManifestCompiler { context: ManifestCompilerContext, ): ChannelManifest[] { const requestedIds = new Set(channelIds); - const supportedIds = - context.supportedChannelIds && context.supportedChannelIds.length > 0 - ? new Set(context.supportedChannelIds) - : null; + const supportedIds = Array.isArray(context.supportedChannelIds) + ? new Set(context.supportedChannelIds) + : null; const manifests = this.registry .list() diff --git a/src/lib/messaging/compiler/types.ts b/src/lib/messaging/compiler/types.ts index 1c9ee945984..a0adcf583e9 100644 --- a/src/lib/messaging/compiler/types.ts +++ b/src/lib/messaging/compiler/types.ts @@ -14,6 +14,6 @@ export interface ManifestCompilerContext { readonly isInteractive: boolean; readonly configuredChannels: readonly MessagingChannelId[]; readonly disabledChannels?: readonly MessagingChannelId[]; - readonly supportedChannelIds?: readonly MessagingChannelId[]; + readonly supportedChannelIds?: readonly MessagingChannelId[] | null; readonly credentialAvailability?: MessagingCompilerCredentialAvailability; } diff --git a/src/lib/messaging/compiler/workflow-planner.test.ts b/src/lib/messaging/compiler/workflow-planner.test.ts index 04440f38398..ddec8348e17 100644 --- a/src/lib/messaging/compiler/workflow-planner.test.ts +++ b/src/lib/messaging/compiler/workflow-planner.test.ts @@ -770,6 +770,75 @@ describe("MessagingWorkflowPlanner", () => { expect(rebuilt).toBeNull(); }); + it("drops a persisted Telegram plan during rebuild when supportedChannelIds: [] declares deny-all", async () => { + const existingPlan = await planner().buildPlan({ + sandboxName: "demo", + agent: "openclaw", + workflow: "onboard", + isInteractive: false, + configuredChannels: ["telegram"], + credentialAvailability: { TELEGRAM_BOT_TOKEN: true }, + }); + + const rebuilt = await planner().buildRebuildPlanFromSandboxEntry({ + sandboxName: "demo", + agent: "openclaw", + sandboxEntry: { + name: "demo", + messaging: { schemaVersion: 1, plan: existingPlan }, + }, + supportedChannelIds: [], + }); + + expect(rebuilt).toBeNull(); + }); + + it("drops a persisted plan during stop/start/remove mutations when supportedChannelIds: [] denies the stored channel", async () => { + const existingPlan = await planner().buildPlan({ + sandboxName: "demo", + agent: "openclaw", + workflow: "onboard", + isInteractive: false, + configuredChannels: ["telegram"], + credentialAvailability: { TELEGRAM_BOT_TOKEN: true }, + }); + + const baseEntry = { + name: "demo", + messaging: { schemaVersion: 1, plan: existingPlan } as const, + }; + + expect( + await planner().buildChannelStopPlanFromSandboxEntry({ + sandboxName: "demo", + agent: "openclaw", + channelId: "telegram", + sandboxEntry: baseEntry, + supportedChannelIds: [], + }), + ).toBeNull(); + + expect( + await planner().buildChannelStartPlanFromSandboxEntry({ + sandboxName: "demo", + agent: "openclaw", + channelId: "telegram", + sandboxEntry: baseEntry, + supportedChannelIds: [], + }), + ).toBeNull(); + + expect( + await planner().buildChannelRemovePlanFromSandboxEntry({ + sandboxName: "demo", + agent: "openclaw", + channelId: "telegram", + sandboxEntry: baseEntry, + supportedChannelIds: [], + }), + ).toBeNull(); + }); + it("reports unsupported channels deterministically before compiling", async () => { await expect( planner().buildPlan({ @@ -783,6 +852,19 @@ describe("MessagingWorkflowPlanner", () => { ).rejects.toThrow("Unsupported messaging channel(s) for openclaw: discord, slack"); }); + it("rejects every configured channel when supportedChannelIds is an explicit empty array (deny-all)", async () => { + await expect( + planner().buildPlan({ + sandboxName: "demo", + agent: "openclaw", + workflow: "onboard", + isInteractive: false, + configuredChannels: ["telegram"], + supportedChannelIds: [], + }), + ).rejects.toThrow("Unsupported messaging channel(s) for openclaw: telegram"); + }); + it("returns serializable, secret-free plans suitable for dry-run and shadow output", async () => { await withEnv( { diff --git a/src/lib/messaging/compiler/workflow-planner.ts b/src/lib/messaging/compiler/workflow-planner.ts index ce901ad1d3c..cbd6b4d21c2 100644 --- a/src/lib/messaging/compiler/workflow-planner.ts +++ b/src/lib/messaging/compiler/workflow-planner.ts @@ -25,7 +25,7 @@ export interface MessagingWorkflowPlannerBuildContext { readonly isInteractive: boolean; readonly configuredChannels?: readonly MessagingChannelId[]; readonly disabledChannels?: readonly MessagingChannelId[]; - readonly supportedChannelIds?: readonly MessagingChannelId[]; + readonly supportedChannelIds?: readonly MessagingChannelId[] | null; readonly credentialAvailability?: MessagingCompilerCredentialAvailability; } @@ -146,10 +146,9 @@ export class MessagingWorkflowPlanner { private supportedChannelIds( context: Pick, ): MessagingChannelId[] { - const supportedFilter = - context.supportedChannelIds && context.supportedChannelIds.length > 0 - ? new Set(context.supportedChannelIds) - : null; + const supportedFilter = Array.isArray(context.supportedChannelIds) + ? new Set(context.supportedChannelIds) + : null; return this.registry .list() @@ -168,7 +167,10 @@ export class MessagingWorkflowPlanner { } private credentialAvailabilityFromSandboxEntry( - context: Pick, + context: Pick< + MessagingWorkflowPlannerSandboxContext, + "agent" | "sandboxEntry" | "sandboxName" | "supportedChannelIds" + >, channelIds: readonly MessagingChannelId[], ): MessagingCompilerCredentialAvailability | undefined { const plan = readSandboxEntryPlan(context); @@ -207,7 +209,7 @@ export interface MessagingWorkflowPlannerSandboxContext { readonly sandboxName: string; readonly agent: MessagingAgentId; readonly sandboxEntry?: MessagingWorkflowPlannerSandboxEntry | null; - readonly supportedChannelIds?: readonly MessagingChannelId[]; + readonly supportedChannelIds?: readonly MessagingChannelId[] | null; readonly credentialAvailability?: MessagingCompilerCredentialAvailability; } @@ -239,11 +241,15 @@ function onlyConfiguredChannels( } function readSandboxEntryPlan( - context: Pick, + context: Pick< + MessagingWorkflowPlannerSandboxContext, + "agent" | "sandboxEntry" | "sandboxName" | "supportedChannelIds" + >, ): SandboxMessagingPlan | null { const plan = parseSandboxMessagingPlan(context.sandboxEntry?.messaging?.plan, { sandboxName: context.sandboxName, agent: context.agent, + supportedChannelIds: context.supportedChannelIds, }); return plan ? hydrateDerivedSandboxMessagingPlanFields(plan) : null; } diff --git a/src/lib/messaging/manifest/registry.test.ts b/src/lib/messaging/manifest/registry.test.ts index 12a2c131b3e..3c886c68c01 100644 --- a/src/lib/messaging/manifest/registry.test.ts +++ b/src/lib/messaging/manifest/registry.test.ts @@ -47,7 +47,7 @@ describe("ChannelManifestRegistry", () => { ); }); - it("filters available manifests by agent and non-empty platform support lists", () => { + it("filters available manifests by agent and explicit platform support lists", () => { const registry = new ChannelManifestRegistry([TELEGRAM_MANIFEST, WECHAT_MANIFEST]); expect(registry.listAvailable().map((manifest) => manifest.id)).toEqual(["telegram", "wechat"]); @@ -59,8 +59,29 @@ describe("ChannelManifestRegistry", () => { .listAvailable({ agent: "openclaw", supportedChannelIds: ["wechat"] }) .map((manifest) => manifest.id), ).toEqual(["wechat"]); + }); + + it("treats an explicit empty supportedChannelIds array as deny-all", () => { + const registry = new ChannelManifestRegistry([TELEGRAM_MANIFEST, WECHAT_MANIFEST]); + expect( registry.listAvailable({ supportedChannelIds: [] }).map((manifest) => manifest.id), + ).toEqual([]); + expect( + registry + .listAvailable({ agent: "openclaw", supportedChannelIds: [] }) + .map((manifest) => manifest.id), + ).toEqual([]); + }); + + it("treats null or undefined supportedChannelIds as no constraint", () => { + const registry = new ChannelManifestRegistry([TELEGRAM_MANIFEST, WECHAT_MANIFEST]); + + expect( + registry.listAvailable({ supportedChannelIds: null }).map((manifest) => manifest.id), + ).toEqual(["telegram", "wechat"]); + expect( + registry.listAvailable({ supportedChannelIds: undefined }).map((manifest) => manifest.id), ).toEqual(["telegram", "wechat"]); }); }); diff --git a/src/lib/messaging/manifest/registry.ts b/src/lib/messaging/manifest/registry.ts index 235754ac6a9..145251fd29f 100644 --- a/src/lib/messaging/manifest/registry.ts +++ b/src/lib/messaging/manifest/registry.ts @@ -35,10 +35,9 @@ export class ChannelManifestRegistry { } listAvailable(ctx: ChannelManifestAvailabilityContext = {}): ChannelManifest[] { - const supportedChannelIds = - ctx.supportedChannelIds && ctx.supportedChannelIds.length > 0 - ? new Set(ctx.supportedChannelIds) - : null; + const supportedChannelIds = Array.isArray(ctx.supportedChannelIds) + ? new Set(ctx.supportedChannelIds) + : null; return this.list().filter((manifest) => { if (ctx.agent && !manifest.supportedAgents.includes(ctx.agent)) { diff --git a/src/lib/messaging/plan-validation.test.ts b/src/lib/messaging/plan-validation.test.ts index 0f4682b6ea1..9de40dfe292 100644 --- a/src/lib/messaging/plan-validation.test.ts +++ b/src/lib/messaging/plan-validation.test.ts @@ -158,6 +158,10 @@ describe("parseSandboxMessagingPlan", () => { ).toBeNull(); }); + it("rejects any persisted channel when supportedChannelIds: [] is passed (deny-all)", () => { + expect(parseSandboxMessagingPlan(makePlan(), { supportedChannelIds: [] })).toBeNull(); + }); + it("rejects malformed channel arrays without throwing", () => { const plan = makePlan() as unknown as { channels: unknown[] }; plan.channels = [null]; diff --git a/src/lib/messaging/plan-validation.ts b/src/lib/messaging/plan-validation.ts index dce275201af..f4923adce73 100644 --- a/src/lib/messaging/plan-validation.ts +++ b/src/lib/messaging/plan-validation.ts @@ -45,10 +45,9 @@ export function parseSandboxMessagingPlan( if (options.sandboxName && value.sandboxName !== options.sandboxName) return null; if (options.agent && value.agent !== options.agent) return null; - const supported = - options.supportedChannelIds && options.supportedChannelIds.length > 0 - ? new Set(options.supportedChannelIds) - : null; + const supported = Array.isArray(options.supportedChannelIds) + ? new Set(options.supportedChannelIds) + : null; for (const [index, channel] of value.channels.entries()) { if (!isObject(channel) || typeof channel.channelId !== "string") return null; if (Object.hasOwn(channel, "configured") && typeof channel.configured !== "boolean") { diff --git a/src/lib/messaging/utils.test.ts b/src/lib/messaging/utils.test.ts new file mode 100644 index 00000000000..39d92870cea --- /dev/null +++ b/src/lib/messaging/utils.test.ts @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; +import { getMessagingManifestAvailabilityContext, toMessagingAgentId } from "./utils"; + +describe("toMessagingAgentId", () => { + it("returns 'hermes' for hermes-named agents", () => { + expect(toMessagingAgentId({ name: "hermes" })).toBe("hermes"); + }); + + it("defaults to 'openclaw' for any other name (including non-OpenClaw runtimes)", () => { + expect(toMessagingAgentId({ name: "openclaw" })).toBe("openclaw"); + expect(toMessagingAgentId({ name: "langchain-deepagents-code" })).toBe("openclaw"); + expect(toMessagingAgentId(null)).toBe("openclaw"); + expect(toMessagingAgentId(undefined)).toBe("openclaw"); + }); +}); + +describe("getMessagingManifestAvailabilityContext", () => { + it("preserves an explicit empty messagingPlatforms array as a deny-all signal", () => { + expect( + getMessagingManifestAvailabilityContext({ + name: "langchain-deepagents-code", + messagingPlatforms: [], + }), + ).toEqual({ agent: "openclaw", supportedChannelIds: [] }); + }); + + it("forwards a populated messagingPlatforms array verbatim", () => { + expect( + getMessagingManifestAvailabilityContext({ + name: "openclaw", + messagingPlatforms: ["telegram", "slack"], + }), + ).toEqual({ agent: "openclaw", supportedChannelIds: ["telegram", "slack"] }); + }); + + it("falls back to null (no constraint) when messagingPlatforms is missing", () => { + expect(getMessagingManifestAvailabilityContext({ name: "openclaw" })).toEqual({ + agent: "openclaw", + supportedChannelIds: null, + }); + expect(getMessagingManifestAvailabilityContext(null)).toEqual({ + agent: "openclaw", + supportedChannelIds: null, + }); + }); + + it("preserves hermes agent identity alongside platform constraints", () => { + expect( + getMessagingManifestAvailabilityContext({ + name: "hermes", + messagingPlatforms: ["telegram"], + }), + ).toEqual({ agent: "hermes", supportedChannelIds: ["telegram"] }); + }); +}); diff --git a/src/lib/messaging/utils.ts b/src/lib/messaging/utils.ts index 4f9d61c7ce9..df5a6f3ec85 100644 --- a/src/lib/messaging/utils.ts +++ b/src/lib/messaging/utils.ts @@ -27,10 +27,7 @@ export function getMessagingManifestAvailabilityContext( ): ChannelManifestAvailabilityContext { return { agent: toMessagingAgentId(agent), - supportedChannelIds: - agent?.messagingPlatforms && agent.messagingPlatforms.length > 0 - ? agent.messagingPlatforms - : null, + supportedChannelIds: Array.isArray(agent?.messagingPlatforms) ? agent.messagingPlatforms : null, }; }