From b977fe4bac1a72ba88ee7e6ab2f0125dde4e91ab Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Wed, 24 Jun 2026 12:45:29 +0000 Subject: [PATCH 01/12] fix(messaging): reject channel adds on agents that do not support messaging Signed-off-by: Tinson Lai --- src/lib/actions/sandbox/policy-channel.ts | 8 ++ src/lib/actions/sandbox/rebuild.ts | 12 +- src/lib/messaging/manifest/registry.test.ts | 23 +++- src/lib/messaging/manifest/registry.ts | 7 +- src/lib/messaging/utils.test.ts | 136 ++++++++++++++++++++ src/lib/messaging/utils.ts | 49 ++++++- 6 files changed, 222 insertions(+), 13 deletions(-) create mode 100644 src/lib/messaging/utils.test.ts diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index 09aa5bc42b8..686cbd66ab7 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -17,6 +17,7 @@ import { createMessagingPreEnableHookInputs, getMessagingManifestAvailabilityContext, isMessagingHookConflictError, + isMessagingSupportedAgent, MessagingHostStateApplier, MessagingSetupApplier, MessagingWorkflowPlanner, @@ -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}'.`, diff --git a/src/lib/actions/sandbox/rebuild.ts b/src/lib/actions/sandbox/rebuild.ts index dbd6f023ac3..5c7a4b6cadd 100644 --- a/src/lib/actions/sandbox/rebuild.ts +++ b/src/lib/actions/sandbox/rebuild.ts @@ -50,7 +50,7 @@ import { createBuiltInChannelManifestRegistry, MessagingSetupApplier, MessagingWorkflowPlanner, - toMessagingAgentId, + tryGetMessagingAgentId, } from "../../messaging"; import { hydrateMessagingChannelConfig } from "../../messaging-channel-config"; import { getStoredMessagingChannelConfig } from "../../onboard/messaging-config"; @@ -231,10 +231,18 @@ async function stageMessagingManifestPlanForRebuild( log: (msg: string) => void, ): Promise { 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, }); diff --git a/src/lib/messaging/manifest/registry.test.ts b/src/lib/messaging/manifest/registry.test.ts index 12a2c131b3e..b7f65840a56 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 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 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"]); }); }); 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/utils.test.ts b/src/lib/messaging/utils.test.ts new file mode 100644 index 00000000000..1bf109a3059 --- /dev/null +++ b/src/lib/messaging/utils.test.ts @@ -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: [], + }); + }); +}); diff --git a/src/lib/messaging/utils.ts b/src/lib/messaging/utils.ts index 4f9d61c7ce9..bd15b58a47d 100644 --- a/src/lib/messaging/utils.ts +++ b/src/lib/messaging/utils.ts @@ -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, }; } From ae0e554b4f4118ac361f28a2028fac4df92d3dac Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Wed, 24 Jun 2026 14:01:46 +0000 Subject: [PATCH 02/12] test(messaging): enforce planner deny-all and prove rejection cannot reach rebuild Signed-off-by: Tinson Lai --- .../sandbox/policy-channel-agent-gate.test.ts | 168 ++++++++++++++++++ .../sandbox/rebuild-messaging-stage.test.ts | 79 ++++++++ src/lib/actions/sandbox/rebuild.ts | 2 +- .../compiler/workflow-planner.test.ts | 13 ++ .../messaging/compiler/workflow-planner.ts | 7 +- src/lib/messaging/utils.test.ts | 11 ++ 6 files changed, 275 insertions(+), 5 deletions(-) create mode 100644 src/lib/actions/sandbox/policy-channel-agent-gate.test.ts create mode 100644 src/lib/actions/sandbox/rebuild-messaging-stage.test.ts diff --git a/src/lib/actions/sandbox/policy-channel-agent-gate.test.ts b/src/lib/actions/sandbox/policy-channel-agent-gate.test.ts new file mode 100644 index 00000000000..a8c6c88cc19 --- /dev/null +++ b/src/lib/actions/sandbox/policy-channel-agent-gate.test.ts @@ -0,0 +1,168 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Lifecycle-boundary regression: `addSandboxChannel` must refuse non-messaging +// agents (including any agent with an explicit empty `messagingPlatforms` +// allowlist) BEFORE any preset load, policy mutation, provider upsert, registry +// write, credential prompt, or rebuild trigger. Without this gate, a +// destructive sandbox rebuild can run and fail late at Dockerfile patching. +// +// Why dist + vi.spyOn (matches policy-channel-conflict.test.ts): the source +// policy-channel.ts loads several deps via runtime CommonJS `require()`. In +// this repo's vitest setup, `vi.mock` only intercepts ESM `import`, not plain +// `require()`. We `require()` the COMPILED module + its real compiled +// dependency modules from dist/ (one shared require cache) and `vi.spyOn` +// the dependency exports. Run `npm run build:cli` first. + +import { createRequire } from "node:module"; + +import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from "vitest"; + +const requireDist = createRequire(import.meta.url); +const D = (p: string) => requireDist(`../../../../dist/lib/${p}`); + +const registry = D("state/registry.js"); +const providers = D("onboard/providers.js"); +const runtime = D("adapters/openshell/runtime.js"); +const defs = D("agent/defs.js"); +const rebuild = D("actions/sandbox/rebuild.js"); +const policy = D("policy/index.js"); +const store = D("credentials/store.js"); + +const { addSandboxChannel } = D("actions/sandbox/policy-channel.js") as { + addSandboxChannel: ( + name: string, + options?: { channel?: string; dryRun?: boolean; force?: boolean }, + ) => Promise; +}; + +let exitMock: MockInstance; +let errSpy: MockInstance; +let logSpy: MockInstance; +let getSandboxMock: MockInstance; +let upsertMock: MockInstance; +let updateSandboxMock: MockInstance; +let runOpenshellMock: MockInstance; +let applyPresetMock: MockInstance; +let loadPresetMock: MockInstance; +let saveCredentialMock: MockInstance; +let getCredentialMock: MockInstance; +let promptMock: MockInstance; +let rebuildMock: MockInstance; + +function exitCodeFromError(err: unknown): number | null { + const message = err instanceof Error ? err.message : String(err); + const match = message.match(/^process\.exit\((\d+)\)$/); + return match ? Number(match[1]) : null; +} + +beforeEach(() => { + delete process.env.NEMOCLAW_NON_INTERACTIVE; + + logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + exitMock = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`process.exit(${code})`); + }) as never); + + getSandboxMock = vi.spyOn(registry, "getSandbox").mockReturnValue({ name: "da-test" }); + updateSandboxMock = vi.spyOn(registry, "updateSandbox").mockReturnValue(true); + upsertMock = vi.spyOn(providers, "upsertMessagingProviders").mockImplementation(() => undefined); + runOpenshellMock = vi + .spyOn(runtime, "runOpenshell") + .mockReturnValue({ status: 0, stdout: "", stderr: "" }); + loadPresetMock = vi + .spyOn(policy, "loadPreset") + .mockReturnValue("network_policies:\n stub: {}\n"); + vi.spyOn(policy, "parsePresetPolicyKeys").mockReturnValue(["stub"]); + vi.spyOn(policy, "listPresets").mockReturnValue([]); + applyPresetMock = vi.spyOn(policy, "applyPreset").mockReturnValue(true); + vi.spyOn(policy, "getAppliedPresets").mockReturnValue([]); + getCredentialMock = vi.spyOn(store, "getCredential").mockReturnValue(null); + saveCredentialMock = vi.spyOn(store, "saveCredential").mockImplementation(() => undefined); + promptMock = vi.spyOn(store, "prompt").mockResolvedValue(""); + rebuildMock = vi.spyOn(rebuild, "rebuildSandbox").mockResolvedValue(undefined); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("addSandboxChannel agent gate (#5729)", () => { + it("rejects langchain-deepagents-code before any preset, mutation, provider, credential, or rebuild call", async () => { + vi.spyOn(defs, "loadAgent").mockReturnValue({ + name: "langchain-deepagents-code", + messagingPlatforms: [], + }); + + let caught: unknown; + try { + await addSandboxChannel("da-test", { channel: "discord" }); + } catch (err) { + caught = err; + } + + expect(exitCodeFromError(caught)).toBe(1); + const errorText = (errSpy.mock.calls as unknown[][]) + .map((call) => call.map(String).join(" ")) + .join("\n"); + expect(errorText).toMatch( + /Agent 'langchain-deepagents-code' does not support messaging channels/, + ); + expect(errorText).toMatch(/Messaging-capable agents: openclaw, hermes/); + + expect(loadPresetMock).not.toHaveBeenCalled(); + expect(applyPresetMock).not.toHaveBeenCalled(); + expect(upsertMock).not.toHaveBeenCalled(); + expect(updateSandboxMock).not.toHaveBeenCalled(); + expect(saveCredentialMock).not.toHaveBeenCalled(); + expect(getCredentialMock).not.toHaveBeenCalled(); + expect(promptMock).not.toHaveBeenCalled(); + expect(rebuildMock).not.toHaveBeenCalled(); + expect(runOpenshellMock).not.toHaveBeenCalled(); + }); + + it("rejects any agent with an explicit empty messagingPlatforms allowlist before any mutation", async () => { + vi.spyOn(defs, "loadAgent").mockReturnValue({ + name: "future-agent", + messagingPlatforms: [], + }); + + let caught: unknown; + try { + await addSandboxChannel("da-test", { channel: "telegram" }); + } catch (err) { + caught = err; + } + + expect(exitCodeFromError(caught)).toBe(1); + expect(loadPresetMock).not.toHaveBeenCalled(); + expect(applyPresetMock).not.toHaveBeenCalled(); + expect(upsertMock).not.toHaveBeenCalled(); + expect(updateSandboxMock).not.toHaveBeenCalled(); + expect(rebuildMock).not.toHaveBeenCalled(); + }); + + it("does not gate messaging-capable agents (openclaw flows past the agent check)", async () => { + vi.spyOn(defs, "loadAgent").mockReturnValue({ + name: "openclaw", + messagingPlatforms: ["telegram", "discord", "slack", "wechat", "whatsapp"], + }); + + let caught: unknown; + try { + await addSandboxChannel("da-test", { channel: "telegram" }); + } catch (err) { + caught = err; + } + + const errorText = (errSpy.mock.calls as unknown[][]) + .map((call) => call.map(String).join(" ")) + .join("\n"); + expect(errorText).not.toMatch(/does not support messaging channels/); + expect(loadPresetMock).toHaveBeenCalled(); + void caught; + void exitMock; + void logSpy; + }); +}); diff --git a/src/lib/actions/sandbox/rebuild-messaging-stage.test.ts b/src/lib/actions/sandbox/rebuild-messaging-stage.test.ts new file mode 100644 index 00000000000..cd047a5d39e --- /dev/null +++ b/src/lib/actions/sandbox/rebuild-messaging-stage.test.ts @@ -0,0 +1,79 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Regression: stageMessagingManifestPlanForRebuild() must clear any stale +// NEMOCLAW_MESSAGING_PLAN_B64 and skip planning for agents whose manifest +// declares no messaging support, so a non-messaging sandbox rebuild cannot +// carry messaging-plan state into the Dockerfile patch step. +// +// Loaded from dist/ to match the rest of the rebuild test suite (runner.ts +// loads './platform' via runtime CommonJS `require()` that vitest cannot +// resolve from a TS source file). Run `npm run build:cli` first. + +import { createRequire } from "node:module"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +const requireDist = createRequire(import.meta.url); +const D = (p: string) => requireDist(`../../../../dist/lib/${p}`); + +const defs = D("agent/defs.js"); +const messaging = D("messaging/index.js") as { + MessagingSetupApplier: { clearPlanEnv: () => void }; +}; +const { stageMessagingManifestPlanForRebuild } = D("actions/sandbox/rebuild.js") as { + stageMessagingManifestPlanForRebuild: ( + sandboxName: string, + sandboxEntry: unknown, + rebuildAgent: string | null, + log: (msg: string) => void, + ) => Promise; +}; + +describe("stageMessagingManifestPlanForRebuild non-messaging agent guard (#5729)", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns null and logs the skip message for langchain-deepagents-code without staging any plan", async () => { + const loadAgentSpy = vi.spyOn(defs, "loadAgent").mockReturnValue({ + name: "langchain-deepagents-code", + messagingPlatforms: [], + }); + const clearPlanEnvSpy = vi.spyOn(messaging.MessagingSetupApplier, "clearPlanEnv"); + + const messages: string[] = []; + const result = await stageMessagingManifestPlanForRebuild( + "deepagents-sandbox", + { name: "deepagents-sandbox" }, + "langchain-deepagents-code", + (msg) => messages.push(msg), + ); + + expect(loadAgentSpy).toHaveBeenCalledWith("langchain-deepagents-code"); + expect(clearPlanEnvSpy).toHaveBeenCalledTimes(1); + expect(messages).toEqual( + expect.arrayContaining([expect.stringMatching(/does not support messaging/)]), + ); + expect(result).toBeNull(); + }); + + it("skips planner output for any agent whose name is not openclaw or hermes", async () => { + vi.spyOn(defs, "loadAgent").mockReturnValue({ + name: "future-non-messaging-agent", + messagingPlatforms: [], + }); + const clearPlanEnvSpy = vi.spyOn(messaging.MessagingSetupApplier, "clearPlanEnv"); + + const messages: string[] = []; + const result = await stageMessagingManifestPlanForRebuild( + "future-sandbox", + { name: "future-sandbox" }, + "future-non-messaging-agent", + (msg) => messages.push(msg), + ); + + expect(clearPlanEnvSpy).toHaveBeenCalledTimes(1); + expect(result).toBeNull(); + }); +}); diff --git a/src/lib/actions/sandbox/rebuild.ts b/src/lib/actions/sandbox/rebuild.ts index 5c7a4b6cadd..191afd90f4f 100644 --- a/src/lib/actions/sandbox/rebuild.ts +++ b/src/lib/actions/sandbox/rebuild.ts @@ -224,7 +224,7 @@ function preflightHermesProviderCredentials( return false; } -async function stageMessagingManifestPlanForRebuild( +export async function stageMessagingManifestPlanForRebuild( sandboxName: string, sandboxEntry: registry.SandboxEntry, rebuildAgent: string | null, diff --git a/src/lib/messaging/compiler/workflow-planner.test.ts b/src/lib/messaging/compiler/workflow-planner.test.ts index 04440f38398..1a9daf6e37f 100644 --- a/src/lib/messaging/compiler/workflow-planner.test.ts +++ b/src/lib/messaging/compiler/workflow-planner.test.ts @@ -783,6 +783,19 @@ describe("MessagingWorkflowPlanner", () => { ).rejects.toThrow("Unsupported messaging channel(s) for openclaw: discord, slack"); }); + it("rejects every configured channel when supportedChannelIds is an explicit empty allowlist", 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..869cb083ce9 100644 --- a/src/lib/messaging/compiler/workflow-planner.ts +++ b/src/lib/messaging/compiler/workflow-planner.ts @@ -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() diff --git a/src/lib/messaging/utils.test.ts b/src/lib/messaging/utils.test.ts index 1bf109a3059..aa782624248 100644 --- a/src/lib/messaging/utils.test.ts +++ b/src/lib/messaging/utils.test.ts @@ -94,6 +94,17 @@ describe("isMessagingSupportedAgent", () => { }); describe("getMessagingManifestAvailabilityContext", () => { + it("returns a null agent when no agent is provided (default-agent caller path)", () => { + expect(getMessagingManifestAvailabilityContext(null)).toEqual({ + agent: null, + supportedChannelIds: null, + }); + expect(getMessagingManifestAvailabilityContext(undefined)).toEqual({ + agent: null, + supportedChannelIds: null, + }); + }); + it("returns the resolved messaging agent id and an explicit allowlist when present", () => { expect( getMessagingManifestAvailabilityContext({ From 95973810d15fc0394006aa99983b70c97464fe93 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Wed, 24 Jun 2026 14:22:49 +0000 Subject: [PATCH 03/12] fix(messaging): clear rebuild plan for known agents with empty messaging allowlist Signed-off-by: Tinson Lai --- src/lib/actions/sandbox/rebuild-flow.test.ts | 6 +- .../sandbox/rebuild-messaging-stage.test.ts | 56 ++++++++++++++++++- src/lib/actions/sandbox/rebuild.ts | 3 +- 3 files changed, 62 insertions(+), 3 deletions(-) diff --git a/src/lib/actions/sandbox/rebuild-flow.test.ts b/src/lib/actions/sandbox/rebuild-flow.test.ts index f68c09b90b1..c09368ead15 100644 --- a/src/lib/actions/sandbox/rebuild-flow.test.ts +++ b/src/lib/actions/sandbox/rebuild-flow.test.ts @@ -80,7 +80,11 @@ function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): Rebuild hermesToolGateways: [], }; const rebuildShieldsWindow = { relocked: false, wasLocked: false }; - const agentDef = { name: "openclaw", expectedVersion: "0.2.0", messagingPlatforms: [] }; + const agentDef = { + name: "openclaw", + expectedVersion: "0.2.0", + messagingPlatforms: ["telegram", "discord", "slack", "wechat", "whatsapp"], + }; vi.spyOn(gatewayDrift, "detectOpenShellStateRpcPreflightIssue").mockReturnValue(null); vi.spyOn(gatewayDrift, "detectOpenShellStateRpcResultIssue").mockReturnValue(null); diff --git a/src/lib/actions/sandbox/rebuild-messaging-stage.test.ts b/src/lib/actions/sandbox/rebuild-messaging-stage.test.ts index cd047a5d39e..9da97b0de87 100644 --- a/src/lib/actions/sandbox/rebuild-messaging-stage.test.ts +++ b/src/lib/actions/sandbox/rebuild-messaging-stage.test.ts @@ -19,7 +19,7 @@ const D = (p: string) => requireDist(`../../../../dist/lib/${p}`); const defs = D("agent/defs.js"); const messaging = D("messaging/index.js") as { - MessagingSetupApplier: { clearPlanEnv: () => void }; + MessagingSetupApplier: { clearPlanEnv: () => void; writePlanToEnv: (plan: unknown) => void }; }; const { stageMessagingManifestPlanForRebuild } = D("actions/sandbox/rebuild.js") as { stageMessagingManifestPlanForRebuild: ( @@ -76,4 +76,58 @@ describe("stageMessagingManifestPlanForRebuild non-messaging agent guard (#5729) expect(clearPlanEnvSpy).toHaveBeenCalledTimes(1); expect(result).toBeNull(); }); + + it("skips planner output for a known agent whose messagingPlatforms is an explicit empty allowlist, even with a stored plan", async () => { + vi.spyOn(defs, "loadAgent").mockReturnValue({ + name: "openclaw", + messagingPlatforms: [], + }); + const clearPlanEnvSpy = vi.spyOn(messaging.MessagingSetupApplier, "clearPlanEnv"); + const writePlanEnvSpy = vi.spyOn(messaging.MessagingSetupApplier, "writePlanToEnv"); + + const sandboxEntryWithStoredPlan = { + name: "openclaw-sandbox", + messaging: { + schemaVersion: 1, + plan: { + schemaVersion: 1, + sandboxName: "openclaw-sandbox", + agent: "openclaw", + workflow: "rebuild", + channels: [ + { + channelId: "telegram", + displayName: "telegram", + authMode: "token-paste", + active: true, + selected: true, + configured: true, + disabled: false, + inputs: [], + hooks: [], + }, + ], + disabledChannels: [], + credentialBindings: [], + networkPolicy: { presets: [], entries: [] }, + agentRender: [], + buildSteps: [], + stateUpdates: [], + healthChecks: [], + }, + }, + }; + + const messages: string[] = []; + const result = await stageMessagingManifestPlanForRebuild( + "openclaw-sandbox", + sandboxEntryWithStoredPlan, + "openclaw", + (msg) => messages.push(msg), + ); + + expect(clearPlanEnvSpy).toHaveBeenCalledTimes(1); + expect(writePlanEnvSpy).not.toHaveBeenCalled(); + expect(result).toBeNull(); + }); }); diff --git a/src/lib/actions/sandbox/rebuild.ts b/src/lib/actions/sandbox/rebuild.ts index 191afd90f4f..f8c72435daf 100644 --- a/src/lib/actions/sandbox/rebuild.ts +++ b/src/lib/actions/sandbox/rebuild.ts @@ -48,6 +48,7 @@ import type { } from "../../messaging"; import { createBuiltInChannelManifestRegistry, + isMessagingSupportedAgent, MessagingSetupApplier, MessagingWorkflowPlanner, tryGetMessagingAgentId, @@ -232,7 +233,7 @@ export async function stageMessagingManifestPlanForRebuild( ): Promise { const agent = loadAgent(rebuildAgent || "openclaw"); const agentId = tryGetMessagingAgentId(agent); - if (agentId === null) { + if (agentId === null || !isMessagingSupportedAgent(agent)) { MessagingSetupApplier.clearPlanEnv(); log( `Messaging manifest rebuild plan skipped: agent '${agent.name}' does not support messaging`, From 26367911af18dd6fa84d41d18503aa20a90dddab Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Wed, 24 Jun 2026 14:59:42 +0000 Subject: [PATCH 04/12] fix(messaging): drop stored channels outside supportedChannelIds on rebuild Signed-off-by: Tinson Lai --- .../compiler/workflow-planner.test.ts | 53 +++++++++++++++++++ .../messaging/compiler/workflow-planner.ts | 37 +++++++++---- 2 files changed, 80 insertions(+), 10 deletions(-) diff --git a/src/lib/messaging/compiler/workflow-planner.test.ts b/src/lib/messaging/compiler/workflow-planner.test.ts index 1a9daf6e37f..9be3d316cef 100644 --- a/src/lib/messaging/compiler/workflow-planner.test.ts +++ b/src/lib/messaging/compiler/workflow-planner.test.ts @@ -770,6 +770,59 @@ describe("MessagingWorkflowPlanner", () => { expect(rebuilt).toBeNull(); }); + it("drops stored channels that fall outside the current supportedChannelIds allowlist on rebuild", async () => { + const existingPlan = await planner().buildPlan({ + sandboxName: "demo", + agent: "openclaw", + workflow: "onboard", + isInteractive: false, + configuredChannels: ["telegram", "slack"], + credentialAvailability: { + TELEGRAM_BOT_TOKEN: true, + SLACK_BOT_TOKEN: true, + SLACK_APP_TOKEN: true, + }, + }); + + const rebuilt = await planner().buildRebuildPlanFromSandboxEntry({ + sandboxName: "demo", + agent: "openclaw", + sandboxEntry: { + name: "demo", + messaging: { schemaVersion: 1, plan: existingPlan }, + }, + supportedChannelIds: ["telegram"], + }); + + expect(rebuilt?.channels.map((channel) => channel.channelId)).toEqual(["telegram"]); + expect(rebuilt?.credentialBindings.some((binding) => binding.channelId === "slack")).toBe(false); + expect(rebuilt?.networkPolicy.entries.some((entry) => entry.channelId === "slack")).toBe(false); + expect(rebuilt?.agentRender.some((entry) => entry.channelId === "slack")).toBe(false); + }); + + it("returns null on rebuild when every stored channel falls outside the current allowlist", 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("reports unsupported channels deterministically before compiling", async () => { await expect( planner().buildPlan({ diff --git a/src/lib/messaging/compiler/workflow-planner.ts b/src/lib/messaging/compiler/workflow-planner.ts index 869cb083ce9..7ad0e900319 100644 --- a/src/lib/messaging/compiler/workflow-planner.ts +++ b/src/lib/messaging/compiler/workflow-planner.ts @@ -114,17 +114,34 @@ export class MessagingWorkflowPlanner { context: MessagingWorkflowPlannerSandboxRebuildContext, ): Promise { const existingPlan = readSandboxEntryPlan(context); - if (existingPlan) { - return refreshRuntimeSetup( - setPlanDisabledChannels( - existingPlan, - disabledChannelsFromSandboxEntry(context.sandboxEntry, existingPlan), - "rebuild", - ), - this.registry, - ); + if (!existingPlan) return null; + + const filteredPlan = this.filterPlanChannelsToSupportedAllowlist(existingPlan, context); + if (!filteredPlan || filteredPlan.channels.length === 0) return null; + + return refreshRuntimeSetup( + setPlanDisabledChannels( + filteredPlan, + disabledChannelsFromSandboxEntry(context.sandboxEntry, filteredPlan), + "rebuild", + ), + this.registry, + ); + } + + private filterPlanChannelsToSupportedAllowlist( + plan: SandboxMessagingPlan, + context: Pick, + ): SandboxMessagingPlan | null { + if (!Array.isArray(context.supportedChannelIds)) return plan; + const allowlist = new Set(context.supportedChannelIds); + let filtered = plan; + for (const channel of plan.channels) { + if (!allowlist.has(channel.channelId)) { + filtered = removePlanChannel(filtered, channel.channelId, "rebuild"); + } } - return null; + return filtered; } private assertSupportedChannels( From aac70e7ef4ae78d71dea717162652bcc40fbf3bc Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Wed, 24 Jun 2026 15:25:35 +0000 Subject: [PATCH 05/12] fix(messaging): allow stale plan cleanup on non-messaging agents without throwing Signed-off-by: Tinson Lai --- .../sandbox/policy-channel-cleanup.test.ts | 134 ++++++++++++++++++ src/lib/actions/sandbox/policy-channel.ts | 18 ++- .../compiler/workflow-planner.test.ts | 4 +- 3 files changed, 151 insertions(+), 5 deletions(-) create mode 100644 src/lib/actions/sandbox/policy-channel-cleanup.test.ts diff --git a/src/lib/actions/sandbox/policy-channel-cleanup.test.ts b/src/lib/actions/sandbox/policy-channel-cleanup.test.ts new file mode 100644 index 00000000000..94df7f0b473 --- /dev/null +++ b/src/lib/actions/sandbox/policy-channel-cleanup.test.ts @@ -0,0 +1,134 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Cleanup-path regression: with the new strict `toMessagingAgentId` semantics, +// stale messaging state on a non-messaging agent must still be cleanable +// without raising MessagingAgentNotSupportedError. `channels remove` should +// strip the stored messaging plan from the registry, `channels pause/resume` +// should fail closed (no throw, no plan mutation). + +import { createRequire } from "node:module"; + +import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from "vitest"; + +const requireDist = createRequire(import.meta.url); +const D = (p: string) => requireDist(`../../../../dist/lib/${p}`); + +const registry = D("state/registry.js"); +const defs = D("agent/defs.js"); + +const { + persistManifestChannelDisabledPlan, + persistManifestChannelRemovePlan, +} = D("actions/sandbox/policy-channel.js") as { + persistManifestChannelDisabledPlan: ( + sandboxName: string, + channelId: string, + disabled: boolean, + ) => Promise; + persistManifestChannelRemovePlan: (sandboxName: string, channelId: string) => Promise; +}; + +let getSandboxMock: MockInstance; +let updateSandboxMock: MockInstance; + +function entryWithStalePlan(sandboxName: string, channelId: string) { + return { + name: sandboxName, + agent: "langchain-deepagents-code", + messaging: { + schemaVersion: 1, + plan: { + schemaVersion: 1, + sandboxName, + agent: "openclaw", + workflow: "rebuild", + channels: [ + { + channelId, + displayName: channelId, + authMode: "token-paste", + active: true, + selected: true, + configured: true, + disabled: false, + inputs: [], + hooks: [], + }, + ], + disabledChannels: [], + credentialBindings: [], + networkPolicy: { presets: [], entries: [] }, + agentRender: [], + buildSteps: [], + stateUpdates: [], + healthChecks: [], + }, + }, + }; +} + +beforeEach(() => { + getSandboxMock = vi.spyOn(registry, "getSandbox"); + updateSandboxMock = vi.spyOn(registry, "updateSandbox").mockReturnValue(true); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("persistManifestChannelRemovePlan with non-messaging agent (#5729)", () => { + it("strips stale messaging state from the registry without throwing", async () => { + vi.spyOn(defs, "loadAgent").mockReturnValue({ + name: "langchain-deepagents-code", + messagingPlatforms: [], + }); + getSandboxMock.mockReturnValue(entryWithStalePlan("da-test", "discord")); + + const result = await persistManifestChannelRemovePlan("da-test", "discord"); + + expect(result).toBe(true); + expect(updateSandboxMock).toHaveBeenCalledWith("da-test", { messaging: undefined }); + }); + + it("returns true and skips registry update when no stale plan exists", async () => { + vi.spyOn(defs, "loadAgent").mockReturnValue({ + name: "langchain-deepagents-code", + messagingPlatforms: [], + }); + getSandboxMock.mockReturnValue({ name: "da-test", agent: "langchain-deepagents-code" }); + + const result = await persistManifestChannelRemovePlan("da-test", "discord"); + + expect(result).toBe(true); + expect(updateSandboxMock).not.toHaveBeenCalled(); + }); +}); + +describe("persistManifestChannelDisabledPlan with non-messaging agent (#5729)", () => { + it("returns false without throwing or mutating the registry when the agent does not support messaging", async () => { + vi.spyOn(defs, "loadAgent").mockReturnValue({ + name: "langchain-deepagents-code", + messagingPlatforms: [], + }); + getSandboxMock.mockReturnValue(entryWithStalePlan("da-test", "discord")); + + const result = await persistManifestChannelDisabledPlan("da-test", "discord", true); + + expect(result).toBe(false); + expect(updateSandboxMock).not.toHaveBeenCalled(); + }); + + it("returns false without throwing when there is no stored messaging plan", async () => { + vi.spyOn(defs, "loadAgent").mockReturnValue({ + name: "langchain-deepagents-code", + messagingPlatforms: [], + }); + getSandboxMock.mockReturnValue({ name: "da-test", agent: "langchain-deepagents-code" }); + + const result = await persistManifestChannelDisabledPlan("da-test", "discord", true); + + expect(result).toBe(false); + expect(updateSandboxMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index 686cbd66ab7..9227a7e52d0 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -25,6 +25,7 @@ import { type SandboxMessagingChannelPlan, type SandboxMessagingPlan, toMessagingAgentId, + tryGetMessagingAgentId, } from "../../messaging"; import { hydrateMessagingChannelConfig } from "../../messaging-channel-config"; import { hashCredential } from "../../security/credential-hash"; @@ -756,7 +757,7 @@ async function planSandboxChannelAdd( } } -async function persistManifestChannelDisabledPlan( +export async function persistManifestChannelDisabledPlan( sandboxName: string, channelId: string, disabled: boolean, @@ -764,10 +765,12 @@ async function persistManifestChannelDisabledPlan( const entry = registry.getSandbox(sandboxName); if (!entry?.messaging?.plan) return false; const agent = resolveAgentForSandbox(sandboxName); + const agentId = tryGetMessagingAgentId(agent); + if (agentId === null) return false; const planner = new MessagingWorkflowPlanner(messagingManifestRegistry); const context = { sandboxName, - agent: toMessagingAgentId(agent), + agent: agentId, channelId, sandboxEntry: entry, supportedChannelIds: availableManifestChannelsForAgent(agent).map((manifest) => manifest.id), @@ -778,17 +781,24 @@ async function persistManifestChannelDisabledPlan( return plan ? MessagingHostStateApplier.applyPlanToRegistry(sandboxName, plan) : false; } -async function persistManifestChannelRemovePlan( +export async function persistManifestChannelRemovePlan( sandboxName: string, channelId: string, ): Promise { const entry = registry.getSandbox(sandboxName); if (!entry) return false; const agent = resolveAgentForSandbox(sandboxName); + const agentId = tryGetMessagingAgentId(agent); + if (agentId === null) { + if (entry.messaging?.plan) { + return registry.updateSandbox(sandboxName, { messaging: undefined }); + } + return true; + } const planner = new MessagingWorkflowPlanner(messagingManifestRegistry); const plan = await planner.buildChannelRemovePlanFromSandboxEntry({ sandboxName, - agent: toMessagingAgentId(agent), + agent: agentId, channelId, sandboxEntry: entry, supportedChannelIds: availableManifestChannelsForAgent(agent).map((manifest) => manifest.id), diff --git a/src/lib/messaging/compiler/workflow-planner.test.ts b/src/lib/messaging/compiler/workflow-planner.test.ts index 9be3d316cef..1ea7d854365 100644 --- a/src/lib/messaging/compiler/workflow-planner.test.ts +++ b/src/lib/messaging/compiler/workflow-planner.test.ts @@ -795,7 +795,9 @@ describe("MessagingWorkflowPlanner", () => { }); expect(rebuilt?.channels.map((channel) => channel.channelId)).toEqual(["telegram"]); - expect(rebuilt?.credentialBindings.some((binding) => binding.channelId === "slack")).toBe(false); + expect(rebuilt?.credentialBindings.some((binding) => binding.channelId === "slack")).toBe( + false, + ); expect(rebuilt?.networkPolicy.entries.some((entry) => entry.channelId === "slack")).toBe(false); expect(rebuilt?.agentRender.some((entry) => entry.channelId === "slack")).toBe(false); }); From b9b48bb26636dfb79f0340071e0cac70462c6fda Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Wed, 24 Jun 2026 15:41:59 +0000 Subject: [PATCH 06/12] fix(messaging): differentiate unknown-runtime vs empty-allowlist skip on rebuild Signed-off-by: Tinson Lai --- .../sandbox/policy-channel-cleanup.test.ts | 7 +++---- .../sandbox/rebuild-messaging-stage.test.ts | 20 ++++++++++++++----- src/lib/actions/sandbox/rebuild.ts | 11 ++++++++-- 3 files changed, 27 insertions(+), 11 deletions(-) diff --git a/src/lib/actions/sandbox/policy-channel-cleanup.test.ts b/src/lib/actions/sandbox/policy-channel-cleanup.test.ts index 94df7f0b473..58ae9a17889 100644 --- a/src/lib/actions/sandbox/policy-channel-cleanup.test.ts +++ b/src/lib/actions/sandbox/policy-channel-cleanup.test.ts @@ -17,10 +17,9 @@ const D = (p: string) => requireDist(`../../../../dist/lib/${p}`); const registry = D("state/registry.js"); const defs = D("agent/defs.js"); -const { - persistManifestChannelDisabledPlan, - persistManifestChannelRemovePlan, -} = D("actions/sandbox/policy-channel.js") as { +const { persistManifestChannelDisabledPlan, persistManifestChannelRemovePlan } = D( + "actions/sandbox/policy-channel.js", +) as { persistManifestChannelDisabledPlan: ( sandboxName: string, channelId: string, diff --git a/src/lib/actions/sandbox/rebuild-messaging-stage.test.ts b/src/lib/actions/sandbox/rebuild-messaging-stage.test.ts index 9da97b0de87..59089176fc3 100644 --- a/src/lib/actions/sandbox/rebuild-messaging-stage.test.ts +++ b/src/lib/actions/sandbox/rebuild-messaging-stage.test.ts @@ -35,7 +35,7 @@ describe("stageMessagingManifestPlanForRebuild non-messaging agent guard (#5729) vi.restoreAllMocks(); }); - it("returns null and logs the skip message for langchain-deepagents-code without staging any plan", async () => { + it("emits the unknown-runtime skip message for langchain-deepagents-code without staging any plan", async () => { const loadAgentSpy = vi.spyOn(defs, "loadAgent").mockReturnValue({ name: "langchain-deepagents-code", messagingPlatforms: [], @@ -52,13 +52,16 @@ describe("stageMessagingManifestPlanForRebuild non-messaging agent guard (#5729) expect(loadAgentSpy).toHaveBeenCalledWith("langchain-deepagents-code"); expect(clearPlanEnvSpy).toHaveBeenCalledTimes(1); - expect(messages).toEqual( - expect.arrayContaining([expect.stringMatching(/does not support messaging/)]), + expect(messages).toContain( + "Messaging manifest rebuild plan skipped: agent 'langchain-deepagents-code' is not a messaging-capable runtime", + ); + expect(messages.some((msg) => msg.includes("declares no supported messaging channels"))).toBe( + false, ); expect(result).toBeNull(); }); - it("skips planner output for any agent whose name is not openclaw or hermes", async () => { + it("emits the unknown-runtime skip message for any agent whose name is not openclaw or hermes", async () => { vi.spyOn(defs, "loadAgent").mockReturnValue({ name: "future-non-messaging-agent", messagingPlatforms: [], @@ -74,10 +77,13 @@ describe("stageMessagingManifestPlanForRebuild non-messaging agent guard (#5729) ); expect(clearPlanEnvSpy).toHaveBeenCalledTimes(1); + expect(messages).toContain( + "Messaging manifest rebuild plan skipped: agent 'future-non-messaging-agent' is not a messaging-capable runtime", + ); expect(result).toBeNull(); }); - it("skips planner output for a known agent whose messagingPlatforms is an explicit empty allowlist, even with a stored plan", async () => { + it("emits the empty-allowlist skip message for a known agent whose messagingPlatforms is an explicit empty allowlist", async () => { vi.spyOn(defs, "loadAgent").mockReturnValue({ name: "openclaw", messagingPlatforms: [], @@ -128,6 +134,10 @@ describe("stageMessagingManifestPlanForRebuild non-messaging agent guard (#5729) expect(clearPlanEnvSpy).toHaveBeenCalledTimes(1); expect(writePlanEnvSpy).not.toHaveBeenCalled(); + expect(messages).toContain( + "Messaging manifest rebuild plan skipped: agent 'openclaw' declares no supported messaging channels", + ); + expect(messages.some((msg) => msg.includes("is not a messaging-capable runtime"))).toBe(false); expect(result).toBeNull(); }); }); diff --git a/src/lib/actions/sandbox/rebuild.ts b/src/lib/actions/sandbox/rebuild.ts index f8c72435daf..0319ba9ed08 100644 --- a/src/lib/actions/sandbox/rebuild.ts +++ b/src/lib/actions/sandbox/rebuild.ts @@ -233,10 +233,17 @@ export async function stageMessagingManifestPlanForRebuild( ): Promise { const agent = loadAgent(rebuildAgent || "openclaw"); const agentId = tryGetMessagingAgentId(agent); - if (agentId === null || !isMessagingSupportedAgent(agent)) { + if (agentId === null) { MessagingSetupApplier.clearPlanEnv(); log( - `Messaging manifest rebuild plan skipped: agent '${agent.name}' does not support messaging`, + `Messaging manifest rebuild plan skipped: agent '${agent.name}' is not a messaging-capable runtime`, + ); + return null; + } + if (!isMessagingSupportedAgent(agent)) { + MessagingSetupApplier.clearPlanEnv(); + log( + `Messaging manifest rebuild plan skipped: agent '${agent.name}' declares no supported messaging channels`, ); return null; } From e69ebf88e8483ef59c111d144efedbdc3f9c6544 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Wed, 24 Jun 2026 16:13:51 +0000 Subject: [PATCH 07/12] docs(messaging): record agent gating invariant + behaviour-level rejection test Signed-off-by: Tinson Lai --- src/lib/messaging/AGENTS.md | 8 + .../channels-add-deepagents-rejection.test.ts | 192 ++++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 test/channels-add-deepagents-rejection.test.ts diff --git a/src/lib/messaging/AGENTS.md b/src/lib/messaging/AGENTS.md index d50cade365b..2bed86085ba 100644 --- a/src/lib/messaging/AGENTS.md +++ b/src/lib/messaging/AGENTS.md @@ -82,3 +82,11 @@ Mock external messaging APIs. Do not call real Telegram, Discord, Slack, WeChat, User-facing behavior changes usually need docs under `docs/manage-sandboxes/messaging-channels.mdx` or `docs/reference/commands.mdx`. Update `.agents/skills/nemoclaw-user-guide/SKILL.md` only when AI-agent docs routing guidance changes. + +## Agent Gating and Stale Plan Cleanup + +- **Invalid state.** A sandbox can be configured with an agent whose manifest declares no messaging support (`messaging_platforms.supported: []`) or an agent name outside the messaging runtime allowlist. Without an explicit gate the channel-add path can still tear down the sandbox before failing at `dockerfile-patch.ts`, and a rebuild can carry a stale `NEMOCLAW_MESSAGING_PLAN_B64` into the Dockerfile patch step for an agent that does not declare the matching `ARG`. +- **Source boundary.** The agent manifest's `messaging_platforms.supported` list is the single source of truth for whether a given agent supports messaging today, and which channels are available for it. The `MessagingAgentId` union in `applier/build/messaging-build-applier.mts` is the runtime allowlist for build-time messaging integration. Both are checked at the action boundary by `isMessagingSupportedAgent(agent)` and `tryGetMessagingAgentId(agent)` in `utils.ts`. The `ChannelManifestRegistry.listAvailable` and `MessagingWorkflowPlanner.supportedChannelIds` paths share these semantics, so an explicit empty allowlist means deny-all everywhere, and a populated allowlist filters to that subset. +- **Source-fix constraint.** Expanding `messaging_platforms.supported` for an agent requires the matching Dockerfile `ARG NEMOCLAW_MESSAGING_PLAN_B64=`, a `MessagingAgentId` entry, per-channel `supportedAgents`, and agent-side render and hook handlers in `applier/build/messaging-build-applier.mts`. Until that stack lands, the gate at the action boundary is the only safe behavior — surfacing the unsupported-agent message in `addSandboxChannel`, clearing the staged plan in `stageMessagingManifestPlanForRebuild`, and stripping stale plans in `persistManifestChannelRemovePlan`. +- **Regression tests.** `src/lib/messaging/utils.test.ts`, `src/lib/messaging/manifest/registry.test.ts`, and `src/lib/messaging/compiler/workflow-planner.test.ts` lock the helper and registry semantics. `src/lib/actions/sandbox/policy-channel-agent-gate.test.ts`, `src/lib/actions/sandbox/policy-channel-cleanup.test.ts`, and `src/lib/actions/sandbox/rebuild-messaging-stage.test.ts` cover the action and rebuild boundaries against the compiled `dist/` modules. `test/channels-add-deepagents-rejection.test.ts` exercises the full `addSandboxChannel` boundary in a spawned Node process to prove no policy, provider, registry, credential, or rebuild call happens before the unsupported-agent exit. +- **Removal condition.** Drop the gate (or shrink it back to known runtimes only) once the targeted agent has a Dockerfile `ARG NEMOCLAW_MESSAGING_PLAN_B64=`, a `MessagingAgentId` entry, populated `messaging_platforms.supported`, the channel manifests list it under `supportedAgents`, and `applier/build/messaging-build-applier.mts` resolves its render and runtime targets. At that point the empty-allowlist branch becomes unreachable for that agent and the action boundary can rely on planner-level validation alone. diff --git a/test/channels-add-deepagents-rejection.test.ts b/test/channels-add-deepagents-rejection.test.ts new file mode 100644 index 00000000000..8add861c12e --- /dev/null +++ b/test/channels-add-deepagents-rejection.test.ts @@ -0,0 +1,192 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Behaviour-level regression for #5729. `nemoclaw channels add ` +// on an agent whose manifest declares no messaging support must exit nonzero +// before any preset load, policy mutation, provider upsert, registry write, +// credential save, prompt, rebuild call, or openshell invocation. +// +// Spawns the assembled `addSandboxChannel` action in a real Node process so +// the entire module graph loads, then asserts the no-mutation invariant from +// the public action boundary rather than from a unit-mocked seam. + +import assert from "node:assert/strict"; +import { type SpawnSyncReturns, spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, it } from "vitest"; + +const repoRoot = path.join(import.meta.dirname, ".."); + +function runScript(scriptBody: string): SpawnSyncReturns { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-5729-")); + const scriptPath = path.join(tmpDir, "script.js"); + fs.writeFileSync(scriptPath, scriptBody); + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + NEMOCLAW_NON_INTERACTIVE: "1", + }, + timeout: 15000, + }); + fs.rmSync(tmpDir, { recursive: true, force: true }); + return result; +} + +function parseResultPayload>( + result: SpawnSyncReturns, +): T { + const marker = result.stdout.lastIndexOf("__RESULT__"); + assert.ok(marker >= 0, `no __RESULT__ marker in stdout:\n${result.stdout}\n---stderr---\n${result.stderr}`); + return JSON.parse(result.stdout.slice(marker + "__RESULT__".length).trim()) as T; +} + +function buildPreamble(agentName: string): string { + const d = (p: string) => JSON.stringify(path.join(repoRoot, "dist", "lib", p)); + return String.raw` +const resolver = require(${d("adapters/openshell/resolve.js")}); +resolver.resolveOpenshell = () => "/fake/openshell"; + +const openshellRuntime = require(${d("adapters/openshell/runtime.js")}); +const runOpenshellCalls = []; +openshellRuntime.runOpenshell = (...args) => { + runOpenshellCalls.push(args); + return { status: 0, stdout: "", stderr: "" }; +}; + +const processRecovery = require(${d("actions/sandbox/process-recovery.js")}); +processRecovery.executeSandboxExecCommand = () => null; +processRecovery.executeSandboxCommand = () => null; + +const gatewayRuntime = require(${d("gateway-runtime-action.js")}); +gatewayRuntime.recoverNamedGatewayRuntime = async () => ({ recovered: true }); + +const credentials = require(${d("credentials/store.js")}); +const credentialCalls = { get: [], save: [], delete: [], prompt: [] }; +credentials.getCredential = (key) => { credentialCalls.get.push(key); return null; }; +credentials.saveCredential = (key, value) => { credentialCalls.save.push({ key, value }); return true; }; +credentials.deleteCredential = (key) => { credentialCalls.delete.push(key); return true; }; +credentials.prompt = async (msg) => { credentialCalls.prompt.push(msg); return ""; }; + +const onboard = require(${d("onboard.js")}); +onboard.isNonInteractive = () => true; + +const onboardProviders = require(${d("onboard/providers.js")}); +const providerCalls = []; +onboardProviders.upsertMessagingProviders = (defs) => { providerCalls.push(...defs); }; + +const registry = require(${d("state/registry.js")}); +const registryUpdates = []; +registry.getSandbox = () => ({ name: "test-sb", agent: ${JSON.stringify(agentName)} }); +registry.updateSandbox = (name, updates) => { registryUpdates.push({ name, updates }); return true; }; + +const policies = require(${d("policy/index.js")}); +const policyCalls = { loadPreset: [], applyPreset: [] }; +policies.listPresets = () => []; +policies.loadPreset = (name) => { policyCalls.loadPreset.push(name); return "network_policies:\n stub: {}\n"; }; +policies.parsePresetPolicyKeys = () => ["stub"]; +policies.applyPreset = (name, preset) => { policyCalls.applyPreset.push({ name, preset }); return true; }; +policies.getAppliedPresets = () => []; + +const rebuild = require(${d("actions/sandbox/rebuild.js")}); +const rebuildCalls = []; +rebuild.rebuildSandbox = async (name, args, opts) => { rebuildCalls.push({ name, args, opts }); }; + +const agentDefs = require(${d("agent/defs.js")}); +agentDefs.loadAgent = () => ({ + name: ${JSON.stringify(agentName)}, + messagingPlatforms: [], +}); + +const channelModule = require(${d("actions/sandbox/policy-channel.js")}); + +let exitCode = null; +const originalExit = process.exit; +process.exit = (code) => { exitCode = code; throw new Error("__INTERCEPTED_EXIT__:" + code); }; + +const errors = []; +const origErr = console.error; +console.error = (...args) => { errors.push(args.map(String).join(" ")); }; + +module.exports = { + channelModule, + policyCalls, + providerCalls, + registryUpdates, + rebuildCalls, + credentialCalls, + runOpenshellCalls, + errors, + getExitCode: () => exitCode, +}; +`; +} + +describe("addSandboxChannel agent gate (behaviour, #5729)", () => { + it("DeepAgents channels add discord exits non-mutatingly with the unsupported-agent message", () => { + const script = `${buildPreamble("langchain-deepagents-code")} +const ctx = module.exports; +(async () => { + let caught = null; + try { + await ctx.channelModule.addSandboxChannel("test-sb", { channel: "discord" }); + } catch (err) { + if (!String(err && err.message).startsWith("__INTERCEPTED_EXIT__")) { + caught = { message: String(err && err.message), stack: err && err.stack }; + } + } + process.stdout.write("\\n__RESULT__" + JSON.stringify({ + exitCode: ctx.getExitCode(), + errors: ctx.errors, + policyCalls: ctx.policyCalls, + providerCalls: ctx.providerCalls, + registryUpdates: ctx.registryUpdates, + rebuildCalls: ctx.rebuildCalls, + credentialCalls: ctx.credentialCalls, + runOpenshellCalls: ctx.runOpenshellCalls, + unexpectedError: caught, + }) + "\\n"); +})(); +`; + const result = runScript(script); + assert.equal(result.status, 0, `script crashed: ${result.stderr}\n${result.stdout}`); + const payload = parseResultPayload<{ + exitCode: number; + errors: string[]; + policyCalls: { loadPreset: string[]; applyPreset: unknown[] }; + providerCalls: unknown[]; + registryUpdates: unknown[]; + rebuildCalls: unknown[]; + credentialCalls: { get: string[]; save: unknown[]; delete: string[]; prompt: string[] }; + runOpenshellCalls: unknown[]; + unexpectedError: { message: string; stack: string } | null; + }>(result); + + assert.equal(payload.unexpectedError, null, `unexpected exception: ${payload.unexpectedError?.stack}`); + assert.equal(payload.exitCode, 1, "expected addSandboxChannel to exit with code 1"); + assert.ok( + payload.errors.some((msg) => + /Agent 'langchain-deepagents-code' does not support messaging channels/.test(msg), + ), + `missing unsupported-agent error in stderr: ${JSON.stringify(payload.errors)}`, + ); + assert.ok( + payload.errors.some((msg) => /Messaging-capable agents: openclaw, hermes/.test(msg)), + `missing supported-agents hint in stderr: ${JSON.stringify(payload.errors)}`, + ); + + assert.deepEqual(payload.policyCalls.loadPreset, [], "loadPreset must not run before the gate"); + assert.deepEqual(payload.policyCalls.applyPreset, [], "applyPreset must not run before the gate"); + assert.deepEqual(payload.providerCalls, [], "upsertMessagingProviders must not run before the gate"); + assert.deepEqual(payload.registryUpdates, [], "updateSandbox must not run before the gate"); + assert.deepEqual(payload.rebuildCalls, [], "rebuildSandbox must not run before the gate"); + assert.deepEqual(payload.credentialCalls.save, [], "saveCredential must not run before the gate"); + assert.deepEqual(payload.credentialCalls.delete, [], "deleteCredential must not run before the gate"); + assert.deepEqual(payload.credentialCalls.prompt, [], "prompt must not run before the gate"); + assert.deepEqual(payload.runOpenshellCalls, [], "openshell must not be invoked before the gate"); + }); +}); From b1e88c738ced341f0637305ff26a0177c35d79c6 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Wed, 24 Jun 2026 17:26:28 +0000 Subject: [PATCH 08/12] feat(messaging): allow DeepAgents agent on messaging runtime allowlist Signed-off-by: Tinson Lai --- agents/langchain-deepagents-code/Dockerfile | 2 ++ .../langchain-deepagents-code/manifest.yaml | 5 ++- .../sandbox/policy-channel-agent-gate.test.ts | 21 +++++------ .../sandbox/policy-channel-cleanup.test.ts | 14 ++++---- src/lib/actions/sandbox/policy-channel.ts | 2 +- .../sandbox/rebuild-messaging-stage.test.ts | 34 ++++-------------- src/lib/messaging/AGENTS.md | 4 +-- .../applier/build/messaging-build-applier.mts | 32 +++++++++++++---- .../messaging/channels/discord/manifest.ts | 2 +- src/lib/messaging/channels/manifests.test.ts | 6 +++- src/lib/messaging/channels/metadata.ts | 2 +- src/lib/messaging/channels/slack/manifest.ts | 2 +- .../messaging/channels/telegram/manifest.ts | 2 +- src/lib/messaging/manifest/types.ts | 2 +- src/lib/messaging/utils.test.ts | 35 ++++++++++++------- src/lib/messaging/utils.ts | 6 +++- .../channels-add-deepagents-rejection.test.ts | 20 ++++++----- 17 files changed, 108 insertions(+), 83 deletions(-) diff --git a/agents/langchain-deepagents-code/Dockerfile b/agents/langchain-deepagents-code/Dockerfile index fb05937eeec..abb5556477c 100644 --- a/agents/langchain-deepagents-code/Dockerfile +++ b/agents/langchain-deepagents-code/Dockerfile @@ -43,6 +43,7 @@ ARG NEMOCLAW_UPSTREAM_PROVIDER=nvidia ARG NEMOCLAW_INFERENCE_BASE_URL=https://inference.local/v1 ARG NEMOCLAW_INFERENCE_API=openai-completions ARG NEMOCLAW_BUILD_ID=default +ARG NEMOCLAW_MESSAGING_PLAN_B64= ARG NEMOCLAW_DARWIN_VM_COMPAT=0 ENV HOME=/sandbox \ @@ -53,6 +54,7 @@ ENV HOME=/sandbox \ NEMOCLAW_INFERENCE_BASE_URL=${NEMOCLAW_INFERENCE_BASE_URL} \ NEMOCLAW_INFERENCE_API=${NEMOCLAW_INFERENCE_API} \ NEMOCLAW_BUILD_ID=${NEMOCLAW_BUILD_ID} \ + NEMOCLAW_MESSAGING_PLAN_B64=${NEMOCLAW_MESSAGING_PLAN_B64} \ DEEPAGENTS_CODE_NO_UPDATE_CHECK=1 \ DEEPAGENTS_CODE_AUTO_UPDATE=0 \ DEEPAGENTS_CODE_OPENAI_API_KEY=nemoclaw-managed-inference \ diff --git a/agents/langchain-deepagents-code/manifest.yaml b/agents/langchain-deepagents-code/manifest.yaml index da89ba45cb0..d09fe747d83 100644 --- a/agents/langchain-deepagents-code/manifest.yaml +++ b/agents/langchain-deepagents-code/manifest.yaml @@ -54,7 +54,10 @@ state_files: device_pairing: false messaging_platforms: - supported: [] + supported: + - telegram + - discord + - slack # ── Inference ─────────────────────────────────────────────────── # V1 routes NVIDIA/OpenAI-compatible selections through OpenShell's managed diff --git a/src/lib/actions/sandbox/policy-channel-agent-gate.test.ts b/src/lib/actions/sandbox/policy-channel-agent-gate.test.ts index a8c6c88cc19..ed74996d167 100644 --- a/src/lib/actions/sandbox/policy-channel-agent-gate.test.ts +++ b/src/lib/actions/sandbox/policy-channel-agent-gate.test.ts @@ -1,11 +1,12 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -// Lifecycle-boundary regression: `addSandboxChannel` must refuse non-messaging -// agents (including any agent with an explicit empty `messagingPlatforms` -// allowlist) BEFORE any preset load, policy mutation, provider upsert, registry -// write, credential prompt, or rebuild trigger. Without this gate, a -// destructive sandbox rebuild can run and fail late at Dockerfile patching. +// Lifecycle-boundary regression: `addSandboxChannel` must refuse agents that +// either fall outside the runtime allowlist or carry an explicit empty +// `messagingPlatforms` allowlist BEFORE any preset load, policy mutation, +// provider upsert, registry write, credential prompt, or rebuild trigger. +// Without this gate, a destructive sandbox rebuild can run and fail late at +// Dockerfile patching. // // Why dist + vi.spyOn (matches policy-channel-conflict.test.ts): the source // policy-channel.ts loads several deps via runtime CommonJS `require()`. In @@ -88,10 +89,10 @@ afterEach(() => { vi.restoreAllMocks(); }); -describe("addSandboxChannel agent gate (#5729)", () => { - it("rejects langchain-deepagents-code before any preset, mutation, provider, credential, or rebuild call", async () => { +describe("addSandboxChannel agent gate", () => { + it("rejects an unknown agent before any preset, mutation, provider, credential, or rebuild call", async () => { vi.spyOn(defs, "loadAgent").mockReturnValue({ - name: "langchain-deepagents-code", + name: "custom-agent", messagingPlatforms: [], }); @@ -106,10 +107,10 @@ describe("addSandboxChannel agent gate (#5729)", () => { const errorText = (errSpy.mock.calls as unknown[][]) .map((call) => call.map(String).join(" ")) .join("\n"); + expect(errorText).toMatch(/Agent 'custom-agent' does not support messaging channels/); expect(errorText).toMatch( - /Agent 'langchain-deepagents-code' does not support messaging channels/, + /Messaging-capable agents: openclaw, hermes, langchain-deepagents-code/, ); - expect(errorText).toMatch(/Messaging-capable agents: openclaw, hermes/); expect(loadPresetMock).not.toHaveBeenCalled(); expect(applyPresetMock).not.toHaveBeenCalled(); diff --git a/src/lib/actions/sandbox/policy-channel-cleanup.test.ts b/src/lib/actions/sandbox/policy-channel-cleanup.test.ts index ec4b490cd54..97a53719038 100644 --- a/src/lib/actions/sandbox/policy-channel-cleanup.test.ts +++ b/src/lib/actions/sandbox/policy-channel-cleanup.test.ts @@ -34,7 +34,7 @@ let updateSandboxMock: MockInstance; function entryWithStalePlan(sandboxName: string, channelId: string) { return { name: sandboxName, - agent: "langchain-deepagents-code", + agent: "custom-agent", messaging: { schemaVersion: 1, plan: { @@ -79,7 +79,7 @@ afterEach(() => { describe("persistManifestChannelRemovePlan with non-messaging agent (#5729)", () => { it("strips stale messaging state from the registry without throwing", async () => { vi.spyOn(defs, "loadAgent").mockReturnValue({ - name: "langchain-deepagents-code", + name: "custom-agent", messagingPlatforms: [], }); getSandboxMock.mockReturnValue(entryWithStalePlan("da-test", "discord")); @@ -92,10 +92,10 @@ describe("persistManifestChannelRemovePlan with non-messaging agent (#5729)", () it("returns true and skips registry update when no stale plan exists", async () => { vi.spyOn(defs, "loadAgent").mockReturnValue({ - name: "langchain-deepagents-code", + name: "custom-agent", messagingPlatforms: [], }); - getSandboxMock.mockReturnValue({ name: "da-test", agent: "langchain-deepagents-code" }); + getSandboxMock.mockReturnValue({ name: "da-test", agent: "custom-agent" }); const result = await persistManifestChannelRemovePlan("da-test", "discord"); @@ -107,7 +107,7 @@ describe("persistManifestChannelRemovePlan with non-messaging agent (#5729)", () describe("persistManifestChannelDisabledPlan with non-messaging agent (#5729)", () => { it("returns null without throwing or mutating the registry when the agent does not support messaging", async () => { vi.spyOn(defs, "loadAgent").mockReturnValue({ - name: "langchain-deepagents-code", + name: "custom-agent", messagingPlatforms: [], }); getSandboxMock.mockReturnValue(entryWithStalePlan("da-test", "discord")); @@ -120,10 +120,10 @@ describe("persistManifestChannelDisabledPlan with non-messaging agent (#5729)", it("returns null without throwing when there is no stored messaging plan", async () => { vi.spyOn(defs, "loadAgent").mockReturnValue({ - name: "langchain-deepagents-code", + name: "custom-agent", messagingPlatforms: [], }); - getSandboxMock.mockReturnValue({ name: "da-test", agent: "langchain-deepagents-code" }); + getSandboxMock.mockReturnValue({ name: "da-test", agent: "custom-agent" }); const result = await persistManifestChannelDisabledPlan("da-test", "discord", true); diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index c0099865f08..d77b45b341c 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -925,7 +925,7 @@ export async function addSandboxChannel( console.error( ` Agent '${agent.name}' does not support messaging channels for sandbox '${sandboxName}'.`, ); - console.error(" Messaging-capable agents: openclaw, hermes."); + console.error(" Messaging-capable agents: openclaw, hermes, langchain-deepagents-code."); process.exit(1); } if (!channelSupportedByAgent(canonical, agent)) { diff --git a/src/lib/actions/sandbox/rebuild-messaging-stage.test.ts b/src/lib/actions/sandbox/rebuild-messaging-stage.test.ts index 59089176fc3..b5f0649476b 100644 --- a/src/lib/actions/sandbox/rebuild-messaging-stage.test.ts +++ b/src/lib/actions/sandbox/rebuild-messaging-stage.test.ts @@ -30,39 +30,13 @@ const { stageMessagingManifestPlanForRebuild } = D("actions/sandbox/rebuild.js") ) => Promise; }; -describe("stageMessagingManifestPlanForRebuild non-messaging agent guard (#5729)", () => { +describe("stageMessagingManifestPlanForRebuild non-messaging agent guard", () => { afterEach(() => { vi.restoreAllMocks(); }); - it("emits the unknown-runtime skip message for langchain-deepagents-code without staging any plan", async () => { + it("emits the unknown-runtime skip message for any agent whose name is not in the runtime allowlist", async () => { const loadAgentSpy = vi.spyOn(defs, "loadAgent").mockReturnValue({ - name: "langchain-deepagents-code", - messagingPlatforms: [], - }); - const clearPlanEnvSpy = vi.spyOn(messaging.MessagingSetupApplier, "clearPlanEnv"); - - const messages: string[] = []; - const result = await stageMessagingManifestPlanForRebuild( - "deepagents-sandbox", - { name: "deepagents-sandbox" }, - "langchain-deepagents-code", - (msg) => messages.push(msg), - ); - - expect(loadAgentSpy).toHaveBeenCalledWith("langchain-deepagents-code"); - expect(clearPlanEnvSpy).toHaveBeenCalledTimes(1); - expect(messages).toContain( - "Messaging manifest rebuild plan skipped: agent 'langchain-deepagents-code' is not a messaging-capable runtime", - ); - expect(messages.some((msg) => msg.includes("declares no supported messaging channels"))).toBe( - false, - ); - expect(result).toBeNull(); - }); - - it("emits the unknown-runtime skip message for any agent whose name is not openclaw or hermes", async () => { - vi.spyOn(defs, "loadAgent").mockReturnValue({ name: "future-non-messaging-agent", messagingPlatforms: [], }); @@ -76,10 +50,14 @@ describe("stageMessagingManifestPlanForRebuild non-messaging agent guard (#5729) (msg) => messages.push(msg), ); + expect(loadAgentSpy).toHaveBeenCalledWith("future-non-messaging-agent"); expect(clearPlanEnvSpy).toHaveBeenCalledTimes(1); expect(messages).toContain( "Messaging manifest rebuild plan skipped: agent 'future-non-messaging-agent' is not a messaging-capable runtime", ); + expect(messages.some((msg) => msg.includes("declares no supported messaging channels"))).toBe( + false, + ); expect(result).toBeNull(); }); diff --git a/src/lib/messaging/AGENTS.md b/src/lib/messaging/AGENTS.md index 2bed86085ba..4e0d0eb2326 100644 --- a/src/lib/messaging/AGENTS.md +++ b/src/lib/messaging/AGENTS.md @@ -40,7 +40,7 @@ The design goal is to keep messaging channel behavior out of core onboard/rebuil - Secret inputs must not declare `statePath`; persisted plans may contain `credentialAvailable`, `credentialHash`, and placeholders, never tokens. - Hook implementations are resolved by stable handler IDs through `MessagingHookRegistry`. Manifests reference handlers by string; they do not import handler code. - Hook outputs must match manifest declarations and be JSON-serializable. Add outputs to the manifest before consuming them. -- Channel render/build-file targets must stay inside `/sandbox/.openclaw` or `/sandbox/.hermes`; rely on existing applier validation instead of bypassing it. +- Channel render/build-file targets must stay inside `/sandbox/.openclaw`, `/sandbox/.hermes`, or `/sandbox/.deepagents`; rely on existing applier validation instead of bypassing it. - Disabled channels are not active. Always filter effects through `enabledPlanChannels()` or `filterEnabledPlanEntries()` when applying providers, policies, render, hooks, runtime setup, or conflicts. - Conflict detection has two axes: generic credential-hash overlap in `applier/conflict-detection/` and channel-owned `pre-enable` hooks such as Slack Socket Mode gateway checks. - Keep transitional compatibility tables derived from manifests. `src/lib/sandbox/channels.ts` intentionally builds legacy CLI metadata from `listBuiltInMessagingChannelManifests()`. @@ -85,7 +85,7 @@ Update `.agents/skills/nemoclaw-user-guide/SKILL.md` only when AI-agent docs rou ## Agent Gating and Stale Plan Cleanup -- **Invalid state.** A sandbox can be configured with an agent whose manifest declares no messaging support (`messaging_platforms.supported: []`) or an agent name outside the messaging runtime allowlist. Without an explicit gate the channel-add path can still tear down the sandbox before failing at `dockerfile-patch.ts`, and a rebuild can carry a stale `NEMOCLAW_MESSAGING_PLAN_B64` into the Dockerfile patch step for an agent that does not declare the matching `ARG`. +- **Invalid state.** A sandbox can be configured with an agent whose manifest declares no messaging support (`messaging_platforms.supported: []`) or an agent name outside the messaging runtime allowlist. Without an explicit gate the channel-add path can still tear down the sandbox before failing at `dockerfile-patch.ts`, and a rebuild can carry a stale `NEMOCLAW_MESSAGING_PLAN_B64` into the Dockerfile patch step for an agent that does not declare the matching `ARG`. The current runtime allowlist is `openclaw`, `hermes`, and `langchain-deepagents-code` — see `MESSAGING_AGENT_IDS` in `utils.ts`. - **Source boundary.** The agent manifest's `messaging_platforms.supported` list is the single source of truth for whether a given agent supports messaging today, and which channels are available for it. The `MessagingAgentId` union in `applier/build/messaging-build-applier.mts` is the runtime allowlist for build-time messaging integration. Both are checked at the action boundary by `isMessagingSupportedAgent(agent)` and `tryGetMessagingAgentId(agent)` in `utils.ts`. The `ChannelManifestRegistry.listAvailable` and `MessagingWorkflowPlanner.supportedChannelIds` paths share these semantics, so an explicit empty allowlist means deny-all everywhere, and a populated allowlist filters to that subset. - **Source-fix constraint.** Expanding `messaging_platforms.supported` for an agent requires the matching Dockerfile `ARG NEMOCLAW_MESSAGING_PLAN_B64=`, a `MessagingAgentId` entry, per-channel `supportedAgents`, and agent-side render and hook handlers in `applier/build/messaging-build-applier.mts`. Until that stack lands, the gate at the action boundary is the only safe behavior — surfacing the unsupported-agent message in `addSandboxChannel`, clearing the staged plan in `stageMessagingManifestPlanForRebuild`, and stripping stale plans in `persistManifestChannelRemovePlan`. - **Regression tests.** `src/lib/messaging/utils.test.ts`, `src/lib/messaging/manifest/registry.test.ts`, and `src/lib/messaging/compiler/workflow-planner.test.ts` lock the helper and registry semantics. `src/lib/actions/sandbox/policy-channel-agent-gate.test.ts`, `src/lib/actions/sandbox/policy-channel-cleanup.test.ts`, and `src/lib/actions/sandbox/rebuild-messaging-stage.test.ts` cover the action and rebuild boundaries against the compiled `dist/` modules. `test/channels-add-deepagents-rejection.test.ts` exercises the full `addSandboxChannel` boundary in a spawned Node process to prove no policy, provider, registry, credential, or rebuild call happens before the unsupported-agent exit. diff --git a/src/lib/messaging/applier/build/messaging-build-applier.mts b/src/lib/messaging/applier/build/messaging-build-applier.mts index c3c19cb62c2..31fb4a3474f 100755 --- a/src/lib/messaging/applier/build/messaging-build-applier.mts +++ b/src/lib/messaging/applier/build/messaging-build-applier.mts @@ -17,7 +17,7 @@ import type { ChannelManifest } from "../../manifest/types.ts"; type Env = Record; type JsonObject = Record; -type MessagingAgentId = "openclaw" | "hermes"; +type MessagingAgentId = "openclaw" | "hermes" | "langchain-deepagents-code"; type MessagingHookPhase = "agent-install" | "post-agent-install"; type MessagingRuntimeSetupKey = "nodePreloads" | "envAliases" | "secretScans"; type MessagingSerializableValue = @@ -692,7 +692,12 @@ function resolveAgentRenderTarget( options: { readonly homeDir?: string } = {}, ): string { const home = options.homeDir ?? homedir(); - const agentRoot = agent === "hermes" ? join(home, ".hermes") : join(home, ".openclaw"); + const agentRoot = + agent === "hermes" + ? join(home, ".hermes") + : agent === "langchain-deepagents-code" + ? join(home, ".deepagents") + : join(home, ".openclaw"); const normalizedRoot = resolve(agentRoot); if (agent === "openclaw" && target === "openclaw.json") { return join(agentRoot, "openclaw.json"); @@ -714,6 +719,14 @@ function resolveAgentRenderTarget( } relativePath = target.slice("~/.hermes/".length); } + if (target.startsWith("~/.deepagents/")) { + if (agent !== "langchain-deepagents-code") { + throw new MessagingBuildApplierError( + `Messaging render target ${target} does not match ${agent}.`, + ); + } + relativePath = target.slice("~/.deepagents/".length); + } if (relativePath !== null) { const resolvedTarget = resolve(agentRoot, relativePath); if ( @@ -779,10 +792,13 @@ function applyBuildFileOutputToLocalAgentRoot( file: BuildFileOutput, options: { readonly homeDir?: string } = {}, ): string { + const home = options.homeDir ?? homedir(); const root = agent === "hermes" - ? join(options.homeDir ?? homedir(), ".hermes") - : join(options.homeDir ?? homedir(), ".openclaw"); + ? join(home, ".hermes") + : agent === "langchain-deepagents-code" + ? join(home, ".deepagents") + : join(home, ".openclaw"); const relativePath = normalizeBuildFilePath(file.path); const target = resolve(root, relativePath); const normalizedRoot = resolve(root); @@ -1530,8 +1546,12 @@ function parseMessagingBuildArgs(argv: readonly string[]): { } function readAgentArg(value: string | undefined): MessagingAgentId { - if (value === "openclaw" || value === "hermes") return value; - throw new MessagingBuildApplierError("--agent must be 'openclaw' or 'hermes'"); + if (value === "openclaw" || value === "hermes" || value === "langchain-deepagents-code") { + return value; + } + throw new MessagingBuildApplierError( + "--agent must be 'openclaw', 'hermes', or 'langchain-deepagents-code'", + ); } function readPhaseArg(value: string | undefined): MessagingBuildPhase { diff --git a/src/lib/messaging/channels/discord/manifest.ts b/src/lib/messaging/channels/discord/manifest.ts index 0db2158bdd2..2f63f2bbdf6 100644 --- a/src/lib/messaging/channels/discord/manifest.ts +++ b/src/lib/messaging/channels/discord/manifest.ts @@ -8,7 +8,7 @@ export const discordManifest = { id: "discord", displayName: "Discord", description: "Discord bot messaging", - supportedAgents: ["openclaw", "hermes"], + supportedAgents: ["openclaw", "hermes", "langchain-deepagents-code"], auth: { mode: "token-paste", }, diff --git a/src/lib/messaging/channels/manifests.test.ts b/src/lib/messaging/channels/manifests.test.ts index 8d08c248b18..0ecd3c87785 100644 --- a/src/lib/messaging/channels/manifests.test.ts +++ b/src/lib/messaging/channels/manifests.test.ts @@ -274,11 +274,15 @@ describe("built-in channel manifests", () => { teams: teamsManifest, }; + const channelsSupportingDeepAgents = new Set(["telegram", "discord", "slack"]); for (const [channelId, manifest] of Object.entries(manifests)) { const legacy = KNOWN_CHANNELS[channelId]; expect(manifest.description).toBe(legacy.description); expect(policyPresetNames(manifest)).toEqual([channelId]); - expect(manifest.supportedAgents).toEqual(["openclaw", "hermes"]); + const expectedAgents = channelsSupportingDeepAgents.has(channelId) + ? ["openclaw", "hermes", "langchain-deepagents-code"] + : ["openclaw", "hermes"]; + expect(manifest.supportedAgents).toEqual(expectedAgents); expect(manifest.auth.mode).toBe(legacy.loginMethod ?? "token-paste"); } diff --git a/src/lib/messaging/channels/metadata.ts b/src/lib/messaging/channels/metadata.ts index 62cdb3d28d9..5142c681d03 100644 --- a/src/lib/messaging/channels/metadata.ts +++ b/src/lib/messaging/channels/metadata.ts @@ -316,7 +316,7 @@ export function listMessagingPackageInstallSpecs( } function selectManifests(options: MessagingManifestMetadataOptions): ChannelManifest[] { - const manifests = options.manifests ?? BUILT_IN_CHANNEL_MANIFESTS; + const manifests: readonly ChannelManifest[] = options.manifests ?? BUILT_IN_CHANNEL_MANIFESTS; const agent = options.agent; const selected = agent ? manifests.filter((manifest) => manifest.supportedAgents.includes(agent)) diff --git a/src/lib/messaging/channels/slack/manifest.ts b/src/lib/messaging/channels/slack/manifest.ts index 000e4c94e76..30c3da7c517 100644 --- a/src/lib/messaging/channels/slack/manifest.ts +++ b/src/lib/messaging/channels/slack/manifest.ts @@ -8,7 +8,7 @@ export const slackManifest = { id: "slack", displayName: "Slack", description: "Slack bot messaging", - supportedAgents: ["openclaw", "hermes"], + supportedAgents: ["openclaw", "hermes", "langchain-deepagents-code"], auth: { mode: "token-paste", }, diff --git a/src/lib/messaging/channels/telegram/manifest.ts b/src/lib/messaging/channels/telegram/manifest.ts index 0bed20f86e1..0780227930c 100644 --- a/src/lib/messaging/channels/telegram/manifest.ts +++ b/src/lib/messaging/channels/telegram/manifest.ts @@ -12,7 +12,7 @@ export const telegramManifest = { "For Telegram group chats, disable privacy mode in @BotFather (/setprivacy -> your bot -> Disable).", "After changing privacy mode, remove and re-add the bot to each group before testing @mentions.", ], - supportedAgents: ["openclaw", "hermes"], + supportedAgents: ["openclaw", "hermes", "langchain-deepagents-code"], auth: { mode: "token-paste", }, diff --git a/src/lib/messaging/manifest/types.ts b/src/lib/messaging/manifest/types.ts index aa455658eeb..4b6c87f5ff7 100644 --- a/src/lib/messaging/manifest/types.ts +++ b/src/lib/messaging/manifest/types.ts @@ -19,7 +19,7 @@ export type MessagingSerializableObject = { export type MessagingChannelId = string; /** Agent runtimes that messaging manifests can target today. */ -export type MessagingAgentId = "openclaw" | "hermes"; +export type MessagingAgentId = "openclaw" | "hermes" | "langchain-deepagents-code"; /** Dot-separated path into NemoClaw's persisted sandbox or channel state. */ export type MessagingStatePath = string; diff --git a/src/lib/messaging/utils.test.ts b/src/lib/messaging/utils.test.ts index aa782624248..8f3a1b59f55 100644 --- a/src/lib/messaging/utils.test.ts +++ b/src/lib/messaging/utils.test.ts @@ -20,8 +20,13 @@ describe("tryGetMessagingAgentId", () => { expect(tryGetMessagingAgentId({ name: "hermes" })).toBe("hermes"); }); + it("returns 'langchain-deepagents-code' for the DeepAgents agent name", () => { + expect(tryGetMessagingAgentId({ name: "langchain-deepagents-code" })).toBe( + "langchain-deepagents-code", + ); + }); + it("returns null for unknown agent names instead of silently defaulting", () => { - expect(tryGetMessagingAgentId({ name: "langchain-deepagents-code" })).toBeNull(); expect(tryGetMessagingAgentId({ name: "custom-agent" })).toBeNull(); }); @@ -36,6 +41,9 @@ describe("toMessagingAgentId", () => { it("returns the messaging agent id for known names", () => { expect(toMessagingAgentId({ name: "openclaw" })).toBe("openclaw"); expect(toMessagingAgentId({ name: "hermes" })).toBe("hermes"); + expect(toMessagingAgentId({ name: "langchain-deepagents-code" })).toBe( + "langchain-deepagents-code", + ); }); it("falls back to openclaw when no agent name is supplied (legacy default convention)", () => { @@ -47,9 +55,6 @@ describe("toMessagingAgentId", () => { }); it("throws MessagingAgentNotSupportedError for an explicit unknown agent", () => { - expect(() => toMessagingAgentId({ name: "langchain-deepagents-code" })).toThrow( - MessagingAgentNotSupportedError, - ); expect(() => toMessagingAgentId({ name: "custom-agent" })).toThrow( MessagingAgentNotSupportedError, ); @@ -57,11 +62,11 @@ describe("toMessagingAgentId", () => { it("surfaces the offending agent name on the thrown error", () => { try { - toMessagingAgentId({ name: "langchain-deepagents-code" }); + toMessagingAgentId({ name: "custom-agent" }); } catch (err) { expect(err).toBeInstanceOf(MessagingAgentNotSupportedError); - expect((err as MessagingAgentNotSupportedError).agentName).toBe("langchain-deepagents-code"); - expect((err as Error).message).toMatch(/openclaw, hermes/); + expect((err as MessagingAgentNotSupportedError).agentName).toBe("custom-agent"); + expect((err as Error).message).toMatch(/openclaw, hermes, langchain-deepagents-code/); return; } throw new Error("expected toMessagingAgentId to throw"); @@ -69,25 +74,31 @@ describe("toMessagingAgentId", () => { }); describe("isMessagingSupportedAgent", () => { - it("returns true for openclaw and hermes regardless of messagingPlatforms", () => { + it("returns true for openclaw, hermes, and DeepAgents regardless of messagingPlatforms", () => { expect(isMessagingSupportedAgent({ name: "openclaw" })).toBe(true); expect(isMessagingSupportedAgent({ name: "hermes", messagingPlatforms: ["telegram"] })).toBe( true, ); + expect( + isMessagingSupportedAgent({ + name: "langchain-deepagents-code", + messagingPlatforms: ["discord"], + }), + ).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); + }); + + it("returns false for unknown agents", () => { expect(isMessagingSupportedAgent({ name: "custom-agent" })).toBe(false); expect(isMessagingSupportedAgent(null)).toBe(false); }); @@ -136,7 +147,7 @@ describe("getMessagingManifestAvailabilityContext", () => { it("returns a null agent for unknown agents and never silently defaults to openclaw", () => { expect( getMessagingManifestAvailabilityContext({ - name: "langchain-deepagents-code", + name: "custom-agent", messagingPlatforms: [], }), ).toEqual({ diff --git a/src/lib/messaging/utils.ts b/src/lib/messaging/utils.ts index bd15b58a47d..e745bf0593f 100644 --- a/src/lib/messaging/utils.ts +++ b/src/lib/messaging/utils.ts @@ -16,7 +16,11 @@ export interface MessagingAgentDescriptor { export type MessagingInputResolver = (input: ChannelInputSpec) => string | null; -const MESSAGING_AGENT_IDS = ["openclaw", "hermes"] as const satisfies readonly MessagingAgentId[]; +const MESSAGING_AGENT_IDS = [ + "openclaw", + "hermes", + "langchain-deepagents-code", +] as const satisfies readonly MessagingAgentId[]; export class MessagingAgentNotSupportedError extends Error { readonly agentName: string; diff --git a/test/channels-add-deepagents-rejection.test.ts b/test/channels-add-deepagents-rejection.test.ts index 8add861c12e..4789d538391 100644 --- a/test/channels-add-deepagents-rejection.test.ts +++ b/test/channels-add-deepagents-rejection.test.ts @@ -1,10 +1,10 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 // -// Behaviour-level regression for #5729. `nemoclaw channels add ` -// on an agent whose manifest declares no messaging support must exit nonzero -// before any preset load, policy mutation, provider upsert, registry write, -// credential save, prompt, rebuild call, or openshell invocation. +// Behaviour-level regression: `nemoclaw channels add ` on an +// agent whose manifest declares no messaging support must exit nonzero before +// any preset load, policy mutation, provider upsert, registry write, credential +// save, prompt, rebuild call, or openshell invocation. // // Spawns the assembled `addSandboxChannel` action in a real Node process so // the entire module graph loads, then asserts the no-mutation invariant from @@ -126,9 +126,9 @@ module.exports = { `; } -describe("addSandboxChannel agent gate (behaviour, #5729)", () => { - it("DeepAgents channels add discord exits non-mutatingly with the unsupported-agent message", () => { - const script = `${buildPreamble("langchain-deepagents-code")} +describe("addSandboxChannel agent gate (behaviour)", () => { + it("custom non-messaging agent channels add discord exits non-mutatingly with the unsupported-agent message", () => { + const script = `${buildPreamble("custom-agent")} const ctx = module.exports; (async () => { let caught = null; @@ -170,12 +170,14 @@ const ctx = module.exports; assert.equal(payload.exitCode, 1, "expected addSandboxChannel to exit with code 1"); assert.ok( payload.errors.some((msg) => - /Agent 'langchain-deepagents-code' does not support messaging channels/.test(msg), + /Agent 'custom-agent' does not support messaging channels/.test(msg), ), `missing unsupported-agent error in stderr: ${JSON.stringify(payload.errors)}`, ); assert.ok( - payload.errors.some((msg) => /Messaging-capable agents: openclaw, hermes/.test(msg)), + payload.errors.some((msg) => + /Messaging-capable agents: openclaw, hermes, langchain-deepagents-code/.test(msg), + ), `missing supported-agents hint in stderr: ${JSON.stringify(payload.errors)}`, ); From 8613e96f6f0f3f0475a4ff1a84316637a2624871 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Wed, 24 Jun 2026 18:13:13 +0000 Subject: [PATCH 09/12] feat(messaging): apply DeepAgents messaging plan during sandbox build Signed-off-by: Tinson Lai --- agents/langchain-deepagents-code/Dockerfile | 8 +++- .../messaging/channels/discord/manifest.ts | 25 +++++++++++ src/lib/messaging/channels/slack/manifest.ts | 24 +++++++++++ .../messaging/channels/telegram/manifest.ts | 23 +++++++++++ .../channels-add-deepagents-rejection.test.ts | 41 +++++++++++++++---- 5 files changed, 113 insertions(+), 8 deletions(-) diff --git a/agents/langchain-deepagents-code/Dockerfile b/agents/langchain-deepagents-code/Dockerfile index abb5556477c..2106063048b 100644 --- a/agents/langchain-deepagents-code/Dockerfile +++ b/agents/langchain-deepagents-code/Dockerfile @@ -25,9 +25,10 @@ COPY agents/langchain-deepagents-code/patch-managed-deepagents-code.py /opt/nemo COPY agents/langchain-deepagents-code/dcode-wrapper.sh /usr/local/lib/nemoclaw/dcode-wrapper.sh COPY agents/langchain-deepagents-code/start.sh /usr/local/bin/nemoclaw-start COPY nemoclaw-blueprint/ /opt/nemoclaw-blueprint/ +COPY src/lib/messaging/ /src/lib/messaging/ RUN chmod 444 /opt/nemoclaw-deepagents-code/generate-config.ts /opt/nemoclaw-deepagents-code/patch-managed-deepagents-code.py \ && chmod 755 /usr/local/bin/nemoclaw-start /usr/local/lib/nemoclaw/dcode-wrapper.sh \ - && chmod -R a+rX /opt/nemoclaw-blueprint \ + && chmod -R a+rX /opt/nemoclaw-blueprint /src/lib/messaging \ && python3 /opt/nemoclaw-deepagents-code/patch-managed-deepagents-code.py \ && rm -f /usr/local/bin/dcode /usr/local/bin/deepagents-code \ && install -m 0755 /usr/local/lib/nemoclaw/dcode-wrapper.sh /usr/local/bin/dcode \ @@ -68,6 +69,11 @@ RUN mkdir -p /sandbox/.nemoclaw/blueprints/0.1.0 \ && node --experimental-strip-types /opt/nemoclaw-deepagents-code/generate-config.ts \ && chmod 660 /sandbox/.deepagents/config.toml +# Apply messaging render and post-agent-install build-file hooks. DeepAgents has +# no agent-install package step today, so only post-agent-install runs. +# hadolint ignore=DL3059 +RUN node --experimental-strip-types /src/lib/messaging/applier/build/messaging-build-applier.mts --agent langchain-deepagents-code --phase post-agent-install + USER root RUN chown root:root /sandbox/.nemoclaw \ && chmod 1755 /sandbox/.nemoclaw \ diff --git a/src/lib/messaging/channels/discord/manifest.ts b/src/lib/messaging/channels/discord/manifest.ts index 2f63f2bbdf6..6948c82097f 100644 --- a/src/lib/messaging/channels/discord/manifest.ts +++ b/src/lib/messaging/channels/discord/manifest.ts @@ -179,6 +179,31 @@ export const discordManifest = { }, }, }, + { + id: "discord-deepagents-env", + kind: "env-lines", + agent: "langchain-deepagents-code", + target: "~/.deepagents/.env", + lines: [ + "DISCORD_BOT_TOKEN={{credential.discordBotToken.placeholder}}", + "NEMOCLAW_DISCORD_GUILD_IDS={{discord.guildIds.csv}}", + "DISCORD_ALLOWED_USERS={{discord.allowedUsers.csv}}", + "DISCORD_ALLOW_ALL_USERS={{discord.allowAllUsers}}", + ], + }, + { + id: "discord-deepagents-channel", + kind: "json-fragment", + agent: "langchain-deepagents-code", + target: "~/.deepagents/messaging.json", + fragment: { + path: "channels.discord", + value: { + enabled: true, + requireMention: "{{discord.requireMention}}", + }, + }, + }, ], runtime: { openclaw: { diff --git a/src/lib/messaging/channels/slack/manifest.ts b/src/lib/messaging/channels/slack/manifest.ts index 30c3da7c517..3dd99740c77 100644 --- a/src/lib/messaging/channels/slack/manifest.ts +++ b/src/lib/messaging/channels/slack/manifest.ts @@ -144,6 +144,30 @@ export const slackManifest = { }, }, }, + { + id: "slack-deepagents-env", + kind: "env-lines", + agent: "langchain-deepagents-code", + target: "~/.deepagents/.env", + lines: [ + "SLACK_BOT_TOKEN={{credential.slackBotToken.placeholder}}", + "SLACK_APP_TOKEN={{credential.slackAppToken.placeholder}}", + "SLACK_ALLOWED_USERS={{allowedIds.slack.csv}}", + "SLACK_ALLOWED_CHANNELS={{slackConfig.allowedChannels.csv}}", + ], + }, + { + id: "slack-deepagents-channel", + kind: "json-fragment", + agent: "langchain-deepagents-code", + target: "~/.deepagents/messaging.json", + fragment: { + path: "channels.slack", + value: { + enabled: true, + }, + }, + }, ], runtime: { openclaw: { diff --git a/src/lib/messaging/channels/telegram/manifest.ts b/src/lib/messaging/channels/telegram/manifest.ts index 0780227930c..5c5dd5bd78d 100644 --- a/src/lib/messaging/channels/telegram/manifest.ts +++ b/src/lib/messaging/channels/telegram/manifest.ts @@ -167,6 +167,29 @@ export const telegramManifest = { }, }, }, + { + id: "telegram-deepagents-env", + kind: "env-lines", + agent: "langchain-deepagents-code", + target: "~/.deepagents/.env", + lines: [ + "TELEGRAM_BOT_TOKEN={{credential.telegramBotToken.placeholder}}", + "TELEGRAM_ALLOWED_USERS={{allowedIds.telegram.csv}}", + ], + }, + { + id: "telegram-deepagents-channel", + kind: "json-fragment", + agent: "langchain-deepagents-code", + target: "~/.deepagents/messaging.json", + fragment: { + path: "channels.telegram", + value: { + enabled: true, + requireMention: "{{telegramConfig.requireMention}}", + }, + }, + }, ], runtime: { openclaw: { diff --git a/test/channels-add-deepagents-rejection.test.ts b/test/channels-add-deepagents-rejection.test.ts index 4789d538391..dc09b000762 100644 --- a/test/channels-add-deepagents-rejection.test.ts +++ b/test/channels-add-deepagents-rejection.test.ts @@ -41,7 +41,10 @@ function parseResultPayload>( result: SpawnSyncReturns, ): T { const marker = result.stdout.lastIndexOf("__RESULT__"); - assert.ok(marker >= 0, `no __RESULT__ marker in stdout:\n${result.stdout}\n---stderr---\n${result.stderr}`); + assert.ok( + marker >= 0, + `no __RESULT__ marker in stdout:\n${result.stdout}\n---stderr---\n${result.stderr}`, + ); return JSON.parse(result.stdout.slice(marker + "__RESULT__".length).trim()) as T; } @@ -166,7 +169,11 @@ const ctx = module.exports; unexpectedError: { message: string; stack: string } | null; }>(result); - assert.equal(payload.unexpectedError, null, `unexpected exception: ${payload.unexpectedError?.stack}`); + assert.equal( + payload.unexpectedError, + null, + `unexpected exception: ${payload.unexpectedError?.stack}`, + ); assert.equal(payload.exitCode, 1, "expected addSandboxChannel to exit with code 1"); assert.ok( payload.errors.some((msg) => @@ -182,13 +189,33 @@ const ctx = module.exports; ); assert.deepEqual(payload.policyCalls.loadPreset, [], "loadPreset must not run before the gate"); - assert.deepEqual(payload.policyCalls.applyPreset, [], "applyPreset must not run before the gate"); - assert.deepEqual(payload.providerCalls, [], "upsertMessagingProviders must not run before the gate"); + assert.deepEqual( + payload.policyCalls.applyPreset, + [], + "applyPreset must not run before the gate", + ); + assert.deepEqual( + payload.providerCalls, + [], + "upsertMessagingProviders must not run before the gate", + ); assert.deepEqual(payload.registryUpdates, [], "updateSandbox must not run before the gate"); assert.deepEqual(payload.rebuildCalls, [], "rebuildSandbox must not run before the gate"); - assert.deepEqual(payload.credentialCalls.save, [], "saveCredential must not run before the gate"); - assert.deepEqual(payload.credentialCalls.delete, [], "deleteCredential must not run before the gate"); + assert.deepEqual( + payload.credentialCalls.save, + [], + "saveCredential must not run before the gate", + ); + assert.deepEqual( + payload.credentialCalls.delete, + [], + "deleteCredential must not run before the gate", + ); assert.deepEqual(payload.credentialCalls.prompt, [], "prompt must not run before the gate"); - assert.deepEqual(payload.runOpenshellCalls, [], "openshell must not be invoked before the gate"); + assert.deepEqual( + payload.runOpenshellCalls, + [], + "openshell must not be invoked before the gate", + ); }); }); From 85f32a3a236e780fed3d1806f33cd349c127f2ee Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Wed, 24 Jun 2026 18:35:10 +0000 Subject: [PATCH 10/12] feat(messaging): consume DeepAgents messaging artifacts at sandbox startup Signed-off-by: Tinson Lai --- agents/langchain-deepagents-code/start.sh | 20 ++++++++++++++++++++ src/lib/messaging/AGENTS.md | 9 +++++++++ 2 files changed, 29 insertions(+) diff --git a/agents/langchain-deepagents-code/start.sh b/agents/langchain-deepagents-code/start.sh index ada7cdba8fe..6d3c800a8b8 100755 --- a/agents/langchain-deepagents-code/start.sh +++ b/agents/langchain-deepagents-code/start.sh @@ -51,6 +51,15 @@ write_proxy_export_pair() { write_export_if_set "$secondary" } +load_messaging_env() { + local env_file="/sandbox/.deepagents/.env" + [ -r "$env_file" ] || return 0 + set -a + # shellcheck disable=SC1090 + . "$env_file" + set +a +} + prepare_runtime_env() { local target=/tmp/nemoclaw-proxy-env.sh local tmp @@ -75,11 +84,22 @@ prepare_runtime_env() { write_export_if_set LANGSMITH_TRACING write_export_if_set LANGSMITH_PROJECT write_export_if_set DEEPAGENTS_CODE_LANGSMITH_PROJECT + write_export_if_set TELEGRAM_BOT_TOKEN + write_export_if_set TELEGRAM_ALLOWED_USERS + write_export_if_set DISCORD_BOT_TOKEN + write_export_if_set NEMOCLAW_DISCORD_GUILD_IDS + write_export_if_set DISCORD_ALLOWED_USERS + write_export_if_set DISCORD_ALLOW_ALL_USERS + write_export_if_set SLACK_BOT_TOKEN + write_export_if_set SLACK_APP_TOKEN + write_export_if_set SLACK_ALLOWED_USERS + write_export_if_set SLACK_ALLOWED_CHANNELS } >"$tmp" chmod 400 "$tmp" mv -f "$tmp" "$target" } +load_messaging_env prepare_runtime_env if [ "$#" -eq 0 ]; then diff --git a/src/lib/messaging/AGENTS.md b/src/lib/messaging/AGENTS.md index 4e0d0eb2326..8d5eaefdba1 100644 --- a/src/lib/messaging/AGENTS.md +++ b/src/lib/messaging/AGENTS.md @@ -83,6 +83,15 @@ Mock external messaging APIs. Do not call real Telegram, Discord, Slack, WeChat, User-facing behavior changes usually need docs under `docs/manage-sandboxes/messaging-channels.mdx` or `docs/reference/commands.mdx`. Update `.agents/skills/nemoclaw-user-guide/SKILL.md` only when AI-agent docs routing guidance changes. +## DeepAgents Messaging Artifact Contract + +LangChain Deep Agents Code is a terminal-oriented harness. NemoClaw does not run a long-running messaging bridge inside the DeepAgents sandbox today; the integration is artifact-only. + +- **Build-time artifacts.** Channel manifests render two targets per DeepAgents-supported channel: an env-lines fragment to `~/.deepagents/.env` and a JSON fragment to `~/.deepagents/messaging.json`. The build applier writes both during `--phase post-agent-install` in the DeepAgents Dockerfile. +- **Startup consumer.** `agents/langchain-deepagents-code/start.sh` sources `~/.deepagents/.env` before launching `dcode`, so messaging-related env vars (Telegram bot token, Discord guild ids, Slack app token, etc.) are present in the agent process environment. +- **No inbound bridge.** The harness does not spawn channel bot processes. Inbound messages from Telegram, Discord, or Slack do not currently reach `dcode`. The Ready state reported after rebuild reflects the agent runtime, not channel reachability. A future change must add a bot/bridge process before claiming end-to-end channel functionality. +- **Removal condition.** Drop this artifact-only contract once a DeepAgents-side messaging bridge (or upstream `dcode` feature) consumes `~/.deepagents/messaging.json` and routes messages to/from `dcode`. Until then, this section is the documented limit of the integration. + ## Agent Gating and Stale Plan Cleanup - **Invalid state.** A sandbox can be configured with an agent whose manifest declares no messaging support (`messaging_platforms.supported: []`) or an agent name outside the messaging runtime allowlist. Without an explicit gate the channel-add path can still tear down the sandbox before failing at `dockerfile-patch.ts`, and a rebuild can carry a stale `NEMOCLAW_MESSAGING_PLAN_B64` into the Dockerfile patch step for an agent that does not declare the matching `ARG`. The current runtime allowlist is `openclaw`, `hermes`, and `langchain-deepagents-code` — see `MESSAGING_AGENT_IDS` in `utils.ts`. From 701797660abf5287ad04c3e3ee2b04cd38b3f656 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 24 Jun 2026 14:59:18 -0700 Subject: [PATCH 11/12] fix(messaging): keep DeepAgents channels gated until bridge support --- .../langchain-deepagents-code/manifest.yaml | 8 +- agents/langchain-deepagents-code/start.sh | 26 ++- ci/platform-matrix.json | 4 +- docs/inference/inference-options.mdx | 2 +- docs/reference/platform-support.mdx | 4 +- .../sandbox/policy-channel-agent-gate.test.ts | 4 +- src/lib/actions/sandbox/policy-channel.ts | 2 +- src/lib/messaging/AGENTS.md | 12 +- .../messaging/channels/discord/manifest.ts | 2 +- src/lib/messaging/channels/manifests.test.ts | 6 +- src/lib/messaging/channels/slack/manifest.ts | 2 +- .../messaging/channels/telegram/manifest.ts | 2 +- src/lib/onboard.ts | 2 + .../onboard/machine/core-flow-phases.test.ts | 1 + .../onboard/machine/handlers/sandbox.test.ts | 54 +++++- src/lib/onboard/machine/handlers/sandbox.ts | 158 +++++++++++++++--- src/lib/onboard/messaging-channel-setup.ts | 4 + src/lib/onboard/messaging-state.test.ts | 10 +- src/lib/onboard/messaging-state.ts | 6 +- .../channels-add-deepagents-rejection.test.ts | 19 +-- test/langchain-deepagents-code-image.test.ts | 63 ++++++- test/messaging-build-applier.test.ts | 76 +++++++++ 22 files changed, 398 insertions(+), 69 deletions(-) diff --git a/agents/langchain-deepagents-code/manifest.yaml b/agents/langchain-deepagents-code/manifest.yaml index d09fe747d83..2501656537d 100644 --- a/agents/langchain-deepagents-code/manifest.yaml +++ b/agents/langchain-deepagents-code/manifest.yaml @@ -53,11 +53,11 @@ state_files: device_pairing: false +# Artifact-only messaging render exists for build validation, but DeepAgents +# does not run a channel bridge today. Keep public channel support disabled +# until inbound Telegram/Discord/Slack messages can reach dcode. messaging_platforms: - supported: - - telegram - - discord - - slack + supported: [] # ── Inference ─────────────────────────────────────────────────── # V1 routes NVIDIA/OpenAI-compatible selections through OpenShell's managed diff --git a/agents/langchain-deepagents-code/start.sh b/agents/langchain-deepagents-code/start.sh index 6d3c800a8b8..3626d27b4e5 100755 --- a/agents/langchain-deepagents-code/start.sh +++ b/agents/langchain-deepagents-code/start.sh @@ -51,13 +51,31 @@ write_proxy_export_pair() { write_export_if_set "$secondary" } +is_messaging_env_key_allowed() { + case "$1" in + TELEGRAM_BOT_TOKEN | TELEGRAM_ALLOWED_USERS | DISCORD_BOT_TOKEN | NEMOCLAW_DISCORD_GUILD_IDS) return 0 ;; + DISCORD_ALLOWED_USERS | DISCORD_ALLOW_ALL_USERS | SLACK_BOT_TOKEN | SLACK_APP_TOKEN) return 0 ;; + SLACK_ALLOWED_USERS | SLACK_ALLOWED_CHANNELS) return 0 ;; + *) return 1 ;; + esac +} + load_messaging_env() { local env_file="/sandbox/.deepagents/.env" + local line key value [ -r "$env_file" ] || return 0 - set -a - # shellcheck disable=SC1090 - . "$env_file" - set +a + while IFS= read -r line || [ -n "$line" ]; do + line="${line%$'\r'}" + [ -n "$line" ] || continue + case "$line" in \#*) continue ;; esac + key="${line%%=*}" + if [ "$key" = "$line" ] || ! is_messaging_env_key_allowed "$key"; then + printf 'Skipping invalid Deep Agents Code messaging env line for key %s.\n' "$key" >&2 + continue + fi + value="${line#*=}" + export "$key=$value" + done <"$env_file" } prepare_runtime_env() { diff --git a/ci/platform-matrix.json b/ci/platform-matrix.json index c78a340d41d..7ad3b65a4a0 100644 --- a/ci/platform-matrix.json +++ b/ci/platform-matrix.json @@ -129,7 +129,7 @@ "name": "Local NVIDIA NIM", "status": "experimental", "endpoint_type": "Local OpenAI-compatible", - "notes": "Requires `NEMOCLAW_EXPERIMENTAL=1` and a NIM-capable NVIDIA GPU. Host must have the NVIDIA Container Toolkit installed and a CDI spec present (`onboard` asserts CDI presence with `assertCdiNvidiaGpuSpecPresent`, `src/lib/onboard.ts:1581`). NIM images pull from `nvcr.io` and require NGC registry login. NemoClaw gates this path behind the experimental flag because it does not auto-select a NIM image for the host today. You must explicitly pick from the validated image list. Managed vLLM has host-specific default models and is not gated on the same boxes. Validated images referenced in `src/lib/inference/config.ts` and `nemoclaw/src/index.ts`: `nvidia/nemotron-3-super-120b-a12b` (default cloud model), `nvidia/nemotron-3-nano-30b-a3b`, `nvidia/llama-3.3-nemotron-super-49b-v1.5`." + "notes": "Requires `NEMOCLAW_EXPERIMENTAL=1` and a NIM-capable NVIDIA GPU. Host must have the NVIDIA Container Toolkit installed and a CDI spec present (`onboard` asserts CDI presence with `assertCdiNvidiaGpuSpecPresent`, `src/lib/onboard.ts:1586`). NIM images pull from `nvcr.io` and require NGC registry login. NemoClaw gates this path behind the experimental flag because it does not auto-select a NIM image for the host today. You must explicitly pick from the validated image list. Managed vLLM has host-specific default models and is not gated on the same boxes. Validated images referenced in `src/lib/inference/config.ts` and `nemoclaw/src/index.ts`: `nvidia/nemotron-3-super-120b-a12b` (default cloud model), `nvidia/nemotron-3-nano-30b-a3b`, `nvidia/llama-3.3-nemotron-super-49b-v1.5`." }, { "name": "Local vLLM (already running)", @@ -248,7 +248,7 @@ { "name": "Non-NVIDIA GPUs (AMD/ROCm, Intel Arc, Apple Metal)", "status": "unsupported", - "notes": "Local vLLM and NIM paths assert NVIDIA CDI presence with `assertCdiNvidiaGpuSpecPresent` (`src/lib/onboard.ts:1581`, called at `:1646`). NemoClaw does not install non-NVIDIA accelerator drivers." + "notes": "Local vLLM and NIM paths assert NVIDIA CDI presence with `assertCdiNvidiaGpuSpecPresent` (`src/lib/onboard.ts:1586`, called at `:1651`). NemoClaw does not install non-NVIDIA accelerator drivers." }, { "name": "Other LangChain, AutoGen, CrewAI, or non-listed agent harnesses", diff --git a/docs/inference/inference-options.mdx b/docs/inference/inference-options.mdx index 9697c0c80e8..8069e2ae230 100644 --- a/docs/inference/inference-options.mdx +++ b/docs/inference/inference-options.mdx @@ -49,7 +49,7 @@ NemoClaw uses provider-specific local tokens for those routes, and rebuilds of l | Google Gemini | Tested | OpenAI-compatible | Uses Google's OpenAI-compatible endpoint | | Hermes Provider | Hermes only | OpenAI-compatible route | Available when onboarding Hermes Agent through `nemohermes` | | Local Ollama | Tested with limitations | Local Ollama API | Available when Ollama is installed or running on the host. Validated default models: `qwen3.6:35b` (high VRAM), `nemotron-3-nano:30b` (medium VRAM), `qwen3.5:9b` (low VRAM fallback). | -| Local NVIDIA NIM | Experimental | Local OpenAI-compatible | Requires `NEMOCLAW_EXPERIMENTAL=1` and a NIM-capable NVIDIA GPU. Host must have the NVIDIA Container Toolkit installed and a CDI spec present (`onboard` asserts CDI presence with `assertCdiNvidiaGpuSpecPresent`, `src/lib/onboard.ts:1581`). NIM images pull from `nvcr.io` and require NGC registry login. NemoClaw gates this path behind the experimental flag because it does not auto-select a NIM image for the host today. You must explicitly pick from the validated image list. Managed vLLM has host-specific default models and is not gated on the same boxes. Validated images referenced in `src/lib/inference/config.ts` and `nemoclaw/src/index.ts`: `nvidia/nemotron-3-super-120b-a12b` (default cloud model), `nvidia/nemotron-3-nano-30b-a3b`, `nvidia/llama-3.3-nemotron-super-49b-v1.5`. | +| Local NVIDIA NIM | Experimental | Local OpenAI-compatible | Requires `NEMOCLAW_EXPERIMENTAL=1` and a NIM-capable NVIDIA GPU. Host must have the NVIDIA Container Toolkit installed and a CDI spec present (`onboard` asserts CDI presence with `assertCdiNvidiaGpuSpecPresent`, `src/lib/onboard.ts:1586`). NIM images pull from `nvcr.io` and require NGC registry login. NemoClaw gates this path behind the experimental flag because it does not auto-select a NIM image for the host today. You must explicitly pick from the validated image list. Managed vLLM has host-specific default models and is not gated on the same boxes. Validated images referenced in `src/lib/inference/config.ts` and `nemoclaw/src/index.ts`: `nvidia/nemotron-3-super-120b-a12b` (default cloud model), `nvidia/nemotron-3-nano-30b-a3b`, `nvidia/llama-3.3-nemotron-super-49b-v1.5`. | | Local vLLM (already running) | Tested with limitations | Local OpenAI-compatible | Appears in the onboarding menu when NemoClaw detects a server already on `localhost:8000`. No flag required. Model is whatever the existing server serves. | | Local vLLM (managed install/start) | Tested with limitations | Local OpenAI-compatible | Appears by default on DGX Spark and DGX Station. Generic Linux NVIDIA GPU hosts require `NEMOCLAW_EXPERIMENTAL=1` or `NEMOCLAW_PROVIDER=install-vllm`. Host must have the NVIDIA Container Toolkit installed and a CDI spec present (`onboard` asserts CDI presence). NemoClaw pulls or starts the stable NGC vLLM container for each host profile. See `src/lib/inference/vllm.ts:55,177` for the pins. DGX Spark and DGX Station use `nvcr.io/nvidia/vllm:26.05.post1-py3`; generic Linux NVIDIA GPU hosts use `nvcr.io/nvidia/vllm:26.03.post1-py3`. Validated defaults are listed in `src/lib/inference/vllm-models.ts`: DGX Spark uses `nvidia/Qwen3.6-35B-A3B-NVFP4`, DGX Station uses `Qwen/Qwen3.6-27B-FP8`, and Linux NVIDIA GPU uses `nvidia/NVIDIA-Nemotron-3-Nano-4B-FP8`. Image pulls require NGC registry login (`docker login nvcr.io`); onboard prompts for the NGC API key when authentication is missing. | {/* provider-status:end */} diff --git a/docs/reference/platform-support.mdx b/docs/reference/platform-support.mdx index 20509a93516..16fabdc1ea5 100644 --- a/docs/reference/platform-support.mdx +++ b/docs/reference/platform-support.mdx @@ -100,7 +100,7 @@ NemoClaw routes inference through the OpenShell gateway. Each row below is a pro | Google Gemini | Tested | OpenAI-compatible | Uses Google's OpenAI-compatible endpoint | | Hermes Provider | Hermes only | OpenAI-compatible route | Available when onboarding Hermes Agent through `nemohermes` | | Local Ollama | Tested with limitations | Local Ollama API | Available when Ollama is installed or running on the host. Validated default models: `qwen3.6:35b` (high VRAM), `nemotron-3-nano:30b` (medium VRAM), `qwen3.5:9b` (low VRAM fallback). | -| Local NVIDIA NIM | Experimental | Local OpenAI-compatible | Requires `NEMOCLAW_EXPERIMENTAL=1` and a NIM-capable NVIDIA GPU. Host must have the NVIDIA Container Toolkit installed and a CDI spec present (`onboard` asserts CDI presence with `assertCdiNvidiaGpuSpecPresent`, `src/lib/onboard.ts:1581`). NIM images pull from `nvcr.io` and require NGC registry login. NemoClaw gates this path behind the experimental flag because it does not auto-select a NIM image for the host today. You must explicitly pick from the validated image list. Managed vLLM has host-specific default models and is not gated on the same boxes. Validated images referenced in `src/lib/inference/config.ts` and `nemoclaw/src/index.ts`: `nvidia/nemotron-3-super-120b-a12b` (default cloud model), `nvidia/nemotron-3-nano-30b-a3b`, `nvidia/llama-3.3-nemotron-super-49b-v1.5`. | +| Local NVIDIA NIM | Experimental | Local OpenAI-compatible | Requires `NEMOCLAW_EXPERIMENTAL=1` and a NIM-capable NVIDIA GPU. Host must have the NVIDIA Container Toolkit installed and a CDI spec present (`onboard` asserts CDI presence with `assertCdiNvidiaGpuSpecPresent`, `src/lib/onboard.ts:1586`). NIM images pull from `nvcr.io` and require NGC registry login. NemoClaw gates this path behind the experimental flag because it does not auto-select a NIM image for the host today. You must explicitly pick from the validated image list. Managed vLLM has host-specific default models and is not gated on the same boxes. Validated images referenced in `src/lib/inference/config.ts` and `nemoclaw/src/index.ts`: `nvidia/nemotron-3-super-120b-a12b` (default cloud model), `nvidia/nemotron-3-nano-30b-a3b`, `nvidia/llama-3.3-nemotron-super-49b-v1.5`. | | Local vLLM (already running) | Tested with limitations | Local OpenAI-compatible | Appears in the onboarding menu when NemoClaw detects a server already on `localhost:8000`. No flag required. Model is whatever the existing server serves. | | Local vLLM (managed install/start) | Tested with limitations | Local OpenAI-compatible | Appears by default on DGX Spark and DGX Station. Generic Linux NVIDIA GPU hosts require `NEMOCLAW_EXPERIMENTAL=1` or `NEMOCLAW_PROVIDER=install-vllm`. Host must have the NVIDIA Container Toolkit installed and a CDI spec present (`onboard` asserts CDI presence). NemoClaw pulls or starts the stable NGC vLLM container for each host profile. See `src/lib/inference/vllm.ts:55,177` for the pins. DGX Spark and DGX Station use `nvcr.io/nvidia/vllm:26.05.post1-py3`; generic Linux NVIDIA GPU hosts use `nvcr.io/nvidia/vllm:26.03.post1-py3`. Validated defaults are listed in `src/lib/inference/vllm-models.ts`: DGX Spark uses `nvidia/Qwen3.6-35B-A3B-NVFP4`, DGX Station uses `Qwen/Qwen3.6-27B-FP8`, and Linux NVIDIA GPU uses `nvidia/NVIDIA-Nemotron-3-Nano-4B-FP8`. Image pulls require NGC registry login (`docker login nvcr.io`); onboard prompts for the NGC API key when authentication is missing. | {/* provider-status-full:end */} @@ -162,7 +162,7 @@ The items below come up in conversations but are explicitly out of scope. They a | Native Kubernetes or OpenShift deployments | Unsupported | NemoClaw runs the sandbox as a Docker container, not a Kubernetes pod. The default Docker-driver topology does not embed k3s. Operator-managed K8s/OpenShift deployments are out of scope; see issue #407 (community OpenShift through agent-sandbox CRD). | | Air-gapped / offline installs | Unsupported | Onboard assumes network reachability for package fetches, container pulls, and provider validation. See open issues #4872 and #2218 (production-deployment epic covering air-gapped support, China network guidance, multi-host topology). | | Windows-on-ARM GPU passthrough | Unsupported | Windows-on-ARM CPU paths run under WSL2 'tested with limitations', but GPU passthrough on WOA is denylisted (`src/lib/onboard/wsl-docker-desktop-gpu.ts:188`, `src/lib/inference/gpu-trust.test.ts:70`). See closed issue #4565. | -| Non-NVIDIA GPUs (AMD/ROCm, Intel Arc, Apple Metal) | Unsupported | Local vLLM and NIM paths assert NVIDIA CDI presence with `assertCdiNvidiaGpuSpecPresent` (`src/lib/onboard.ts:1581`, called at `:1646`). NemoClaw does not install non-NVIDIA accelerator drivers. | +| Non-NVIDIA GPUs (AMD/ROCm, Intel Arc, Apple Metal) | Unsupported | Local vLLM and NIM paths assert NVIDIA CDI presence with `assertCdiNvidiaGpuSpecPresent` (`src/lib/onboard.ts:1586`, called at `:1651`). NemoClaw does not install non-NVIDIA accelerator drivers. | | Other LangChain, AutoGen, CrewAI, or non-listed agent harnesses | Unsupported | LangChain Deep Agents Code is the only integrated LangChain-family harness (see the Agents section above; status `Experimental`). Other LangChain harnesses, AutoGen, CrewAI, and any agent runtime not listed in the Agents table are not integrated. Bringing more harnesses is tracked as a research epic (see open issue #4861) but is not on the current roadmap. | | Multi-user host sharing | Unsupported | Sandboxes are scoped to a single host user. NemoClaw treats multi-user hosts as a risk and warns at onboard; see `docs/security/openclaw-controls.mdx` Multi-user detection. | | Hosted SaaS / managed NemoClaw | Unsupported | There is no managed offering. Supported deployment paths are Local CLI onboard, Remote GPU with Brev CLI, and Brev web UI. | diff --git a/src/lib/actions/sandbox/policy-channel-agent-gate.test.ts b/src/lib/actions/sandbox/policy-channel-agent-gate.test.ts index ed74996d167..65bfba63191 100644 --- a/src/lib/actions/sandbox/policy-channel-agent-gate.test.ts +++ b/src/lib/actions/sandbox/policy-channel-agent-gate.test.ts @@ -108,9 +108,7 @@ describe("addSandboxChannel agent gate", () => { .map((call) => call.map(String).join(" ")) .join("\n"); expect(errorText).toMatch(/Agent 'custom-agent' does not support messaging channels/); - expect(errorText).toMatch( - /Messaging-capable agents: openclaw, hermes, langchain-deepagents-code/, - ); + expect(errorText).toMatch(/Messaging-capable agents: openclaw, hermes/); expect(loadPresetMock).not.toHaveBeenCalled(); expect(applyPresetMock).not.toHaveBeenCalled(); diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index d77b45b341c..c0099865f08 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -925,7 +925,7 @@ export async function addSandboxChannel( console.error( ` Agent '${agent.name}' does not support messaging channels for sandbox '${sandboxName}'.`, ); - console.error(" Messaging-capable agents: openclaw, hermes, langchain-deepagents-code."); + console.error(" Messaging-capable agents: openclaw, hermes."); process.exit(1); } if (!channelSupportedByAgent(canonical, agent)) { diff --git a/src/lib/messaging/AGENTS.md b/src/lib/messaging/AGENTS.md index 8d5eaefdba1..f8c303e5e53 100644 --- a/src/lib/messaging/AGENTS.md +++ b/src/lib/messaging/AGENTS.md @@ -85,17 +85,17 @@ Update `.agents/skills/nemoclaw-user-guide/SKILL.md` only when AI-agent docs rou ## DeepAgents Messaging Artifact Contract -LangChain Deep Agents Code is a terminal-oriented harness. NemoClaw does not run a long-running messaging bridge inside the DeepAgents sandbox today; the integration is artifact-only. +LangChain Deep Agents Code is a terminal-oriented harness. NemoClaw does not run a long-running messaging bridge inside the DeepAgents sandbox today; the integration is artifact-only and is not advertised as public channel support. -- **Build-time artifacts.** Channel manifests render two targets per DeepAgents-supported channel: an env-lines fragment to `~/.deepagents/.env` and a JSON fragment to `~/.deepagents/messaging.json`. The build applier writes both during `--phase post-agent-install` in the DeepAgents Dockerfile. -- **Startup consumer.** `agents/langchain-deepagents-code/start.sh` sources `~/.deepagents/.env` before launching `dcode`, so messaging-related env vars (Telegram bot token, Discord guild ids, Slack app token, etc.) are present in the agent process environment. -- **No inbound bridge.** The harness does not spawn channel bot processes. Inbound messages from Telegram, Discord, or Slack do not currently reach `dcode`. The Ready state reported after rebuild reflects the agent runtime, not channel reachability. A future change must add a bot/bridge process before claiming end-to-end channel functionality. +- **Build-time artifacts.** The build applier can render DeepAgents env-lines fragments to `~/.deepagents/.env` and JSON fragments to `~/.deepagents/messaging.json` for local contract validation. The DeepAgents agent manifest keeps `messaging_platforms.supported: []`, and built-in channel manifests do not list DeepAgents under `supportedAgents`, until a bridge exists. +- **Startup consumer.** `agents/langchain-deepagents-code/start.sh` parses `~/.deepagents/.env` as data with a strict messaging-key allowlist before launching `dcode`, so messaging-related env vars (Telegram bot token, Discord guild ids, Slack app token, etc.) are present in the agent process environment without executing generated shell content. +- **No inbound bridge.** The harness does not spawn channel bot processes. Inbound messages from Telegram, Discord, or Slack do not currently reach `dcode`. `channels add` must reject DeepAgents before policy, provider, credential, registry, or rebuild mutation while this remains true. The Ready state reported after rebuild reflects the agent runtime, not channel reachability. A future change must add a bot/bridge process before claiming end-to-end channel functionality. - **Removal condition.** Drop this artifact-only contract once a DeepAgents-side messaging bridge (or upstream `dcode` feature) consumes `~/.deepagents/messaging.json` and routes messages to/from `dcode`. Until then, this section is the documented limit of the integration. ## Agent Gating and Stale Plan Cleanup - **Invalid state.** A sandbox can be configured with an agent whose manifest declares no messaging support (`messaging_platforms.supported: []`) or an agent name outside the messaging runtime allowlist. Without an explicit gate the channel-add path can still tear down the sandbox before failing at `dockerfile-patch.ts`, and a rebuild can carry a stale `NEMOCLAW_MESSAGING_PLAN_B64` into the Dockerfile patch step for an agent that does not declare the matching `ARG`. The current runtime allowlist is `openclaw`, `hermes`, and `langchain-deepagents-code` — see `MESSAGING_AGENT_IDS` in `utils.ts`. - **Source boundary.** The agent manifest's `messaging_platforms.supported` list is the single source of truth for whether a given agent supports messaging today, and which channels are available for it. The `MessagingAgentId` union in `applier/build/messaging-build-applier.mts` is the runtime allowlist for build-time messaging integration. Both are checked at the action boundary by `isMessagingSupportedAgent(agent)` and `tryGetMessagingAgentId(agent)` in `utils.ts`. The `ChannelManifestRegistry.listAvailable` and `MessagingWorkflowPlanner.supportedChannelIds` paths share these semantics, so an explicit empty allowlist means deny-all everywhere, and a populated allowlist filters to that subset. -- **Source-fix constraint.** Expanding `messaging_platforms.supported` for an agent requires the matching Dockerfile `ARG NEMOCLAW_MESSAGING_PLAN_B64=`, a `MessagingAgentId` entry, per-channel `supportedAgents`, and agent-side render and hook handlers in `applier/build/messaging-build-applier.mts`. Until that stack lands, the gate at the action boundary is the only safe behavior — surfacing the unsupported-agent message in `addSandboxChannel`, clearing the staged plan in `stageMessagingManifestPlanForRebuild`, and stripping stale plans in `persistManifestChannelRemovePlan`. -- **Regression tests.** `src/lib/messaging/utils.test.ts`, `src/lib/messaging/manifest/registry.test.ts`, and `src/lib/messaging/compiler/workflow-planner.test.ts` lock the helper and registry semantics. `src/lib/actions/sandbox/policy-channel-agent-gate.test.ts`, `src/lib/actions/sandbox/policy-channel-cleanup.test.ts`, and `src/lib/actions/sandbox/rebuild-messaging-stage.test.ts` cover the action and rebuild boundaries against the compiled `dist/` modules. `test/channels-add-deepagents-rejection.test.ts` exercises the full `addSandboxChannel` boundary in a spawned Node process to prove no policy, provider, registry, credential, or rebuild call happens before the unsupported-agent exit. +- **Source-fix constraint.** Expanding `messaging_platforms.supported` for an agent requires the matching Dockerfile `ARG NEMOCLAW_MESSAGING_PLAN_B64=`, a `MessagingAgentId` entry, per-channel `supportedAgents`, agent-side render and hook handlers in `applier/build/messaging-build-applier.mts`, and a runtime bridge/health path when the public behavior claims channel readiness. Until that stack lands, the gate at the action boundary is the only safe behavior — surfacing the unsupported-agent message in `addSandboxChannel`, clearing the staged plan in `stageMessagingManifestPlanForRebuild`, and stripping stale plans in `persistManifestChannelRemovePlan`. +- **Regression tests.** `src/lib/messaging/utils.test.ts`, `src/lib/messaging/manifest/registry.test.ts`, and `src/lib/messaging/compiler/workflow-planner.test.ts` lock the helper and registry semantics. `src/lib/actions/sandbox/policy-channel-agent-gate.test.ts`, `src/lib/actions/sandbox/policy-channel-cleanup.test.ts`, `src/lib/actions/sandbox/rebuild-messaging-stage.test.ts`, and `src/lib/onboard/machine/handlers/sandbox.test.ts` cover the action, rebuild, and onboard-resume boundaries against stale or unsupported messaging plans. `test/channels-add-deepagents-rejection.test.ts` exercises the full DeepAgents `addSandboxChannel` boundary in a spawned Node process to prove no policy, provider, registry, credential, or rebuild call happens before the unsupported-agent exit. - **Removal condition.** Drop the gate (or shrink it back to known runtimes only) once the targeted agent has a Dockerfile `ARG NEMOCLAW_MESSAGING_PLAN_B64=`, a `MessagingAgentId` entry, populated `messaging_platforms.supported`, the channel manifests list it under `supportedAgents`, and `applier/build/messaging-build-applier.mts` resolves its render and runtime targets. At that point the empty-allowlist branch becomes unreachable for that agent and the action boundary can rely on planner-level validation alone. diff --git a/src/lib/messaging/channels/discord/manifest.ts b/src/lib/messaging/channels/discord/manifest.ts index 6948c82097f..6bffd00e96b 100644 --- a/src/lib/messaging/channels/discord/manifest.ts +++ b/src/lib/messaging/channels/discord/manifest.ts @@ -8,7 +8,7 @@ export const discordManifest = { id: "discord", displayName: "Discord", description: "Discord bot messaging", - supportedAgents: ["openclaw", "hermes", "langchain-deepagents-code"], + supportedAgents: ["openclaw", "hermes"], auth: { mode: "token-paste", }, diff --git a/src/lib/messaging/channels/manifests.test.ts b/src/lib/messaging/channels/manifests.test.ts index 0ecd3c87785..8d08c248b18 100644 --- a/src/lib/messaging/channels/manifests.test.ts +++ b/src/lib/messaging/channels/manifests.test.ts @@ -274,15 +274,11 @@ describe("built-in channel manifests", () => { teams: teamsManifest, }; - const channelsSupportingDeepAgents = new Set(["telegram", "discord", "slack"]); for (const [channelId, manifest] of Object.entries(manifests)) { const legacy = KNOWN_CHANNELS[channelId]; expect(manifest.description).toBe(legacy.description); expect(policyPresetNames(manifest)).toEqual([channelId]); - const expectedAgents = channelsSupportingDeepAgents.has(channelId) - ? ["openclaw", "hermes", "langchain-deepagents-code"] - : ["openclaw", "hermes"]; - expect(manifest.supportedAgents).toEqual(expectedAgents); + expect(manifest.supportedAgents).toEqual(["openclaw", "hermes"]); expect(manifest.auth.mode).toBe(legacy.loginMethod ?? "token-paste"); } diff --git a/src/lib/messaging/channels/slack/manifest.ts b/src/lib/messaging/channels/slack/manifest.ts index 3dd99740c77..d9527542b91 100644 --- a/src/lib/messaging/channels/slack/manifest.ts +++ b/src/lib/messaging/channels/slack/manifest.ts @@ -8,7 +8,7 @@ export const slackManifest = { id: "slack", displayName: "Slack", description: "Slack bot messaging", - supportedAgents: ["openclaw", "hermes", "langchain-deepagents-code"], + supportedAgents: ["openclaw", "hermes"], auth: { mode: "token-paste", }, diff --git a/src/lib/messaging/channels/telegram/manifest.ts b/src/lib/messaging/channels/telegram/manifest.ts index 5c5dd5bd78d..8de213287da 100644 --- a/src/lib/messaging/channels/telegram/manifest.ts +++ b/src/lib/messaging/channels/telegram/manifest.ts @@ -12,7 +12,7 @@ export const telegramManifest = { "For Telegram group chats, disable privacy mode in @BotFather (/setprivacy -> your bot -> Disable).", "After changing privacy mode, remove and re-add the bot to each group before testing @mentions.", ], - supportedAgents: ["openclaw", "hermes", "langchain-deepagents-code"], + supportedAgents: ["openclaw", "hermes"], auth: { mode: "token-paste", }, diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 0c2a0faa227..5d2149a20c9 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -125,6 +125,7 @@ const { setupMessagingChannels: setupMessagingChannelsImpl, readMessagingPlanFromEnv, writePlanToEnv, + clearPlanEnv, getRegistrySandboxMessagingPlan, MessagingHostStateApplier, } = require("./onboard/messaging-channel-setup") as typeof import("./onboard/messaging-channel-setup"); @@ -5124,6 +5125,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { setupMessagingChannels, readMessagingPlanFromEnv, writePlanToEnv, + clearPlanEnv, getRegistrySandboxMessagingPlan, promptValidatedSandboxName, selectResourceProfileForSandbox: () => diff --git a/src/lib/onboard/machine/core-flow-phases.test.ts b/src/lib/onboard/machine/core-flow-phases.test.ts index 431891000b6..bc074e4d39a 100644 --- a/src/lib/onboard/machine/core-flow-phases.test.ts +++ b/src/lib/onboard/machine/core-flow-phases.test.ts @@ -159,6 +159,7 @@ function createPhases( setupMessagingChannels: vi.fn(async () => ["slack", "discord"]), readMessagingPlanFromEnv: () => null, writePlanToEnv: vi.fn(), + clearPlanEnv: vi.fn(), getRegistrySandboxMessagingPlan: () => null, promptValidatedSandboxName: vi.fn(async () => "my-sandbox"), selectResourceProfileForSandbox: vi.fn(async () => null), diff --git a/src/lib/onboard/machine/handlers/sandbox.test.ts b/src/lib/onboard/machine/handlers/sandbox.test.ts index cd3df4ea5d1..23ed0e5f6ac 100644 --- a/src/lib/onboard/machine/handlers/sandbox.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox.test.ts @@ -77,7 +77,7 @@ async function withEnv(key: string, value: string, run: () => Promise): Pr } type Gpu = { type: string } | null; -type Agent = { displayName?: string } | null; +type Agent = { displayName?: string; name?: string; messagingPlatforms?: string[] } | null; type WebSearchConfig = { fetchEnabled: true }; type MessagingChannelConfig = Record; type SandboxGpuConfig = { sandboxGpuEnabled: boolean; mode: string }; @@ -103,6 +103,7 @@ function createDeps( return session; }), persistMessaging: vi.fn(), + clearPlanEnv: vi.fn(), removeSandbox: vi.fn(), repairSandbox: vi.fn(), validateBrave: vi.fn(async () => "brave-key"), @@ -152,6 +153,7 @@ function createDeps( setupMessagingChannels: calls.setupMessaging, readMessagingPlanFromEnv: () => null, writePlanToEnv: () => undefined, + clearPlanEnv: calls.clearPlanEnv, getRegistrySandboxMessagingPlan: () => null, promptValidatedSandboxName: calls.promptName, selectResourceProfileForSandbox: calls.selectResourceProfile, @@ -573,6 +575,56 @@ describe("handleSandboxState", () => { expect(getSession().messagingPlan).toEqual(emptyRebuildPlan); }); + it("clears env-staged messaging plans when the current agent declares an empty allowlist", async () => { + const stalePlan = makeMinimalPlan("my-assistant", "openclaw", ["telegram"]); + const session = createSession({ sandboxName: "my-assistant", messagingPlan: stalePlan }); + const getRecordedMessagingChannelsForResume = vi.fn(() => ["telegram"]); + const writePlanToEnv = vi.fn(); + const { deps, calls, getSession } = createDeps({ + getRecordedMessagingChannelsForResume, + writePlanToEnv, + readMessagingPlanFromEnv: () => stalePlan, + }); + + const result = await handleSandboxState({ + ...baseOptions(deps, session), + resume: true, + sandboxName: "my-assistant", + agent: { name: "openclaw", messagingPlatforms: [] }, + }); + + expect(calls.clearPlanEnv).toHaveBeenCalledTimes(1); + expect(writePlanToEnv).not.toHaveBeenCalled(); + expect(result.selectedMessagingChannels).toEqual([]); + expect((calls.createSandbox.mock.calls[0] as unknown[])[6]).toEqual([]); + expect(getSession().messagingPlan).toBeNull(); + }); + + it("clears registry messaging plans when the current agent is unknown", async () => { + const registryPlan = makeMinimalPlan("my-assistant", "openclaw", ["discord"]); + const session = createSession({ sandboxName: "my-assistant", messagingPlan: registryPlan }); + const getRecordedMessagingChannelsForResume = vi.fn(() => ["discord"]); + const writePlanToEnv = vi.fn(); + const { deps, calls, getSession } = createDeps({ + getRecordedMessagingChannelsForResume, + writePlanToEnv, + readMessagingPlanFromEnv: () => null, + getRegistrySandboxMessagingPlan: () => registryPlan, + }); + + await handleSandboxState({ + ...baseOptions(deps, session), + resume: true, + sandboxName: "my-assistant", + agent: { name: "custom-agent", messagingPlatforms: ["discord"] }, + }); + + expect(calls.clearPlanEnv).toHaveBeenCalledTimes(1); + expect(writePlanToEnv).not.toHaveBeenCalled(); + expect((calls.createSandbox.mock.calls[0] as unknown[])[6]).toEqual([]); + expect(getSession().messagingPlan).toBeNull(); + }); + it("does not restore plan to env when registry has no entry", async () => { const session = createSession({ sandboxName: "my-assistant", diff --git a/src/lib/onboard/machine/handlers/sandbox.ts b/src/lib/onboard/machine/handlers/sandbox.ts index 7b0697aca01..ef51b159bba 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -1,7 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import type { SandboxMessagingPlan } from "../../../messaging/manifest"; +import { isMessagingSupportedAgent, tryGetMessagingAgentId } from "../../../messaging"; +import type { MessagingAgentId, SandboxMessagingPlan } from "../../../messaging/manifest"; import { hashCredential } from "../../../security/credential-hash"; import type { Session, SessionUpdates } from "../../../state/onboard-session"; import { getActiveChannelsFromPlan, getChannelsFromPlan } from "../../messaging-plan-session"; @@ -84,6 +85,7 @@ export interface SandboxStateOptions< ): Promise; readMessagingPlanFromEnv(): SandboxMessagingPlan | null; writePlanToEnv(plan: SandboxMessagingPlan): void; + clearPlanEnv(): void; getRegistrySandboxMessagingPlan(sandboxName: string): SandboxMessagingPlan | null; promptValidatedSandboxName(agent: Agent): Promise; selectResourceProfileForSandbox(): Promise; @@ -154,6 +156,85 @@ function refreshCredentialHashesFromEnv(plan: SandboxMessagingPlan): { return changed ? { plan: { ...plan, credentialBindings }, changed } : { plan, changed }; } +type MessagingAgentLike = { + readonly name?: string; + readonly messagingPlatforms?: readonly string[] | null; +}; + +function resolveCurrentMessagingAgent(agent: unknown): { + readonly agentId: MessagingAgentId | null; + readonly supportedChannelIds: readonly string[] | null; +} { + const descriptor = (agent ?? {}) as MessagingAgentLike; + const name = typeof descriptor.name === "string" ? descriptor.name.trim() : ""; + if (!name) return { agentId: null, supportedChannelIds: null }; + const agentId = tryGetMessagingAgentId(descriptor); + if (agentId === null || !isMessagingSupportedAgent(descriptor)) { + return { agentId: null, supportedChannelIds: [] }; + } + return { + agentId, + supportedChannelIds: Array.isArray(descriptor.messagingPlatforms) + ? descriptor.messagingPlatforms + : null, + }; +} + +function filterChannelNamesForCurrentAgent( + channelIds: readonly string[], + agent: unknown, +): string[] { + const availability = resolveCurrentMessagingAgent(agent); + if (availability.supportedChannelIds === null) return [...channelIds]; + if (availability.agentId === null || availability.supportedChannelIds.length === 0) return []; + const supported = new Set(availability.supportedChannelIds); + return channelIds.filter((channelId) => supported.has(channelId)); +} + +function filterMessagingPlanForCurrentAgent( + plan: SandboxMessagingPlan, + agent: unknown, +): SandboxMessagingPlan | null { + const availability = resolveCurrentMessagingAgent(agent); + if (availability.supportedChannelIds === null) return plan; + if (availability.agentId === null || plan.agent !== availability.agentId) return null; + const supported = new Set(availability.supportedChannelIds); + const channels = plan.channels.filter((channel) => supported.has(channel.channelId)); + if (channels.length === 0) return null; + if (channels.length === plan.channels.length) return plan; + + const remainingChannelIds = new Set(channels.map((channel) => channel.channelId)); + const keepEntry = (entry: T): boolean => + remainingChannelIds.has(entry.channelId); + const networkEntries = plan.networkPolicy.entries.filter(keepEntry); + const filterRuntimeSetup = (entries?: readonly T[]) => + (entries ?? []).filter(keepEntry); + + return { + ...plan, + channels, + disabledChannels: plan.disabledChannels.filter((channelId) => + remainingChannelIds.has(channelId), + ), + credentialBindings: plan.credentialBindings.filter(keepEntry), + networkPolicy: { + presets: [...new Set(networkEntries.map((entry) => entry.presetName))].sort(), + entries: networkEntries, + }, + agentRender: plan.agentRender.filter(keepEntry), + buildSteps: plan.buildSteps.filter(keepEntry), + runtimeSetup: plan.runtimeSetup + ? { + nodePreloads: filterRuntimeSetup(plan.runtimeSetup.nodePreloads), + envAliases: filterRuntimeSetup(plan.runtimeSetup.envAliases), + secretScans: filterRuntimeSetup(plan.runtimeSetup.secretScans), + } + : undefined, + stateUpdates: plan.stateUpdates.filter(keepEntry), + healthChecks: plan.healthChecks.filter(keepEntry), + }; +} + export async function handleSandboxState< Gpu, Agent, @@ -239,7 +320,18 @@ export async function handleSandboxState< if (resumeSandbox) { if (webSearchConfig) deps.note(" [resume] Reusing Brave Search configuration already baked into the sandbox."); - selectedMessagingChannels = getActiveChannelsFromPlan(session?.messagingPlan) ?? []; + const currentMessagingPlan = session?.messagingPlan ?? null; + const filteredPlan = currentMessagingPlan + ? filterMessagingPlanForCurrentAgent(currentMessagingPlan, agent) + : null; + if (filteredPlan !== currentMessagingPlan) { + deps.clearPlanEnv(); + session = deps.updateSession((current) => { + current.messagingPlan = filteredPlan; + return current; + }); + } + selectedMessagingChannels = getActiveChannelsFromPlan(filteredPlan) ?? []; deps.skippedStepMessage("sandbox", sandboxName); await deps.recordStateSkipped("sandbox", { reason: "resume", sandboxName }); } else { @@ -313,18 +405,31 @@ export async function handleSandboxState< const registryMessagingPlan = sandboxName ? deps.getRegistrySandboxMessagingPlan(sandboxName) : null; + const reuseMessagingPlan = (plan: SandboxMessagingPlan, writeToEnv: boolean): void => { + const refreshed = refreshCredentialHashesFromEnv(plan); + const filtered = filterMessagingPlanForCurrentAgent(refreshed.plan, agent); + if (!filtered) { + deps.clearPlanEnv(); + messagingPlan = null; + selectedMessagingChannels = []; + return; + } + messagingPlan = filtered; + selectedMessagingChannels = getActiveChannelsFromPlan(messagingPlan) ?? []; + if (writeToEnv || refreshed.changed || filtered !== refreshed.plan) { + deps.writePlanToEnv(filtered); + } + }; + if (recordedMessagingChannels) { - selectedMessagingChannels = recordedMessagingChannels; + selectedMessagingChannels = filterChannelNamesForCurrentAgent( + recordedMessagingChannels, + agent, + ); if (envMessagingPlan) { - const refreshed = refreshCredentialHashesFromEnv(envMessagingPlan); - messagingPlan = refreshed.plan; - if (refreshed.changed) deps.writePlanToEnv(refreshed.plan); - selectedMessagingChannels = getActiveChannelsFromPlan(messagingPlan) ?? []; + reuseMessagingPlan(envMessagingPlan, false); } else if (registryMessagingPlan) { - const refreshed = refreshCredentialHashesFromEnv(registryMessagingPlan); - deps.writePlanToEnv(refreshed.plan); - messagingPlan = refreshed.plan; - selectedMessagingChannels = getActiveChannelsFromPlan(messagingPlan) ?? []; + reuseMessagingPlan(registryMessagingPlan, true); } if (selectedMessagingChannels.length > 0) { deps.note( @@ -332,19 +437,32 @@ export async function handleSandboxState< ); } } else if (envMessagingPlan) { - const refreshed = refreshCredentialHashesFromEnv(envMessagingPlan); - messagingPlan = refreshed.plan; - if (refreshed.changed) deps.writePlanToEnv(refreshed.plan); - selectedMessagingChannels = getActiveChannelsFromPlan(messagingPlan) ?? []; + reuseMessagingPlan(envMessagingPlan, false); } else if (registryMessagingPlan) { - const refreshed = refreshCredentialHashesFromEnv(registryMessagingPlan); - deps.writePlanToEnv(refreshed.plan); - messagingPlan = refreshed.plan; - selectedMessagingChannels = getActiveChannelsFromPlan(messagingPlan) ?? []; + reuseMessagingPlan(registryMessagingPlan, true); } else { - const existing = getChannelsFromPlan(session?.messagingPlan); + const existingChannels = getChannelsFromPlan(session?.messagingPlan); + const existing = existingChannels + ? filterChannelNamesForCurrentAgent(existingChannels, agent) + : existingChannels; selectedMessagingChannels = await deps.setupMessagingChannels(agent, existing, sandboxName); + selectedMessagingChannels = filterChannelNamesForCurrentAgent( + selectedMessagingChannels, + agent, + ); messagingPlan = deps.readMessagingPlanFromEnv(); + if (messagingPlan) { + const filtered = filterMessagingPlanForCurrentAgent(messagingPlan, agent); + if (!filtered) { + deps.clearPlanEnv(); + messagingPlan = null; + selectedMessagingChannels = []; + } else if (filtered !== messagingPlan) { + messagingPlan = filtered; + selectedMessagingChannels = getActiveChannelsFromPlan(messagingPlan) ?? []; + deps.writePlanToEnv(filtered); + } + } } session = deps.updateSession((current) => { current.messagingPlan = messagingPlan; diff --git a/src/lib/onboard/messaging-channel-setup.ts b/src/lib/onboard/messaging-channel-setup.ts index f9514e15ff5..556c89a0c80 100644 --- a/src/lib/onboard/messaging-channel-setup.ts +++ b/src/lib/onboard/messaging-channel-setup.ts @@ -269,6 +269,10 @@ export function writePlanToEnv(plan: SandboxMessagingPlan): void { MessagingSetupApplier.writePlanToEnv(plan); } +export function clearPlanEnv(): void { + MessagingSetupApplier.clearPlanEnv(); +} + export function getRegistrySandboxMessagingPlan(sandboxName: string): SandboxMessagingPlan | null { return registry.getHydratedMessagingPlanFromEntry(registry.getSandbox(sandboxName)); } diff --git a/src/lib/onboard/messaging-state.test.ts b/src/lib/onboard/messaging-state.test.ts index 84d7812c346..e3c506d67c6 100644 --- a/src/lib/onboard/messaging-state.test.ts +++ b/src/lib/onboard/messaging-state.test.ts @@ -6,7 +6,7 @@ import { describe, expect, it } from "vitest"; import type { AgentDefinition } from "../agent/defs"; import { filterEnabledChannelsByAgent, resolveQrSelectedChannels } from "./messaging-state"; -function agent(messagingPlatforms: string[]): AgentDefinition { +function agent(messagingPlatforms: string[] | undefined): AgentDefinition { return { messagingPlatforms } as unknown as AgentDefinition; } @@ -17,8 +17,12 @@ describe("filterEnabledChannelsByAgent", () => { ]); }); - it("keeps every channel when the agent declares no supported list", () => { - expect(filterEnabledChannelsByAgent(["whatsapp", "telegram"], agent([]))).toEqual([ + it("drops every channel when the agent declares an explicit empty supported list", () => { + expect(filterEnabledChannelsByAgent(["whatsapp", "telegram"], agent([]))).toEqual([]); + }); + + it("keeps every channel when the agent has no supported-list metadata", () => { + expect(filterEnabledChannelsByAgent(["whatsapp", "telegram"], agent(undefined))).toEqual([ "whatsapp", "telegram", ]); diff --git a/src/lib/onboard/messaging-state.ts b/src/lib/onboard/messaging-state.ts index ab3a19b9ee0..98d151e8a3d 100644 --- a/src/lib/onboard/messaging-state.ts +++ b/src/lib/onboard/messaging-state.ts @@ -24,7 +24,9 @@ export function filterEnabledChannelsByAgent supported.includes(n)) as T; } diff --git a/test/channels-add-deepagents-rejection.test.ts b/test/channels-add-deepagents-rejection.test.ts index dc09b000762..469b2839e90 100644 --- a/test/channels-add-deepagents-rejection.test.ts +++ b/test/channels-add-deepagents-rejection.test.ts @@ -1,10 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 // -// Behaviour-level regression: `nemoclaw channels add ` on an -// agent whose manifest declares no messaging support must exit nonzero before -// any preset load, policy mutation, provider upsert, registry write, credential -// save, prompt, rebuild call, or openshell invocation. +// Behaviour-level regression: `nemoclaw channels add ` on +// DeepAgents must exit nonzero before any preset load, policy mutation, +// provider upsert, registry write, credential save, prompt, rebuild call, or +// openshell invocation while DeepAgents has only artifact-level messaging +// render and no inbound channel bridge. // // Spawns the assembled `addSandboxChannel` action in a real Node process so // the entire module graph loads, then asserts the no-mutation invariant from @@ -130,8 +131,8 @@ module.exports = { } describe("addSandboxChannel agent gate (behaviour)", () => { - it("custom non-messaging agent channels add discord exits non-mutatingly with the unsupported-agent message", () => { - const script = `${buildPreamble("custom-agent")} + it("DeepAgents channels add discord exits non-mutatingly with the unsupported-agent message", () => { + const script = `${buildPreamble("langchain-deepagents-code")} const ctx = module.exports; (async () => { let caught = null; @@ -177,14 +178,12 @@ const ctx = module.exports; assert.equal(payload.exitCode, 1, "expected addSandboxChannel to exit with code 1"); assert.ok( payload.errors.some((msg) => - /Agent 'custom-agent' does not support messaging channels/.test(msg), + /Agent 'langchain-deepagents-code' does not support messaging channels/.test(msg), ), `missing unsupported-agent error in stderr: ${JSON.stringify(payload.errors)}`, ); assert.ok( - payload.errors.some((msg) => - /Messaging-capable agents: openclaw, hermes, langchain-deepagents-code/.test(msg), - ), + payload.errors.some((msg) => /Messaging-capable agents: openclaw, hermes/.test(msg)), `missing supported-agents hint in stderr: ${JSON.stringify(payload.errors)}`, ); diff --git a/test/langchain-deepagents-code-image.test.ts b/test/langchain-deepagents-code-image.test.ts index a1729f066d4..d1d1184c6b8 100644 --- a/test/langchain-deepagents-code-image.test.ts +++ b/test/langchain-deepagents-code-image.test.ts @@ -14,10 +14,16 @@ function readAgentFile(name: string): string { return fs.readFileSync(path.join(agentDir, name), "utf8"); } -function makeStartScriptFixture(tempDir: string): { envFile: string; scriptPath: string } { +function makeStartScriptFixture(tempDir: string): { + envFile: string; + messagingEnvFile: string; + scriptPath: string; +} { const envFile = path.join(tempDir, "proxy-env.sh"); + const messagingEnvFile = path.join(tempDir, "messaging.env"); const scriptPath = path.join(tempDir, "start.sh"); const fixture = readAgentFile("start.sh") + .replace('local env_file="/sandbox/.deepagents/.env"', `local env_file="${messagingEnvFile}"`) .replace("local target=/tmp/nemoclaw-proxy-env.sh", `local target="${envFile}"`) .replace( 'tmp="$(mktemp /tmp/nemoclaw-proxy-env.XXXXXX)"', @@ -25,7 +31,7 @@ function makeStartScriptFixture(tempDir: string): { envFile: string; scriptPath: ); fs.writeFileSync(scriptPath, fixture, "utf8"); fs.chmodSync(scriptPath, 0o755); - return { envFile, scriptPath }; + return { envFile, messagingEnvFile, scriptPath }; } describe("LangChain Deep Agents Code image contracts", () => { @@ -43,6 +49,16 @@ describe("LangChain Deep Agents Code image contracts", () => { ); }); + it("declares the messaging plan build arg before the DeepAgents build applier runs", () => { + const dockerfile = readAgentFile("Dockerfile"); + + expect(dockerfile).toContain("ARG NEMOCLAW_MESSAGING_PLAN_B64="); + expect(dockerfile).toContain("NEMOCLAW_MESSAGING_PLAN_B64=${NEMOCLAW_MESSAGING_PLAN_B64}"); + expect(dockerfile.indexOf("ARG NEMOCLAW_MESSAGING_PLAN_B64=")).toBeLessThan( + dockerfile.indexOf("messaging-build-applier.mts --agent langchain-deepagents-code"), + ); + }); + it("does not serialize provider or optional service secrets into the shell env file", () => { const startScript = readAgentFile("start.sh"); @@ -74,6 +90,49 @@ describe("LangChain Deep Agents Code image contracts", () => { expect(envFileText).toContain("export https_proxy=https://safe-proxy.example:8443"); }); + it("loads generated messaging env values literally without command execution", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-dcode-start-")); + const { envFile, messagingEnvFile, scriptPath } = makeStartScriptFixture(tempDir); + const marker = path.join(tempDir, "nemoclaw-pwned"); + fs.writeFileSync( + messagingEnvFile, + [ + `DISCORD_ALLOWED_USERS=$(touch ${marker})`, + `SLACK_ALLOWED_CHANNELS=C123;touch ${marker}`, + `UNTRUSTED_KEY=$(touch ${marker})`, + ].join("\n"), + "utf8", + ); + + const output = execFileSync( + "bash", + [ + scriptPath, + "sh", + "-c", + [ + 'cat "$NEMOCLAW_TEST_PROXY_ENV"', + 'printf "\\nENV_DISCORD_ALLOWED_USERS=%s\\n" "$DISCORD_ALLOWED_USERS"', + 'printf "ENV_SLACK_ALLOWED_CHANNELS=%s\\n" "$SLACK_ALLOWED_CHANNELS"', + 'test ! -e "$NEMOCLAW_PWNED"', + ].join("; "), + ], + { + env: { + NEMOCLAW_TEST_PROXY_ENV: envFile, + NEMOCLAW_PWNED: marker, + PATH: process.env.PATH ?? "/usr/bin:/bin", + }, + encoding: "utf8", + }, + ); + + expect(output).toContain(`ENV_DISCORD_ALLOWED_USERS=$(touch ${marker})`); + expect(output).toContain(`ENV_SLACK_ALLOWED_CHANNELS=C123;touch ${marker}`); + expect(output).not.toContain("UNTRUSTED_KEY"); + expect(fs.existsSync(marker)).toBe(false); + }); + it("omits and unsets credential-bearing proxy URLs", () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-dcode-start-")); const { envFile, scriptPath } = makeStartScriptFixture(tempDir); diff --git a/test/messaging-build-applier.test.ts b/test/messaging-build-applier.test.ts index 55380686894..e7ded8c3bfc 100644 --- a/test/messaging-build-applier.test.ts +++ b/test/messaging-build-applier.test.ts @@ -943,6 +943,82 @@ describe("messaging-build-applier.mts: agent-install", () => { } }); + it("applies DeepAgents messaging render to .env and messaging.json without raw tokens", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-deepagents-render-")); + const plan = { + schemaVersion: 1, + sandboxName: "test-sandbox", + agent: "langchain-deepagents-code", + channels: [{ channelId: "discord", active: true }], + credentialBindings: [ + { + channelId: "discord", + credentialId: "botToken", + providerEnvKey: "DISCORD_BOT_TOKEN", + placeholder: "openshell:resolve:env:DISCORD_BOT_TOKEN", + }, + ], + agentRender: [ + { + channelId: "discord", + agent: "langchain-deepagents-code", + target: "~/.deepagents/.env", + kind: "env-lines", + renderId: "discord-deepagents-env", + lines: [ + "DISCORD_BOT_TOKEN=openshell:resolve:env:DISCORD_BOT_TOKEN", + "NEMOCLAW_DISCORD_GUILD_IDS=1234567890", + ], + }, + { + channelId: "discord", + agent: "langchain-deepagents-code", + target: "~/.deepagents/messaging.json", + kind: "json-fragment", + path: "channels.discord", + value: { enabled: true, requireMention: true }, + }, + ], + buildSteps: [], + }; + + try { + const result = spawnSync( + "node", + [ + "--experimental-strip-types", + SCRIPT_PATH, + "--agent", + "langchain-deepagents-code", + "--phase", + "post-agent-install", + ], + { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + env: { + PATH: process.env.PATH || "/usr/bin:/bin", + HOME: tmp, + NEMOCLAW_MESSAGING_PLAN_B64: Buffer.from(JSON.stringify(plan)).toString("base64"), + DISCORD_BOT_TOKEN: "raw-discord-token", + }, + timeout: 10_000, + }, + ); + + expect(result.status, result.stderr).toBe(0); + const envText = fs.readFileSync(path.join(tmp, ".deepagents", ".env"), "utf-8"); + expect(envText).toContain("DISCORD_BOT_TOKEN=openshell:resolve:env:DISCORD_BOT_TOKEN"); + expect(envText).toContain("NEMOCLAW_DISCORD_GUILD_IDS=1234567890"); + expect(envText).not.toContain("raw-discord-token"); + expect( + JSON.parse(fs.readFileSync(path.join(tmp, ".deepagents", "messaging.json"), "utf-8")), + ).toEqual({ channels: { discord: { enabled: true, requireMention: true } } }); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + it("rejects multiline env render lines from serialized plans", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-env-line-injection-")); const plan = { From 4b6a0a4523e9ffff8abdcc476d67c03d5f24477a Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 24 Jun 2026 15:15:21 -0700 Subject: [PATCH 12/12] chore(onboard): keep entrypoint line-neutral --- src/lib/onboard.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 5d2149a20c9..64006808f2d 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -5145,7 +5145,6 @@ async function onboard(opts: OnboardOptions = {}): Promise { exitProcess: (code) => process.exit(code), }, }); - const coreFlowResult = await runCoreOnboardFlowSlice({ context: coreFlowContext, runtime: onboardRuntimeBoundary.getRuntime(), @@ -5153,7 +5152,6 @@ async function onboard(opts: OnboardOptions = {}): Promise { resume, recordStateResult: recordCompatibleStateResult, }); - const coreContext = coreFlowResult.context; session = coreContext.session; sandboxName = coreContext.sandboxName;