Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/lib/messaging/compiler/manifest-compiler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
7 changes: 3 additions & 4 deletions src/lib/messaging/compiler/manifest-compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion src/lib/messaging/compiler/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
82 changes: 82 additions & 0 deletions src/lib/messaging/compiler/workflow-planner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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(
{
Expand Down
22 changes: 14 additions & 8 deletions src/lib/messaging/compiler/workflow-planner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -146,10 +146,9 @@ export class MessagingWorkflowPlanner {
private supportedChannelIds(
context: Pick<MessagingWorkflowPlannerBuildContext, "agent" | "supportedChannelIds">,
): 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()
Expand All @@ -168,7 +167,10 @@ export class MessagingWorkflowPlanner {
}

private credentialAvailabilityFromSandboxEntry(
context: Pick<MessagingWorkflowPlannerSandboxContext, "agent" | "sandboxEntry" | "sandboxName">,
context: Pick<
MessagingWorkflowPlannerSandboxContext,
"agent" | "sandboxEntry" | "sandboxName" | "supportedChannelIds"
>,
channelIds: readonly MessagingChannelId[],
): MessagingCompilerCredentialAvailability | undefined {
const plan = readSandboxEntryPlan(context);
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -239,11 +241,15 @@ function onlyConfiguredChannels(
}

function readSandboxEntryPlan(
context: Pick<MessagingWorkflowPlannerSandboxContext, "agent" | "sandboxEntry" | "sandboxName">,
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;
}
Expand Down
23 changes: 22 additions & 1 deletion src/lib/messaging/manifest/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
Expand All @@ -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"]);
});
});
7 changes: 3 additions & 4 deletions src/lib/messaging/manifest/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
4 changes: 4 additions & 0 deletions src/lib/messaging/plan-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
7 changes: 3 additions & 4 deletions src/lib/messaging/plan-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
58 changes: 58 additions & 0 deletions src/lib/messaging/utils.test.ts
Original file line number Diff line number Diff line change
@@ -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"] });
});
});
5 changes: 1 addition & 4 deletions src/lib/messaging/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}

Expand Down