Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
b977fe4
fix(messaging): reject channel adds on agents that do not support mes…
laitingsheng Jun 24, 2026
ae0e554
test(messaging): enforce planner deny-all and prove rejection cannot …
laitingsheng Jun 24, 2026
9597381
fix(messaging): clear rebuild plan for known agents with empty messag…
laitingsheng Jun 24, 2026
2636791
fix(messaging): drop stored channels outside supportedChannelIds on r…
laitingsheng Jun 24, 2026
aac70e7
fix(messaging): allow stale plan cleanup on non-messaging agents with…
laitingsheng Jun 24, 2026
b9b48bb
fix(messaging): differentiate unknown-runtime vs empty-allowlist skip…
laitingsheng Jun 24, 2026
a3c5a5e
Merge remote-tracking branch 'origin/main' into fix/deepagents-messag…
laitingsheng Jun 24, 2026
e69ebf8
docs(messaging): record agent gating invariant + behaviour-level reje…
laitingsheng Jun 24, 2026
445a34c
Merge remote-tracking branch 'origin/main' into fix/deepagents-messag…
laitingsheng Jun 24, 2026
b1e88c7
feat(messaging): allow DeepAgents agent on messaging runtime allowlist
laitingsheng Jun 24, 2026
8613e96
feat(messaging): apply DeepAgents messaging plan during sandbox build
laitingsheng Jun 24, 2026
85f32a3
feat(messaging): consume DeepAgents messaging artifacts at sandbox st…
laitingsheng Jun 24, 2026
355775e
merge: main into deepagents messaging fix
cv Jun 24, 2026
7017976
fix(messaging): keep DeepAgents channels gated until bridge support
cv Jun 24, 2026
4b6a0a4
chore(onboard): keep entrypoint line-neutral
cv Jun 24, 2026
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
8 changes: 8 additions & 0 deletions src/lib/actions/sandbox/policy-channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
createMessagingPreEnableHookInputs,
getMessagingManifestAvailabilityContext,
isMessagingHookConflictError,
isMessagingSupportedAgent,
MessagingHostStateApplier,
MessagingSetupApplier,
MessagingWorkflowPlanner,
Expand Down Expand Up @@ -900,6 +901,13 @@ export async function addSandboxChannel(
const canonical = manifest.id;

const agent = resolveAgentForSandbox(sandboxName);
if (!isMessagingSupportedAgent(agent)) {
console.error(
` Agent '${agent.name}' does not support messaging channels for sandbox '${sandboxName}'.`,
);
console.error(" Messaging-capable agents: openclaw, hermes.");
process.exit(1);
}
if (!channelSupportedByAgent(canonical, agent)) {
console.error(
` Channel '${canonical}' is not supported by agent '${agent.name}' for sandbox '${sandboxName}'.`,
Expand Down
12 changes: 10 additions & 2 deletions src/lib/actions/sandbox/rebuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ import {
createBuiltInChannelManifestRegistry,
MessagingSetupApplier,
MessagingWorkflowPlanner,
toMessagingAgentId,
tryGetMessagingAgentId,
} from "../../messaging";
import { hydrateMessagingChannelConfig } from "../../messaging-channel-config";
import { getStoredMessagingChannelConfig } from "../../onboard/messaging-config";
Expand Down Expand Up @@ -231,10 +231,18 @@ async function stageMessagingManifestPlanForRebuild(
log: (msg: string) => void,
): Promise<SandboxMessagingPlan | null> {
const agent = loadAgent(rebuildAgent || "openclaw");
const agentId = tryGetMessagingAgentId(agent);
if (agentId === null) {
MessagingSetupApplier.clearPlanEnv();
log(
`Messaging manifest rebuild plan skipped: agent '${agent.name}' does not support messaging`,
);
return null;
}
const planner = new MessagingWorkflowPlanner(createBuiltInChannelManifestRegistry());
const plan = await planner.buildRebuildPlanFromSandboxEntry({
sandboxName,
agent: toMessagingAgentId(agent),
agent: agentId,
sandboxEntry,
supportedChannelIds: agent.messagingPlatforms,
});
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 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 no channels available", () => {
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 omitted or null supportedChannelIds as no platform restriction", () => {
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
136 changes: 136 additions & 0 deletions src/lib/messaging/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// 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,
isMessagingSupportedAgent,
MessagingAgentNotSupportedError,
toMessagingAgentId,
tryGetMessagingAgentId,
} from "./utils";

describe("tryGetMessagingAgentId", () => {
it("returns 'openclaw' for the openclaw agent name", () => {
expect(tryGetMessagingAgentId({ name: "openclaw" })).toBe("openclaw");
});

it("returns 'hermes' for the hermes agent name", () => {
expect(tryGetMessagingAgentId({ name: "hermes" })).toBe("hermes");
});

it("returns null for unknown agent names instead of silently defaulting", () => {
expect(tryGetMessagingAgentId({ name: "langchain-deepagents-code" })).toBeNull();
expect(tryGetMessagingAgentId({ name: "custom-agent" })).toBeNull();
});

it("returns null for null or undefined input", () => {
expect(tryGetMessagingAgentId(null)).toBeNull();
expect(tryGetMessagingAgentId(undefined)).toBeNull();
expect(tryGetMessagingAgentId({})).toBeNull();
});
});

describe("toMessagingAgentId", () => {
it("returns the messaging agent id for known names", () => {
expect(toMessagingAgentId({ name: "openclaw" })).toBe("openclaw");
expect(toMessagingAgentId({ name: "hermes" })).toBe("hermes");
});

it("falls back to openclaw when no agent name is supplied (legacy default convention)", () => {
expect(toMessagingAgentId(null)).toBe("openclaw");
expect(toMessagingAgentId(undefined)).toBe("openclaw");
expect(toMessagingAgentId({})).toBe("openclaw");
expect(toMessagingAgentId({ name: "" })).toBe("openclaw");
expect(toMessagingAgentId({ name: " " })).toBe("openclaw");
});

it("throws MessagingAgentNotSupportedError for an explicit unknown agent", () => {
expect(() => toMessagingAgentId({ name: "langchain-deepagents-code" })).toThrow(
MessagingAgentNotSupportedError,
);
expect(() => toMessagingAgentId({ name: "custom-agent" })).toThrow(
MessagingAgentNotSupportedError,
);
});

it("surfaces the offending agent name on the thrown error", () => {
try {
toMessagingAgentId({ name: "langchain-deepagents-code" });
} catch (err) {
expect(err).toBeInstanceOf(MessagingAgentNotSupportedError);
expect((err as MessagingAgentNotSupportedError).agentName).toBe("langchain-deepagents-code");
expect((err as Error).message).toMatch(/openclaw, hermes/);
return;
}
throw new Error("expected toMessagingAgentId to throw");
});
});

describe("isMessagingSupportedAgent", () => {
it("returns true for openclaw and hermes regardless of messagingPlatforms", () => {
expect(isMessagingSupportedAgent({ name: "openclaw" })).toBe(true);
expect(isMessagingSupportedAgent({ name: "hermes", messagingPlatforms: ["telegram"] })).toBe(
true,
);
});

it("returns false for known agents whose messagingPlatforms is an explicit empty allowlist", () => {
expect(isMessagingSupportedAgent({ name: "openclaw", messagingPlatforms: [] })).toBe(false);
expect(isMessagingSupportedAgent({ name: "hermes", messagingPlatforms: [] })).toBe(false);
});

it("returns false for unknown agents", () => {
expect(
isMessagingSupportedAgent({
name: "langchain-deepagents-code",
messagingPlatforms: [],
}),
).toBe(false);
expect(isMessagingSupportedAgent({ name: "custom-agent" })).toBe(false);
expect(isMessagingSupportedAgent(null)).toBe(false);
});
});

describe("getMessagingManifestAvailabilityContext", () => {
it("returns the resolved messaging agent id and an explicit allowlist when present", () => {
expect(
getMessagingManifestAvailabilityContext({
name: "openclaw",
messagingPlatforms: ["telegram", "discord"],
}),
).toEqual({
agent: "openclaw",
supportedChannelIds: ["telegram", "discord"],
});
});

it("distinguishes an empty allowlist from an absent one", () => {
expect(
getMessagingManifestAvailabilityContext({
name: "openclaw",
messagingPlatforms: [],
}),
).toEqual({
agent: "openclaw",
supportedChannelIds: [],
});
expect(getMessagingManifestAvailabilityContext({ name: "openclaw" })).toEqual({
agent: "openclaw",
supportedChannelIds: null,
});
});

it("returns a null agent for unknown agents and never silently defaults to openclaw", () => {
expect(
getMessagingManifestAvailabilityContext({
name: "langchain-deepagents-code",
messagingPlatforms: [],
}),
).toEqual({
agent: null,
supportedChannelIds: [],
});
});
});
49 changes: 43 additions & 6 deletions src/lib/messaging/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,58 @@ export interface MessagingAgentDescriptor {

export type MessagingInputResolver = (input: ChannelInputSpec) => string | null;

const MESSAGING_AGENT_IDS = ["openclaw", "hermes"] as const satisfies readonly MessagingAgentId[];

export class MessagingAgentNotSupportedError extends Error {
readonly agentName: string;
constructor(agentName: string) {
super(
`Agent '${agentName}' does not support messaging. Supported agents: ${MESSAGING_AGENT_IDS.join(", ")}.`,
);
this.name = "MessagingAgentNotSupportedError";
this.agentName = agentName;
}
}

export function tryGetMessagingAgentId(
agent: MessagingAgentDescriptor | null | undefined,
): MessagingAgentId | null {
const name = agent?.name;
return (MESSAGING_AGENT_IDS as readonly string[]).includes(name ?? "")
? (name as MessagingAgentId)
: null;
}

export function toMessagingAgentId(
agent: MessagingAgentDescriptor | null | undefined,
): MessagingAgentId {
return agent?.name === "hermes" ? "hermes" : "openclaw";
const name = agent?.name;
if (typeof name !== "string" || name.trim() === "") {
return "openclaw";
}
const id = tryGetMessagingAgentId(agent);
if (id === null) {
throw new MessagingAgentNotSupportedError(name);
}
return id;
}

export function isMessagingSupportedAgent(
agent: MessagingAgentDescriptor | null | undefined,
): boolean {
if (tryGetMessagingAgentId(agent) === null) return false;
const platforms = agent?.messagingPlatforms;
return !Array.isArray(platforms) || platforms.length > 0;
}

export function getMessagingManifestAvailabilityContext(
agent: MessagingAgentDescriptor | null | undefined,
): ChannelManifestAvailabilityContext {
const id = tryGetMessagingAgentId(agent);
const platforms = agent?.messagingPlatforms;
return {
agent: toMessagingAgentId(agent),
supportedChannelIds:
agent?.messagingPlatforms && agent.messagingPlatforms.length > 0
? agent.messagingPlatforms
: null,
agent: id,
supportedChannelIds: Array.isArray(platforms) ? platforms : null,
};
}

Expand Down
Loading