diff --git a/.agents/skills/nemoclaw-contributor-onboard-messaging-channel/SKILL.md b/.agents/skills/nemoclaw-contributor-onboard-messaging-channel/SKILL.md index ce5458b996..63ae3a4887 100644 --- a/.agents/skills/nemoclaw-contributor-onboard-messaging-channel/SKILL.md +++ b/.agents/skills/nemoclaw-contributor-onboard-messaging-channel/SKILL.md @@ -57,7 +57,7 @@ Start with the manifest. Add core code only when the manifest vocabulary cannot 3. Add hooks under `channels//hooks/` only for enrollment, external reachability checks, QR capture, conflict checks, runtime status, or health probes that cannot be static manifest data. 4. Register the manifest in `channels/built-ins.ts`, template resolver in `channels/template-resolver.ts`, and hook handlers in `hooks/builtins.ts`. 5. Add `nemoclaw-blueprint/policies/presets/.yaml` when the manifest declares a policy preset. Keep messaging-specific egress opt-in unless the project policy says otherwise. -6. Update `agents/openclaw/manifest.yaml` and/or `agents/hermes/manifest.yaml` so supported platforms match the manifest `supportedAgents`. +6. Declare channel support only in `src/lib/messaging/channels//manifest.ts` through `supportedAgents`. Do not edit agent manifests for channel availability unless a separate agent contract changed. 7. Add agent package install metadata when the channel needs an external agent plugin. For OpenClaw plugin packages, use this shape unless source evidence says otherwise: ```ts diff --git a/agents/hermes/manifest.yaml b/agents/hermes/manifest.yaml index cd778d3ffe..816aae85c6 100644 --- a/agents/hermes/manifest.yaml +++ b/agents/hermes/manifest.yaml @@ -103,26 +103,6 @@ device_pairing: false web_auth_method: bearer_token web_auth_env: API_SERVER_KEY -# ── Messaging platforms ───────────────────────────────────────── -# Hermes natively supports 14 platforms. We enable the platforms with -# OpenShell policy and NemoClaw config support. WeChat uses the iLink -# gateway (host-side QR login during onboard); the in-sandbox Hermes -# adapter reads WEIXIN_TOKEN + WEIXIN_ACCOUNT_ID per -# https://hermes-agent.nousresearch.com/docs/user-guide/messaging/weixin. -# WhatsApp pairs in the sandbox via `hermes whatsapp`; the selected channel -# bakes WHATSAPP_ENABLED/WHATSAPP_MODE into .env and preserves session state. -# Microsoft Teams uses the Bot Framework webhook adapter at /api/messages. -messaging_platforms: - supported: - - telegram - - discord - - slack - - wechat - - whatsapp - - teams - # Future: signal, matrix, mattermost, email, etc. - # Each needs a network policy entry before enabling. - # ── Inference ─────────────────────────────────────────────────── # Hermes supports custom OpenAI-compatible endpoints natively via # provider: "custom" + base_url in config.yaml. This is how we diff --git a/agents/langchain-deepagents-code/Dockerfile b/agents/langchain-deepagents-code/Dockerfile index ede9bcd0b7..d8c297890f 100644 --- a/agents/langchain-deepagents-code/Dockerfile +++ b/agents/langchain-deepagents-code/Dockerfile @@ -25,10 +25,9 @@ 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 /src/lib/messaging \ + && chmod -R a+rX /opt/nemoclaw-blueprint \ && python3 /opt/nemoclaw-deepagents-code/patch-managed-deepagents-code.py \ && rm -f /usr/local/bin/dcode /usr/local/bin/deepagents-code /opt/venv/bin/dcode /opt/venv/bin/deepagents-code \ && install -m 0755 /usr/local/lib/nemoclaw/dcode-wrapper.sh /usr/local/bin/dcode \ @@ -44,7 +43,6 @@ 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 \ @@ -56,7 +54,6 @@ 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 \ @@ -70,11 +67,6 @@ 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/agents/langchain-deepagents-code/manifest.yaml b/agents/langchain-deepagents-code/manifest.yaml index 609174893f..8e51023bce 100644 --- a/agents/langchain-deepagents-code/manifest.yaml +++ b/agents/langchain-deepagents-code/manifest.yaml @@ -58,12 +58,6 @@ user_managed_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: [] - # ── Inference ─────────────────────────────────────────────────── # V1 routes NVIDIA/OpenAI-compatible selections through OpenShell's managed # inference.local endpoint using Deep Agents Code's OpenAI-compatible provider. diff --git a/agents/langchain-deepagents-code/start.sh b/agents/langchain-deepagents-code/start.sh index 16149d249e..320df4abb5 100755 --- a/agents/langchain-deepagents-code/start.sh +++ b/agents/langchain-deepagents-code/start.sh @@ -51,33 +51,6 @@ 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 - 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() { local target=/tmp/nemoclaw-proxy-env.sh local tmp @@ -101,22 +74,11 @@ 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 # With no command, this invocation IS the sandbox's long-running entrypoint. diff --git a/agents/openclaw/manifest.yaml b/agents/openclaw/manifest.yaml index ba2f95a2f6..1930acdf66 100644 --- a/agents/openclaw/manifest.yaml +++ b/agents/openclaw/manifest.yaml @@ -75,16 +75,6 @@ user_managed_files: device_pairing: true web_auth_method: device_pairing -# ── Messaging platforms ───────────────────────────────────────── -messaging_platforms: - supported: - - telegram - - discord - - slack - - wechat - - whatsapp - - teams - # ── Inference ─────────────────────────────────────────────────── inference: provider_type: gateway_managed diff --git a/ci/platform-matrix.json b/ci/platform-matrix.json index bfbcee9d55..af547eb821 100644 --- a/ci/platform-matrix.json +++ b/ci/platform-matrix.json @@ -301,7 +301,7 @@ { "name": "WhatsApp", "status": "caveated", - "notes": "Supported by both OpenClaw and Hermes (see `messaging_platforms` in `agents/openclaw/manifest.yaml` and `agents/hermes/manifest.yaml`). Pairing happens in the sandbox through WhatsApp Web by scanning a QR code at first run; the Hermes flow exposes this as `hermes whatsapp` and persists session credentials under `~/.hermes/platforms/whatsapp/session` (`agents/hermes/manifest.yaml:69-71`). Sandbox egress goes through the `whatsapp` policy preset, which carries the WebSocket / Noise / h1-ALPN caveats documented in `nemoclaw-blueprint/policies/presets/whatsapp.yaml`. No Meta Business API integration today; that path is out of scope for this matrix." + "notes": "Supported by both OpenClaw and Hermes through the channel manifest `supportedAgents` declaration in `src/lib/messaging/channels/whatsapp/manifest.ts`. Pairing happens in the sandbox through WhatsApp Web by scanning a QR code at first run; the Hermes flow exposes this as `hermes whatsapp` and persists session credentials under `~/.hermes/platforms/whatsapp/session` (`agents/hermes/manifest.yaml:69-71`). Sandbox egress goes through the `whatsapp` policy preset, which carries the WebSocket / Noise / h1-ALPN caveats documented in `nemoclaw-blueprint/policies/presets/whatsapp.yaml`. No Meta Business API integration today; that path is out of scope for this matrix." }, { "name": "Microsoft Teams", diff --git a/docs/about/release-notes.mdx b/docs/about/release-notes.mdx index 49783bf0a6..a8d1729e43 100644 --- a/docs/about/release-notes.mdx +++ b/docs/about/release-notes.mdx @@ -23,7 +23,8 @@ NemoClaw v0.0.68 improves onboarding recovery, messaging setup, agent-specific C Scripted installs propagate onboarding exit codes, interrupted installer runs start fresh when no sandbox exists to resume, and resume behavior stays tied to onboarding sessions with a real sandbox. For more information, refer to [NemoClaw Quickstart with OpenClaw](../get-started/quickstart) and [Troubleshooting](../reference/troubleshooting). - Messaging setup adds experimental Microsoft Teams channel onboarding for OpenClaw and Hermes, including Bot Framework credentials, webhook forwarding, the `teams` network policy preset, and local webhook port conflict checks. - NemoClaw also treats `messaging_platforms: []` as an explicit deny-all declaration and rejects `channels add` before policy, provider, registry, credential, or rebuild mutations when the selected agent does not support messaging. + NemoClaw also derives channel availability from each channel manifest's `supportedAgents` declaration and rejects `channels add` before policy, provider, registry, credential, or rebuild mutations when no channel manifest supports the selected agent. + LangChain Deep Agents Code therefore fails closed for messaging until a real channel bridge and matching manifest support land. For more information, refer to [Messaging Channels](../manage-sandboxes/messaging-channels) and [Platform Support and Launch Claims](../reference/platform-support). - Agent-specific CLI commands now route more accurately and fail earlier with clearer local guidance. `sessions export` routes by sandbox agent kind, Hermes `gateway-token` points users to `dashboard-url`, bare OpenClaw `agent` invocations print wrapper help locally, and omitted extra-agent `workspace` and `agentDir` fields use canonical OpenClaw paths. diff --git a/docs/reference/architecture.mdx b/docs/reference/architecture.mdx index 4a0303d0aa..5e0b89e241 100644 --- a/docs/reference/architecture.mdx +++ b/docs/reference/architecture.mdx @@ -157,7 +157,8 @@ When the policy or phase changes during a session, the plugin sends a smaller up The context tells the agent to try allowed network and filesystem operations before reporting them unavailable, and to distinguish policy denials from DNS, timeout, TLS, or filesystem errors. The Hermes integration follows the generic agent-manifest path instead of the OpenClaw plugin package path. -The manifest declares Hermes' binary, health probe, config directory, state directories, messaging support, and OpenAI-compatible API endpoint. +The manifest declares Hermes' binary, health probe, config directory, state directories, and OpenAI-compatible API endpoint. +Messaging channel availability is declared by each channel manifest's `supportedAgents` list under `src/lib/messaging/channels/`, not by the Hermes agent manifest. The build-time config generator turns NemoClaw onboarding choices into Hermes YAML and environment files, and the Hermes plugin manifest exposes NemoClaw tools and an `on_session_start` hook. ## NemoClaw Blueprint diff --git a/docs/reference/platform-support.mdx b/docs/reference/platform-support.mdx index fa3036f067..b74973e868 100644 --- a/docs/reference/platform-support.mdx +++ b/docs/reference/platform-support.mdx @@ -116,7 +116,7 @@ NemoClaw configures messaging channels during onboarding. The OpenShell gateway | Discord | Tested | Configured through an OpenShell-managed channel during onboarding. Sandbox egress allowed by the `discord` policy preset. | | Telegram | Tested | Configured through an OpenShell-managed channel during onboarding. | | WeChat | Tested with limitations | Channel hook available. Verify regional account access before relying on this path. | -| WhatsApp | Tested with limitations | Supported by both OpenClaw and Hermes (see `messaging_platforms` in `agents/openclaw/manifest.yaml` and `agents/hermes/manifest.yaml`). Pairing happens in the sandbox through WhatsApp Web by scanning a QR code at first run; the Hermes flow exposes this as `hermes whatsapp` and persists session credentials under `~/.hermes/platforms/whatsapp/session` (`agents/hermes/manifest.yaml:69-71`). Sandbox egress goes through the `whatsapp` policy preset, which carries the WebSocket / Noise / h1-ALPN caveats documented in `nemoclaw-blueprint/policies/presets/whatsapp.yaml`. No Meta Business API integration today; that path is out of scope for this matrix. | +| WhatsApp | Tested with limitations | Supported by both OpenClaw and Hermes through the channel manifest `supportedAgents` declaration in `src/lib/messaging/channels/whatsapp/manifest.ts`. Pairing happens in the sandbox through WhatsApp Web by scanning a QR code at first run; the Hermes flow exposes this as `hermes whatsapp` and persists session credentials under `~/.hermes/platforms/whatsapp/session` (`agents/hermes/manifest.yaml:69-71`). Sandbox egress goes through the `whatsapp` policy preset, which carries the WebSocket / Noise / h1-ALPN caveats documented in `nemoclaw-blueprint/policies/presets/whatsapp.yaml`. No Meta Business API integration today; that path is out of scope for this matrix. | | Microsoft Teams | Experimental | Supported by both OpenClaw and Hermes through the manifest-first messaging channel contract. Requires Bot Framework app credentials, a tenant ID, and a public HTTPS endpoint that reaches the sandbox webhook path `/api/messages`. Sandbox egress goes through the `teams` policy preset, and only one active Teams sandbox can use a given local `MSTEAMS_PORT` forward. | {/* integration-status:end */} diff --git a/src/lib/actions/sandbox/channel-status.test.ts b/src/lib/actions/sandbox/channel-status.test.ts index d93f6c8cc2..4d9d9005a3 100644 --- a/src/lib/actions/sandbox/channel-status.test.ts +++ b/src/lib/actions/sandbox/channel-status.test.ts @@ -48,7 +48,6 @@ const PROBED_AT = new Date("2026-05-28T04:00:00.000Z"); function fakeAgent(name: "openclaw" | "hermes" = "openclaw"): AgentDefinition { const configDir = name === "openclaw" ? "/sandbox/.openclaw" : "/sandbox/.hermes"; const stateDirs = name === "openclaw" ? ["whatsapp"] : ["platforms"]; - const messagingPlatforms = ["telegram", "discord", "slack", "wechat", "whatsapp", "teams"]; return { name, agentDir: `/fake/${name}`, @@ -89,9 +88,6 @@ function fakeAgent(name: "openclaw" | "hermes" = "openclaw"): AgentDefinition { get phoneHomeHosts() { return []; }, - get messagingPlatforms() { - return messagingPlatforms; - }, get dockerfileBasePath() { return null; }, diff --git a/src/lib/actions/sandbox/channel-status.ts b/src/lib/actions/sandbox/channel-status.ts index c1514b5901..edf28314b7 100644 --- a/src/lib/actions/sandbox/channel-status.ts +++ b/src/lib/actions/sandbox/channel-status.ts @@ -18,6 +18,10 @@ import { collectBuiltInMessagingChannelDiagnostics, type MessagingChannelDiagnosticSpec, } from "../../messaging/diagnostics"; +import { + createBuiltInChannelManifestRegistry, + getMessagingManifestAvailabilityContext, +} from "../../messaging"; import * as policies from "../../policy"; import { type DiagnosticSeverity, @@ -95,6 +99,7 @@ export type ChannelStatusReport = // channels status from inheriting that hang. const WHATSAPP_PROBE_TIMEOUT_MS = 8_000; const CHANNEL_STATUS_DIAGNOSTICS = collectBuiltInMessagingChannelDiagnostics(); +const channelManifestRegistry = createBuiltInChannelManifestRegistry(); const SHELL_OK = "NEMOCLAW_WA_DIAG_OK"; const HEARTBEAT_BEGIN = "NEMOCLAW_WA_HEARTBEAT_BEGIN"; @@ -507,11 +512,11 @@ function buildBasicChannelReport( }); // Reference the agent in a hint so the deep-diagnostic section is // discoverable per agent without needing extra plumbing. - if (!agent.messagingPlatforms.includes(channelName)) { + if (!channelSupportedByAgent(channelName, agent)) { signals.unshift({ label: "Agent support", severity: "warn", - detail: `agent '${agent.name}' does not declare support for ${channelName}`, + detail: `channel '${channelName}' does not support agent '${agent.name}'`, }); } return { @@ -523,6 +528,12 @@ function buildBasicChannelReport( }; } +function channelSupportedByAgent(channelName: string, agent: AgentDefinition): boolean { + return channelManifestRegistry + .listAvailable(getMessagingManifestAvailabilityContext(agent, channelManifestRegistry.list())) + .some((manifest) => manifest.id === channelName); +} + /** * Run the WhatsApp diagnostic or a thin per-channel summary for the named * sandbox. The function never throws: any unexpected condition is rendered 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 65bfba6319..b11be8bc00 100644 --- a/src/lib/actions/sandbox/policy-channel-agent-gate.test.ts +++ b/src/lib/actions/sandbox/policy-channel-agent-gate.test.ts @@ -1,10 +1,9 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -// 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. +// Lifecycle-boundary regression: `addSandboxChannel` must refuse channel/agent pairs +// that fall outside the channel manifest `supportedAgents` set 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. // @@ -93,7 +92,6 @@ 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: "custom-agent", - messagingPlatforms: [], }); let caught: unknown; @@ -107,8 +105,9 @@ describe("addSandboxChannel agent gate", () => { 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(/Messaging-capable agents: openclaw, hermes/); + expect(errorText).toMatch(/Channel 'discord' does not support agent 'custom-agent'/); + expect(errorText).toMatch(/Channel-supported agents: openclaw, hermes/); + expect(errorText).toMatch(/Channels supported by agent 'custom-agent': \(none\)/); expect(loadPresetMock).not.toHaveBeenCalled(); expect(applyPresetMock).not.toHaveBeenCalled(); @@ -121,10 +120,9 @@ describe("addSandboxChannel agent gate", () => { expect(runOpenshellMock).not.toHaveBeenCalled(); }); - it("rejects any agent with an explicit empty messagingPlatforms allowlist before any mutation", async () => { + it("rejects an agent that is not listed by any channel manifest before any mutation", async () => { vi.spyOn(defs, "loadAgent").mockReturnValue({ name: "future-agent", - messagingPlatforms: [], }); let caught: unknown; @@ -145,7 +143,6 @@ describe("addSandboxChannel agent gate", () => { 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; @@ -158,7 +155,7 @@ describe("addSandboxChannel agent gate", () => { 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(errorText).not.toMatch(/does not support agent/); expect(loadPresetMock).toHaveBeenCalled(); void caught; void exitMock; diff --git a/src/lib/actions/sandbox/policy-channel-cleanup.test.ts b/src/lib/actions/sandbox/policy-channel-cleanup.test.ts index 97a5371903..036c72629c 100644 --- a/src/lib/actions/sandbox/policy-channel-cleanup.test.ts +++ b/src/lib/actions/sandbox/policy-channel-cleanup.test.ts @@ -80,7 +80,6 @@ describe("persistManifestChannelRemovePlan with non-messaging agent (#5729)", () it("strips stale messaging state from the registry without throwing", async () => { vi.spyOn(defs, "loadAgent").mockReturnValue({ name: "custom-agent", - messagingPlatforms: [], }); getSandboxMock.mockReturnValue(entryWithStalePlan("da-test", "discord")); @@ -93,7 +92,6 @@ 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: "custom-agent", - messagingPlatforms: [], }); getSandboxMock.mockReturnValue({ name: "da-test", agent: "custom-agent" }); @@ -108,7 +106,6 @@ 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: "custom-agent", - messagingPlatforms: [], }); getSandboxMock.mockReturnValue(entryWithStalePlan("da-test", "discord")); @@ -121,7 +118,6 @@ 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: "custom-agent", - messagingPlatforms: [], }); getSandboxMock.mockReturnValue({ name: "da-test", agent: "custom-agent" }); diff --git a/src/lib/actions/sandbox/policy-channel-conflict.test.ts b/src/lib/actions/sandbox/policy-channel-conflict.test.ts index 65d621d23e..b0066b35b5 100644 --- a/src/lib/actions/sandbox/policy-channel-conflict.test.ts +++ b/src/lib/actions/sandbox/policy-channel-conflict.test.ts @@ -330,10 +330,9 @@ beforeEach(() => { promptMock = vi.spyOn(store, "prompt").mockResolvedValue(""); vi.spyOn(store, "saveCredential").mockImplementation(() => undefined); - // Agent gate: support every channel. + // Agent gate: OpenClaw support is derived from channel manifests. vi.spyOn(defs, "loadAgent").mockReturnValue({ name: "openclaw", - messagingPlatforms: ["telegram", "discord", "slack", "wechat", "whatsapp", "teams"], }); // Policy seam. addSandboxChannel gates on loadPreset()/parsePresetPolicyKeys() diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index c0099865f0..d85bb40c5f 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -15,9 +15,10 @@ import { createBuiltInMessagingHookRegistry, createBuiltInRenderTemplateResolver, createMessagingPreEnableHookInputs, + formatSupportedMessagingAgentIds, getMessagingManifestAvailabilityContext, + isMessagingChannelSupportedByAgent, isMessagingHookConflictError, - isMessagingSupportedAgent, MessagingHostStateApplier, MessagingSetupApplier, MessagingWorkflowPlanner, @@ -329,23 +330,37 @@ function resolveChannelManifest(name: string): ChannelManifest | undefined { } function availableManifestChannelsForAgent(agent: AgentDefinition): ChannelManifest[] { - return messagingManifestRegistry.listAvailable(getMessagingManifestAvailabilityContext(agent)); + return messagingManifestRegistry.listAvailable( + getMessagingManifestAvailabilityContext(agent, messagingManifestRegistry.list()), + ); } -function channelSupportedByAgent(channelName: string, agent: AgentDefinition): boolean { - return availableManifestChannelsForAgent(agent).some((manifest) => manifest.id === channelName); +function channelSupportedByAgent(manifest: ChannelManifest, agent: AgentDefinition): boolean { + return isMessagingChannelSupportedByAgent(manifest, agent); } export function listSandboxChannels(sandboxName: string) { const agent = resolveAgentForSandbox(sandboxName); + const availableChannels = availableManifestChannelsForAgent(agent); console.log(""); console.log(` Known messaging channels for sandbox '${sandboxName}':`); - for (const manifest of availableManifestChannelsForAgent(agent)) { + if (availableChannels.length === 0) { + console.log(` (none supported by agent '${agent.name}')`); + } + for (const manifest of availableChannels) { console.log(` ${manifest.id} — ${manifest.description ?? manifest.displayName}`); } console.log(""); } +function formatAvailableChannelsForAgent(agent: AgentDefinition): string { + return ( + availableManifestChannelsForAgent(agent) + .map((manifest) => manifest.id) + .join(", ") || "(none)" + ); +} + // Map a channel + token-env-key to the OpenShell provider name onboarding // uses for it. Mirrors the names in src/lib/onboard.ts:3201-3221 so a // channels-add upsert collides with (i.e. updates) the same provider that @@ -742,7 +757,7 @@ async function planSandboxChannelAdd( try { const plan = await planner.buildChannelAddPlanFromSandboxEntry({ sandboxName, - agent: toMessagingAgentId(agent), + agent: toMessagingAgentId(agent, messagingManifestRegistry.list()), isInteractive: !isNonInteractive(), channelId, sandboxEntry: registry.getSandbox(sandboxName), @@ -766,7 +781,7 @@ export async function persistManifestChannelDisabledPlan( const entry = registry.getSandbox(sandboxName); if (!entry?.messaging?.plan) return null; const agent = resolveAgentForSandbox(sandboxName); - const agentId = tryGetMessagingAgentId(agent); + const agentId = tryGetMessagingAgentId(agent, messagingManifestRegistry.list()); if (agentId === null) return null; const planner = new MessagingWorkflowPlanner( messagingManifestRegistry, @@ -794,7 +809,7 @@ export async function persistManifestChannelRemovePlan( const entry = registry.getSandbox(sandboxName); if (!entry) return false; const agent = resolveAgentForSandbox(sandboxName); - const agentId = tryGetMessagingAgentId(agent); + const agentId = tryGetMessagingAgentId(agent, messagingManifestRegistry.list()); if (agentId === null) { if (entry.messaging?.plan) { return registry.updateSandbox(sandboxName, { messaging: undefined }); @@ -921,18 +936,16 @@ export async function addSandboxChannel( const canonical = manifest.id; const agent = resolveAgentForSandbox(sandboxName); - if (!isMessagingSupportedAgent(agent)) { + if (!channelSupportedByAgent(manifest, agent)) { console.error( - ` Agent '${agent.name}' does not support messaging channels for sandbox '${sandboxName}'.`, + ` Channel '${canonical}' does not support agent '${agent.name}' for sandbox '${sandboxName}'.`, + ); + console.error( + ` Channel-supported agents: ${formatSupportedMessagingAgentIds(manifest.supportedAgents)}.`, ); - 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}'.`, + ` Channels supported by agent '${agent.name}': ${formatAvailableChannelsForAgent(agent)}.`, ); - console.error(` Supported channels: ${agent.messagingPlatforms.join(", ") || "(none)"}`); process.exit(1); } diff --git a/src/lib/actions/sandbox/rebuild-flow.test.ts b/src/lib/actions/sandbox/rebuild-flow.test.ts index 4025f83303..6b7e3525d8 100644 --- a/src/lib/actions/sandbox/rebuild-flow.test.ts +++ b/src/lib/actions/sandbox/rebuild-flow.test.ts @@ -185,7 +185,6 @@ function createRebuildFlowHarness(overrides: RebuildFlowOverrides = {}): Rebuild const agentDef = { name: "openclaw", expectedVersion: "0.2.0", - messagingPlatforms: ["telegram", "discord", "slack", "wechat", "whatsapp"], }; vi.spyOn(gatewayDrift, "detectOpenShellStateRpcPreflightIssue").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 118f832aea..87a5be2eb7 100644 --- a/src/lib/actions/sandbox/rebuild-messaging-stage.test.ts +++ b/src/lib/actions/sandbox/rebuild-messaging-stage.test.ts @@ -2,8 +2,8 @@ // 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 +// NEMOCLAW_MESSAGING_PLAN_B64 and skip planning for agents unsupported by +// channel manifests, 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 @@ -50,10 +50,9 @@ describe("stageMessagingManifestPlanForRebuild non-messaging agent guard", () => vi.restoreAllMocks(); }); - it("emits the unknown-runtime skip message for any agent whose name is not in the runtime allowlist", async () => { + it("emits the skip message for any agent whose name is not supported by channel manifests", async () => { const loadAgentSpy = vi.spyOn(defs, "loadAgent").mockReturnValue({ name: "future-non-messaging-agent", - messagingPlatforms: [], }); const clearPlanEnvSpy = vi.spyOn(messaging.MessagingSetupApplier, "clearPlanEnv"); @@ -68,18 +67,15 @@ describe("stageMessagingManifestPlanForRebuild non-messaging agent guard", () => 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, + "Messaging manifest rebuild plan skipped: agent 'future-non-messaging-agent' is not supported by any channel manifest", ); + expect(messages.some((msg) => msg.includes("has no supported messaging channels"))).toBe(false); expect(result).toBeNull(); }); it("stages an explicit empty rebuild plan so token-backed channels are not rediscovered", async () => { vi.spyOn(defs, "loadAgent").mockReturnValue({ name: "openclaw", - messagingPlatforms: ["telegram"], }); const clearPlanEnvSpy = vi.spyOn(messaging.MessagingSetupApplier, "clearPlanEnv"); const writePlanEnvSpy = vi @@ -108,10 +104,9 @@ describe("stageMessagingManifestPlanForRebuild non-messaging agent guard", () => expect(result).toMatchObject({ workflow: "rebuild", channels: [] }); }); - it("emits the empty-allowlist skip message for a known agent whose messagingPlatforms is an explicit empty allowlist", async () => { + it("stages a plan for a known agent using channel-manifest supported channels", async () => { vi.spyOn(defs, "loadAgent").mockReturnValue({ name: "openclaw", - messagingPlatforms: [], }); const clearPlanEnvSpy = vi.spyOn(messaging.MessagingSetupApplier, "clearPlanEnv"); const writePlanEnvSpy = vi.spyOn(messaging.MessagingSetupApplier, "writePlanToEnv"); @@ -157,12 +152,9 @@ describe("stageMessagingManifestPlanForRebuild non-messaging agent guard", () => (msg) => messages.push(msg), ); - 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(); + expect(clearPlanEnvSpy).not.toHaveBeenCalled(); + expect(writePlanEnvSpy).toHaveBeenCalledTimes(1); + expect(messages).toContain("Messaging manifest rebuild plan staged: telegram"); + expect(result).not.toBeNull(); }); }); diff --git a/src/lib/actions/sandbox/rebuild-resume-snapshot.test.ts b/src/lib/actions/sandbox/rebuild-resume-snapshot.test.ts index db878ba290..8ef8cd2a0d 100644 --- a/src/lib/actions/sandbox/rebuild-resume-snapshot.test.ts +++ b/src/lib/actions/sandbox/rebuild-resume-snapshot.test.ts @@ -95,7 +95,9 @@ describe("rebuild resume snapshot repair", () => { result: { status: 0, output: "alpha Ready" }, }), vi.spyOn(resolve, "resolveOpenshell").mockReturnValue(null), - vi.spyOn(agentDefs, "loadAgent").mockReturnValue({ messagingPlatforms: [] } as never), + vi.spyOn(agentDefs, "loadAgent").mockReturnValue({ + name: "langchain-deepagents-code", + } as never), vi.spyOn(agentRuntime, "getSessionAgent").mockReturnValue(null), vi.spyOn(agentRuntime, "getAgentDisplayName").mockReturnValue("OpenClaw"), vi.spyOn(onboardSession, "loadSession").mockImplementation(loadSession), diff --git a/src/lib/actions/sandbox/rebuild.ts b/src/lib/actions/sandbox/rebuild.ts index 403717b6c9..6f182b9509 100644 --- a/src/lib/actions/sandbox/rebuild.ts +++ b/src/lib/actions/sandbox/rebuild.ts @@ -47,6 +47,7 @@ import { createBuiltInChannelManifestRegistry, createBuiltInRenderTemplateResolver, isMessagingSupportedAgent, + listSupportedMessagingChannelIdsForAgent, MessagingSetupApplier, MessagingWorkflowPlanner, tryGetMessagingAgentId, @@ -220,23 +221,26 @@ export async function stageMessagingManifestPlanForRebuild( log: (msg: string) => void, ): Promise { const agent = loadAgent(rebuildAgent || "openclaw"); - const agentId = tryGetMessagingAgentId(agent); + const manifestRegistry = createBuiltInChannelManifestRegistry(); + const manifests = manifestRegistry.list(); + const agentId = tryGetMessagingAgentId(agent, manifests); if (agentId === null) { MessagingSetupApplier.clearPlanEnv(); log( - `Messaging manifest rebuild plan skipped: agent '${agent.name}' is not a messaging-capable runtime`, + `Messaging manifest rebuild plan skipped: agent '${agent.name}' is not supported by any channel manifest`, ); return null; } - if (!isMessagingSupportedAgent(agent)) { + if (!isMessagingSupportedAgent(agent, manifests)) { MessagingSetupApplier.clearPlanEnv(); log( - `Messaging manifest rebuild plan skipped: agent '${agent.name}' declares no supported messaging channels`, + `Messaging manifest rebuild plan skipped: agent '${agent.name}' has no supported messaging channels`, ); return null; } + const supportedChannelIds = listSupportedMessagingChannelIdsForAgent(manifests, agentId); const planner = new MessagingWorkflowPlanner( - createBuiltInChannelManifestRegistry(), + manifestRegistry, undefined, createBuiltInRenderTemplateResolver(), ); @@ -244,7 +248,7 @@ export async function stageMessagingManifestPlanForRebuild( sandboxName, agent: agentId, sandboxEntry, - supportedChannelIds: agent.messagingPlatforms, + supportedChannelIds, }); if (!plan) { MessagingSetupApplier.clearPlanEnv(); @@ -392,10 +396,10 @@ async function stageRebuildMessagingPlanOrBail( try { return await stageMessagingManifestPlanForRebuild(sandboxName, sb, rebuildAgent, log); } catch (err) { - // Source boundary: registry messaging plans and agent manifests are durable - // host-side inputs from prior onboarding. If they drift or become invalid, - // rebuild must fail here before backup/delete; remove this boundary only if - // manifest staging becomes total over all persisted registry states. + // Source boundary: persisted registry messaging plans and current channel + // manifests are host-side inputs. If they drift or become invalid, rebuild + // must fail here before backup/delete; remove this boundary only if manifest + // staging becomes total over all persisted registry states. const message = err instanceof Error ? err.message : String(err); console.error(""); console.error( diff --git a/src/lib/actions/sandbox/snapshot.test.ts b/src/lib/actions/sandbox/snapshot.test.ts index bc38314215..5349182c0a 100644 --- a/src/lib/actions/sandbox/snapshot.test.ts +++ b/src/lib/actions/sandbox/snapshot.test.ts @@ -217,7 +217,7 @@ describe("runSandboxSnapshot", () => { fs.mkdirSync(homeDir); fs.writeFileSync(psPath, `#!/bin/sh\ncat <<'EOF'\n${processes}\nEOF\n`); fs.chmodSync(psPath, 0o755); - const result = spawnSync("sh", ["-lc", script], { + const result = spawnSync("sh", ["-c", script], { encoding: "utf-8", env: { ...process.env, @@ -475,6 +475,7 @@ describe("runSandboxSnapshot", () => { const shellCommandLine = probeScript.replace(/\s+/g, " "); for (const processLine of [ "123 python3 -m deepagents_code --sandbox none --no-mcp -n work\n", + "123 /opt/venv/bin/python3 -m deepagents_code --sandbox none --no-mcp -n work\n", "124 /usr/local/bin/dcode task\n", "125 /opt/bin/deepagents_code task\n", "126 /opt/bin/deepagents-code task\n", @@ -490,6 +491,16 @@ describe("runSandboxSnapshot", () => { status: 0, output: expect.stringContaining("NEMOCLAW_DCODE_PROBE=no-runtime"), }); + for (const processLine of [ + "127 cat /tmp/dcode\n", + "128 grep deepagents-code notes.txt\n", + "129 sh -lc python3 -m deepagents_code\n", + ]) { + expect(runProbeScriptWithProcesses(probeScript, processLine)).toMatchObject({ + status: 0, + output: expect.stringContaining("NEMOCLAW_DCODE_PROBE=no-runtime"), + }); + } expect(consoleLog.mock.calls.flat().join("\n")).toContain("Snapshot v3 created"); }); diff --git a/src/lib/actions/sandbox/snapshot.ts b/src/lib/actions/sandbox/snapshot.ts index 2a4b3fbaec..9e0b069c70 100644 --- a/src/lib/actions/sandbox/snapshot.ts +++ b/src/lib/actions/sandbox/snapshot.ts @@ -64,9 +64,15 @@ processes="$(ps -eo pid=,args= 2>/dev/null)" || { emit_dcode_probe_state no-runtime } printf '%s\n' "$processes" | awk ' -/python[0-9.]*[[:space:]]+-m[[:space:]]+deepagents[_]code/ { found = 1 } -/(^|[[:space:]/])[d]code($|[[:space:]])/ { found = 1 } -/(^|[[:space:]/])deepagents[-_]code($|[[:space:]])/ { found = 1 } +/^[[:space:]]*[0-9]+[[:space:]]+([^[:space:]]*\/)?python[0-9.]*[[:space:]]+-m[[:space:]]+deepagents[_]code([[:space:]]|$)/ { + found = 1 +} +/^[[:space:]]*[0-9]+[[:space:]]+([^[:space:]]*\/)?[d]code([[:space:]]|$)/ { + found = 1 +} +/^[[:space:]]*[0-9]+[[:space:]]+([^[:space:]]*\/)?deepagents[-_]code([[:space:]]|$)/ { + found = 1 +} END { exit found ? 0 : 1 } ' matched=$? diff --git a/src/lib/agent/base-image.test.ts b/src/lib/agent/base-image.test.ts index d1615424bd..125da26c40 100644 --- a/src/lib/agent/base-image.test.ts +++ b/src/lib/agent/base-image.test.ts @@ -40,7 +40,6 @@ function makeAgent(overrides: Partial = {}): AgentDefinition { expectedVersion: "2026.4.30", hasDevicePairing: false, phoneHomeHosts: [], - messagingPlatforms: [], dockerfileBasePath: "/test/root/agents/hermes/Dockerfile.base", dockerfilePath: "/test/root/agents/hermes/Dockerfile", startScriptPath: null, diff --git a/src/lib/agent/defs.test.ts b/src/lib/agent/defs.test.ts index 5e065eb2f6..28a9177713 100644 --- a/src/lib/agent/defs.test.ts +++ b/src/lib/agent/defs.test.ts @@ -48,14 +48,6 @@ describe("agent definitions", () => { envFile: null, format: "json", }); - expect(openclaw.messagingPlatforms).toEqual([ - "telegram", - "discord", - "slack", - "wechat", - "whatsapp", - "teams", - ]); expect(openclaw.inferenceProviderOptions).toEqual([]); // OpenClaw uses device_pairing web auth — no fetchable bearer token. expect(openclaw.webAuth).toEqual({ method: "none", env: null }); @@ -93,14 +85,6 @@ describe("agent definitions", () => { expect(hermes.dashboardUi).toBeNull(); // Hermes' OpenAI-compatible API uses a bearer token read from API_SERVER_KEY. expect(hermes.webAuth).toEqual({ method: "bearer_token", env: "API_SERVER_KEY" }); - expect(hermes.messagingPlatforms).toEqual([ - "telegram", - "discord", - "slack", - "wechat", - "whatsapp", - "teams", - ]); expect(hermes.userManagedFiles).toEqual([".hermes/.env"]); }); diff --git a/src/lib/agent/defs.ts b/src/lib/agent/defs.ts index 83070a0033..ae3e4efc65 100644 --- a/src/lib/agent/defs.ts +++ b/src/lib/agent/defs.ts @@ -86,7 +86,6 @@ export interface AgentDefinition { state_dirs?: string[]; state_files?: AgentStateFile[]; user_managed_files?: string[]; - messaging_platforms?: { supported?: string[] }; _legacy_paths?: StringMap; agentDir: string; manifestPath: string; @@ -105,7 +104,6 @@ export interface AgentDefinition { readonly expectedVersion: string | null; readonly hasDevicePairing: boolean; readonly phoneHomeHosts: string[]; - readonly messagingPlatforms: string[]; readonly dockerfileBasePath: string | null; readonly dockerfilePath: string | null; readonly startScriptPath: string | null; @@ -304,14 +302,6 @@ function readHealthProbe(record: ManifestRecord): AgentHealthProbe | undefined { return undefined; } -function readMessagingPlatforms(record: ManifestRecord): { supported?: string[] } | undefined { - const messagingPlatforms = readObject(record, "messaging_platforms"); - if (!messagingPlatforms) return undefined; - - const supported = readStringArray(messagingPlatforms, "supported"); - return supported ? { supported } : {}; -} - function readDashboard(record: ManifestRecord): AgentDashboard { const d = readObject(record, "dashboard") ?? {}; const rawKind = d.kind; @@ -433,7 +423,6 @@ export function loadAgent(name: string): AgentDefinition { const stateFiles = readStateFiles(raw); const userManagedFiles = readUserManagedFiles(raw); const phoneHomeHosts = readStringArray(raw, "phone_home_hosts"); - const messagingPlatforms = readMessagingPlatforms(raw); const legacyPathConfig = readStringMap(raw, "_legacy_paths"); const dashboardUi = readDashboardUi(raw); @@ -456,7 +445,6 @@ export function loadAgent(name: string): AgentDefinition { state_dirs: stateDirs, state_files: stateFiles, user_managed_files: userManagedFiles, - messaging_platforms: messagingPlatforms, _legacy_paths: legacyPathConfig, agentDir, manifestPath, @@ -538,10 +526,6 @@ export function loadAgent(name: string): AgentDefinition { return phoneHomeHosts ?? []; }, - get messagingPlatforms(): string[] { - return messagingPlatforms?.supported ?? []; - }, - get dockerfileBasePath(): string | null { const dockerfileBase = path.join(agentDir, "Dockerfile.base"); return fs.existsSync(dockerfileBase) ? dockerfileBase : null; diff --git a/src/lib/agent/hermes-recovery-boundary-fixtures.ts b/src/lib/agent/hermes-recovery-boundary-fixtures.ts index 8b3cffe5fc..cd5e581081 100644 --- a/src/lib/agent/hermes-recovery-boundary-fixtures.ts +++ b/src/lib/agent/hermes-recovery-boundary-fixtures.ts @@ -32,7 +32,6 @@ export function makeAgent(overrides: Partial = {}): AgentDefini expectedVersion: null, hasDevicePairing: false, phoneHomeHosts: [], - messagingPlatforms: [], dockerfileBasePath: null, dockerfilePath: null, startScriptPath: null, diff --git a/src/lib/agent/onboard.test.ts b/src/lib/agent/onboard.test.ts index 78cd8ef819..27b8caf974 100644 --- a/src/lib/agent/onboard.test.ts +++ b/src/lib/agent/onboard.test.ts @@ -33,7 +33,6 @@ function makeAgent(overrides: Partial = {}): AgentDefinition { expectedVersion: null, hasDevicePairing: false, phoneHomeHosts: [], - messagingPlatforms: [], dockerfileBasePath: null, dockerfilePath: null, startScriptPath: null, diff --git a/src/lib/agent/runtime.test.ts b/src/lib/agent/runtime.test.ts index 63175af975..c6bc960577 100644 --- a/src/lib/agent/runtime.test.ts +++ b/src/lib/agent/runtime.test.ts @@ -35,7 +35,6 @@ function makeAgent(overrides: Partial = {}): AgentDefinition { expectedVersion: null, hasDevicePairing: false, phoneHomeHosts: [], - messagingPlatforms: [], dockerfileBasePath: null, dockerfilePath: null, startScriptPath: null, diff --git a/src/lib/messaging/AGENTS.md b/src/lib/messaging/AGENTS.md index f8c303e5e5..a8f62ff3a7 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`, `/sandbox/.hermes`, or `/sandbox/.deepagents`; rely on existing applier validation instead of bypassing it. +- Channel render/build-file targets must stay inside `/sandbox/.openclaw` or `/sandbox/.hermes`; 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()`. @@ -83,19 +83,18 @@ 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 +## Unsupported Agent Boundaries -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. +LangChain Deep Agents Code is a terminal-oriented harness. NemoClaw does not run a long-running messaging bridge inside the DeepAgents sandbox today, and no built-in channel manifest lists `langchain-deepagents-code` under `supportedAgents`. -- **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. +- **No artifact-only support.** Do not add DeepAgents messaging render targets, Dockerfile plan args, startup env parsing, or local contract files without a real bridge that routes channel messages to and from `dcode`. +- **No inbound bridge.** The harness does not spawn channel bot processes. Inbound messages from Telegram, Discord, Slack, or other channels do not currently reach `dcode`. `channels add` must reject DeepAgents before policy, provider, credential, registry, or rebuild mutation while this remains true. +- **Support condition.** Adding DeepAgents support requires a channel manifest `supportedAgents` entry, a build/runtime applier path for that agent, and a runtime bridge/health path before public behavior claims channel readiness. ## 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`, 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`. +- **Invalid state.** A sandbox can be configured with an agent name that no channel manifest supports, or with stale `NEMOCLAW_MESSAGING_PLAN_B64` state from an earlier build. Without an explicit gate the channel-add path can still tear down the sandbox before failing at `dockerfile-patch.ts`, and rebuild can carry stale messaging plan data into an agent build path that does not consume it. +- **Source boundary.** Channel manifests' `supportedAgents` lists are the single source of truth for whether a given agent supports messaging today, and which channels are available for it. Helpers in `utils.ts` derive the supported agent list from the active channel manifest registry, so `ChannelManifestRegistry.listAvailable`, `MessagingWorkflowPlanner.supportedChannelIds`, onboard state filtering, channel list, channel add/remove, and rebuild all share the same semantics. If no manifest supports the agent, deny or skip everywhere and clear stale staged plans. +- **Source-fix constraint.** Expanding support for an agent requires per-channel `supportedAgents`, agent-side render and hook handlers in `applier/build/messaging-build-applier.mts`, the matching Dockerfile/build env plumbing when needed, and a runtime bridge/health path when public behavior claims channel readiness. Until that stack lands, the gate at the action boundary is the safe behavior: surface the unsupported-agent message in `addSandboxChannel`, clear the staged plan in `stageMessagingManifestPlanForRebuild`, and strip 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. +- **Removal condition.** Drop the unsupported-agent gate only when every target agent is represented by channel manifest `supportedAgents` entries and `applier/build/messaging-build-applier.mts` resolves its render and runtime targets. At that point the unsupported-agent branch becomes unreachable for that agent and the action boundary can rely on planner-level validation alone. diff --git a/src/lib/messaging/applier/build/messaging-build-applier.mts b/src/lib/messaging/applier/build/messaging-build-applier.mts index 31fb4a3474..65a89b5cdc 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" | "langchain-deepagents-code"; +type MessagingAgentId = "openclaw" | "hermes"; type MessagingHookPhase = "agent-install" | "post-agent-install"; type MessagingRuntimeSetupKey = "nodePreloads" | "envAliases" | "secretScans"; type MessagingSerializableValue = @@ -692,12 +692,7 @@ function resolveAgentRenderTarget( options: { readonly homeDir?: string } = {}, ): string { const home = options.homeDir ?? homedir(); - const agentRoot = - agent === "hermes" - ? join(home, ".hermes") - : agent === "langchain-deepagents-code" - ? join(home, ".deepagents") - : join(home, ".openclaw"); + const agentRoot = agent === "hermes" ? join(home, ".hermes") : join(home, ".openclaw"); const normalizedRoot = resolve(agentRoot); if (agent === "openclaw" && target === "openclaw.json") { return join(agentRoot, "openclaw.json"); @@ -719,14 +714,6 @@ 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 ( @@ -793,12 +780,7 @@ function applyBuildFileOutputToLocalAgentRoot( options: { readonly homeDir?: string } = {}, ): string { const home = options.homeDir ?? homedir(); - const root = - agent === "hermes" - ? join(home, ".hermes") - : agent === "langchain-deepagents-code" - ? join(home, ".deepagents") - : join(home, ".openclaw"); + const root = agent === "hermes" ? join(home, ".hermes") : join(home, ".openclaw"); const relativePath = normalizeBuildFilePath(file.path); const target = resolve(root, relativePath); const normalizedRoot = resolve(root); @@ -1546,12 +1528,10 @@ function parseMessagingBuildArgs(argv: readonly string[]): { } function readAgentArg(value: string | undefined): MessagingAgentId { - if (value === "openclaw" || value === "hermes" || value === "langchain-deepagents-code") { + if (value === "openclaw" || value === "hermes") { return value; } - throw new MessagingBuildApplierError( - "--agent must be 'openclaw', 'hermes', or 'langchain-deepagents-code'", - ); + throw new MessagingBuildApplierError("--agent must be 'openclaw' or 'hermes'"); } 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 6bffd00e96..0db2158bdd 100644 --- a/src/lib/messaging/channels/discord/manifest.ts +++ b/src/lib/messaging/channels/discord/manifest.ts @@ -179,31 +179,6 @@ 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 d9527542b9..000e4c94e7 100644 --- a/src/lib/messaging/channels/slack/manifest.ts +++ b/src/lib/messaging/channels/slack/manifest.ts @@ -144,30 +144,6 @@ 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 8de213287d..0bed20f86e 100644 --- a/src/lib/messaging/channels/telegram/manifest.ts +++ b/src/lib/messaging/channels/telegram/manifest.ts @@ -167,29 +167,6 @@ 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/src/lib/messaging/manifest/registry.test.ts b/src/lib/messaging/manifest/registry.test.ts index 3c886c68c0..5cbcaf4c6d 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 explicit platform support lists", () => { + it("filters available manifests by agent and explicit channel support lists", () => { const registry = new ChannelManifestRegistry([TELEGRAM_MANIFEST, WECHAT_MANIFEST]); expect(registry.listAvailable().map((manifest) => manifest.id)).toEqual(["telegram", "wechat"]); diff --git a/src/lib/messaging/manifest/types.ts b/src/lib/messaging/manifest/types.ts index 4b6c87f5ff..aa455658ee 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" | "langchain-deepagents-code"; +export type MessagingAgentId = "openclaw" | "hermes"; /** 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 3622642388..1fc16bdc0e 100644 --- a/src/lib/messaging/utils.test.ts +++ b/src/lib/messaging/utils.test.ts @@ -3,70 +3,120 @@ import { describe, expect, it } from "vitest"; +import { BUILT_IN_CHANNEL_MANIFESTS } from "./channels"; import { + formatSupportedMessagingAgentIds, getMessagingManifestAvailabilityContext, + isMessagingChannelSupportedByAgent, isMessagingSupportedAgent, + listSupportedMessagingAgentIds, + listSupportedMessagingChannelIdsForAgent, MessagingAgentNotSupportedError, toMessagingAgentId, tryGetMessagingAgentId, } from "./utils"; +const manifests = BUILT_IN_CHANNEL_MANIFESTS; + +describe("listSupportedMessagingAgentIds", () => { + it("derives messaging-capable agents from channel manifests", () => { + expect(listSupportedMessagingAgentIds(manifests)).toEqual(["openclaw", "hermes"]); + }); +}); + +describe("listSupportedMessagingChannelIdsForAgent", () => { + it("derives supported channel IDs from channel manifest supportedAgents", () => { + expect(listSupportedMessagingChannelIdsForAgent(manifests, "openclaw")).toEqual([ + "telegram", + "discord", + "wechat", + "slack", + "whatsapp", + "teams", + ]); + expect(listSupportedMessagingChannelIdsForAgent(manifests, "hermes")).toEqual([ + "telegram", + "discord", + "wechat", + "slack", + "whatsapp", + "teams", + ]); + }); +}); + +describe("isMessagingChannelSupportedByAgent", () => { + it("checks support against one channel manifest", () => { + const discord = manifests.find((manifest) => manifest.id === "discord"); + expect(discord).toBeDefined(); + expect(isMessagingChannelSupportedByAgent(discord!, { name: "openclaw" })).toBe(true); + expect(isMessagingChannelSupportedByAgent(discord!, { name: "hermes" })).toBe(true); + expect( + isMessagingChannelSupportedByAgent(discord!, { + name: "langchain-deepagents-code", + }), + ).toBe(false); + }); +}); + +describe("formatSupportedMessagingAgentIds", () => { + it("formats manifest-derived agent lists for CLI messages", () => { + expect(formatSupportedMessagingAgentIds(["openclaw", "hermes"])).toBe("openclaw, hermes"); + expect(formatSupportedMessagingAgentIds([])).toBe("(none)"); + }); +}); + describe("tryGetMessagingAgentId", () => { it("returns 'openclaw' for the openclaw agent name", () => { - expect(tryGetMessagingAgentId({ name: "openclaw" })).toBe("openclaw"); + expect(tryGetMessagingAgentId({ name: "openclaw" }, manifests)).toBe("openclaw"); }); it("returns 'hermes' for the hermes agent name", () => { - expect(tryGetMessagingAgentId({ name: "hermes" })).toBe("hermes"); + expect(tryGetMessagingAgentId({ name: "hermes" }, manifests)).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 DeepAgents because no channel manifest supports it", () => { + expect(tryGetMessagingAgentId({ name: "langchain-deepagents-code" }, manifests)).toBeNull(); }); it("returns null for unknown agent names instead of silently defaulting", () => { - expect(tryGetMessagingAgentId({ name: "custom-agent" })).toBeNull(); + expect(tryGetMessagingAgentId({ name: "custom-agent" }, manifests)).toBeNull(); }); it("returns null for null or undefined input", () => { - expect(tryGetMessagingAgentId(null)).toBeNull(); - expect(tryGetMessagingAgentId(undefined)).toBeNull(); - expect(tryGetMessagingAgentId({})).toBeNull(); + expect(tryGetMessagingAgentId(null, manifests)).toBeNull(); + expect(tryGetMessagingAgentId(undefined, manifests)).toBeNull(); + expect(tryGetMessagingAgentId({}, manifests)).toBeNull(); }); }); 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", - ); + expect(toMessagingAgentId({ name: "openclaw" }, manifests)).toBe("openclaw"); + expect(toMessagingAgentId({ name: "hermes" }, manifests)).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"); + expect(toMessagingAgentId(null, manifests)).toBe("openclaw"); + expect(toMessagingAgentId(undefined, manifests)).toBe("openclaw"); + expect(toMessagingAgentId({}, manifests)).toBe("openclaw"); + expect(toMessagingAgentId({ name: "" }, manifests)).toBe("openclaw"); + expect(toMessagingAgentId({ name: " " }, manifests)).toBe("openclaw"); }); it("throws MessagingAgentNotSupportedError for an explicit unknown agent", () => { - expect(() => toMessagingAgentId({ name: "custom-agent" })).toThrow( + expect(() => toMessagingAgentId({ name: "custom-agent" }, manifests)).toThrow( MessagingAgentNotSupportedError, ); }); it("surfaces the offending agent name on the thrown error", () => { try { - toMessagingAgentId({ name: "custom-agent" }); + toMessagingAgentId({ name: "custom-agent" }, manifests); } catch (err) { expect(err).toBeInstanceOf(MessagingAgentNotSupportedError); expect((err as MessagingAgentNotSupportedError).agentName).toBe("custom-agent"); - expect((err as Error).message).toMatch(/openclaw, hermes, langchain-deepagents-code/); + expect((err as Error).message).toMatch(/openclaw, hermes/); return; } throw new Error("expected toMessagingAgentId to throw"); @@ -74,103 +124,54 @@ describe("toMessagingAgentId", () => { }); describe("isMessagingSupportedAgent", () => { - it("returns true for openclaw, hermes, and DeepAgents regardless of populated 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); - expect( - isMessagingSupportedAgent({ - name: "langchain-deepagents-code", - messagingPlatforms: [], - }), - ).toBe(false); + it("returns true only for agents supported by at least one channel manifest", () => { + expect(isMessagingSupportedAgent({ name: "openclaw" }, manifests)).toBe(true); + expect(isMessagingSupportedAgent({ name: "hermes" }, manifests)).toBe(true); + expect(isMessagingSupportedAgent({ name: "langchain-deepagents-code" }, manifests)).toBe(false); }); it("returns false for unknown agents", () => { - expect(isMessagingSupportedAgent({ name: "custom-agent" })).toBe(false); - expect(isMessagingSupportedAgent(null)).toBe(false); + expect(isMessagingSupportedAgent({ name: "custom-agent" }, manifests)).toBe(false); + expect(isMessagingSupportedAgent(null, manifests)).toBe(false); }); }); describe("getMessagingManifestAvailabilityContext", () => { it("returns a null agent when no agent is provided (default-agent caller path)", () => { - expect(getMessagingManifestAvailabilityContext(null)).toEqual({ + expect(getMessagingManifestAvailabilityContext(null, manifests)).toEqual({ agent: null, supportedChannelIds: null, }); - expect(getMessagingManifestAvailabilityContext(undefined)).toEqual({ + expect(getMessagingManifestAvailabilityContext(undefined, manifests)).toEqual({ agent: null, supportedChannelIds: null, }); }); - it("returns the resolved messaging agent id and an explicit allowlist when present", () => { - expect( - getMessagingManifestAvailabilityContext({ - name: "openclaw", - messagingPlatforms: ["telegram", "discord"], - }), - ).toEqual({ + it("returns the resolved messaging agent id with no separate channel constraint", () => { + expect(getMessagingManifestAvailabilityContext({ name: "openclaw" }, manifests)).toEqual({ agent: "openclaw", - supportedChannelIds: ["telegram", "discord"], + supportedChannelIds: null, }); }); - it("preserves hermes agent identity alongside platform constraints", () => { - expect( - getMessagingManifestAvailabilityContext({ - name: "hermes", - messagingPlatforms: ["telegram"], - }), - ).toEqual({ agent: "hermes", supportedChannelIds: ["telegram"] }); - }); - - it("preserves DeepAgents agent identity alongside platform constraints", () => { - expect( - getMessagingManifestAvailabilityContext({ - name: "langchain-deepagents-code", - messagingPlatforms: ["discord"], - }), - ).toEqual({ agent: "langchain-deepagents-code", supportedChannelIds: ["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", + it("preserves hermes agent identity", () => { + expect(getMessagingManifestAvailabilityContext({ name: "hermes" }, manifests)).toEqual({ + agent: "hermes", supportedChannelIds: null, }); }); - it("returns a null agent for unknown agents and never silently defaults to openclaw", () => { + it("turns explicit unsupported agents into a deny-all availability context", () => { expect( - getMessagingManifestAvailabilityContext({ - name: "custom-agent", - messagingPlatforms: [], - }), + getMessagingManifestAvailabilityContext({ name: "langchain-deepagents-code" }, manifests), ).toEqual({ agent: null, supportedChannelIds: [], }); + expect(getMessagingManifestAvailabilityContext({ name: "custom-agent" }, manifests)).toEqual({ + agent: null, + supportedChannelIds: [], + }); }); }); diff --git a/src/lib/messaging/utils.ts b/src/lib/messaging/utils.ts index e745bf0593..4c5e5a8a8e 100644 --- a/src/lib/messaging/utils.ts +++ b/src/lib/messaging/utils.ts @@ -11,70 +11,99 @@ import type { export interface MessagingAgentDescriptor { readonly name?: string; - readonly messagingPlatforms?: readonly MessagingChannelId[] | null; } export type MessagingInputResolver = (input: ChannelInputSpec) => string | null; -const MESSAGING_AGENT_IDS = [ - "openclaw", - "hermes", - "langchain-deepagents-code", -] as const satisfies readonly MessagingAgentId[]; - export class MessagingAgentNotSupportedError extends Error { readonly agentName: string; - constructor(agentName: string) { + constructor(agentName: string, supportedAgentIds: readonly MessagingAgentId[]) { super( - `Agent '${agentName}' does not support messaging. Supported agents: ${MESSAGING_AGENT_IDS.join(", ")}.`, + `Agent '${agentName}' does not support messaging. Supported agents: ${formatSupportedMessagingAgentIds(supportedAgentIds)}.`, ); this.name = "MessagingAgentNotSupportedError"; this.agentName = agentName; } } +export function listSupportedMessagingAgentIds( + manifests: readonly ChannelManifest[], +): MessagingAgentId[] { + return [...new Set(manifests.flatMap((manifest) => manifest.supportedAgents))]; +} + +export function listSupportedMessagingChannelIdsForAgent( + manifests: readonly ChannelManifest[], + agentId: MessagingAgentId, +): MessagingChannelId[] { + return manifests + .filter((manifest) => manifest.supportedAgents.includes(agentId)) + .map((manifest) => manifest.id); +} + +export function isMessagingChannelSupportedByAgent( + manifest: ChannelManifest, + agent: MessagingAgentDescriptor | null | undefined, +): boolean { + const name = typeof agent?.name === "string" ? agent.name.trim() : ""; + return name !== "" && (manifest.supportedAgents as readonly string[]).includes(name); +} + export function tryGetMessagingAgentId( agent: MessagingAgentDescriptor | null | undefined, + manifests: readonly ChannelManifest[], ): MessagingAgentId | null { const name = agent?.name; - return (MESSAGING_AGENT_IDS as readonly string[]).includes(name ?? "") + return (listSupportedMessagingAgentIds(manifests) as readonly string[]).includes(name ?? "") ? (name as MessagingAgentId) : null; } export function toMessagingAgentId( agent: MessagingAgentDescriptor | null | undefined, + manifests: readonly ChannelManifest[], ): MessagingAgentId { const name = agent?.name; if (typeof name !== "string" || name.trim() === "") { - return "openclaw"; + const supported = listSupportedMessagingAgentIds(manifests); + if (supported.includes("openclaw")) return "openclaw"; + throw new MessagingAgentNotSupportedError("openclaw", supported); } - const id = tryGetMessagingAgentId(agent); + const id = tryGetMessagingAgentId(agent, manifests); if (id === null) { - throw new MessagingAgentNotSupportedError(name); + throw new MessagingAgentNotSupportedError(name, listSupportedMessagingAgentIds(manifests)); } return id; } export function isMessagingSupportedAgent( agent: MessagingAgentDescriptor | null | undefined, + manifests: readonly ChannelManifest[], ): boolean { - if (tryGetMessagingAgentId(agent) === null) return false; - const platforms = agent?.messagingPlatforms; - return !Array.isArray(platforms) || platforms.length > 0; + const agentId = tryGetMessagingAgentId(agent, manifests); + return ( + agentId !== null && listSupportedMessagingChannelIdsForAgent(manifests, agentId).length > 0 + ); } export function getMessagingManifestAvailabilityContext( agent: MessagingAgentDescriptor | null | undefined, + manifests: readonly ChannelManifest[], ): ChannelManifestAvailabilityContext { - const id = tryGetMessagingAgentId(agent); - const platforms = agent?.messagingPlatforms; + const name = typeof agent?.name === "string" ? agent.name.trim() : ""; + const id = name ? tryGetMessagingAgentId(agent, manifests) : null; return { agent: id, - supportedChannelIds: Array.isArray(platforms) ? platforms : null, + supportedChannelIds: name && id === null ? [] : null, }; } +export function formatSupportedMessagingAgentIds( + supportedAgentIds: readonly MessagingAgentId[], +): string { + return supportedAgentIds.length > 0 ? supportedAgentIds.join(", ") : "(none)"; +} + export function resolveMessagingManifestSeed( manifests: readonly ChannelManifest[], existingChannels: readonly string[] | null | undefined, diff --git a/src/lib/onboard/machine/handlers/sandbox.test.ts b/src/lib/onboard/machine/handlers/sandbox.test.ts index be45f205cd..e74bbe2360 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; name?: string; messagingPlatforms?: string[] } | null; +type Agent = { displayName?: string; name?: string } | null; type WebSearchConfig = { fetchEnabled: true }; type MessagingChannelConfig = Record; type SandboxGpuConfig = { sandboxGpuEnabled: boolean; mode: string }; @@ -614,7 +614,7 @@ describe("handleSandboxState", () => { expect(getSession().messagingPlan).toEqual(emptyRebuildPlan); }); - it("clears env-staged messaging plans when the current agent declares an empty allowlist", async () => { + it("clears env-staged messaging plans when the current agent has no channel manifest support", async () => { const stalePlan = makeMinimalPlan("my-assistant", "openclaw", ["telegram"]); const session = createSession({ sandboxName: "my-assistant", messagingPlan: stalePlan }); const getRecordedMessagingChannelsForResume = vi.fn(() => ["telegram"]); @@ -629,7 +629,7 @@ describe("handleSandboxState", () => { ...baseOptions(deps, session), resume: true, sandboxName: "my-assistant", - agent: { name: "openclaw", messagingPlatforms: [] }, + agent: { name: "langchain-deepagents-code" }, }); expect(calls.clearPlanEnv).toHaveBeenCalledTimes(1); @@ -655,7 +655,7 @@ describe("handleSandboxState", () => { ...baseOptions(deps, session), resume: true, sandboxName: "my-assistant", - agent: { name: "custom-agent", messagingPlatforms: ["discord"] }, + agent: { name: "custom-agent" }, }); expect(calls.clearPlanEnv).toHaveBeenCalledTimes(1); diff --git a/src/lib/onboard/machine/handlers/sandbox.ts b/src/lib/onboard/machine/handlers/sandbox.ts index ef51b159bb..8db3cadf6d 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -1,7 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { isMessagingSupportedAgent, tryGetMessagingAgentId } from "../../../messaging"; +import { + createBuiltInChannelManifestRegistry, + listSupportedMessagingChannelIdsForAgent, + tryGetMessagingAgentId, +} from "../../../messaging"; import type { MessagingAgentId, SandboxMessagingPlan } from "../../../messaging/manifest"; import { hashCredential } from "../../../security/credential-hash"; import type { Session, SessionUpdates } from "../../../state/onboard-session"; @@ -158,9 +162,10 @@ function refreshCredentialHashesFromEnv(plan: SandboxMessagingPlan): { type MessagingAgentLike = { readonly name?: string; - readonly messagingPlatforms?: readonly string[] | null; }; +const messagingManifestRegistry = createBuiltInChannelManifestRegistry(); + function resolveCurrentMessagingAgent(agent: unknown): { readonly agentId: MessagingAgentId | null; readonly supportedChannelIds: readonly string[] | null; @@ -168,15 +173,14 @@ function resolveCurrentMessagingAgent(agent: unknown): { 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)) { + const manifests = messagingManifestRegistry.list(); + const agentId = tryGetMessagingAgentId(descriptor, manifests); + if (agentId === null) { return { agentId: null, supportedChannelIds: [] }; } return { agentId, - supportedChannelIds: Array.isArray(descriptor.messagingPlatforms) - ? descriptor.messagingPlatforms - : null, + supportedChannelIds: listSupportedMessagingChannelIdsForAgent(manifests, agentId), }; } diff --git a/src/lib/onboard/messaging-channel-setup-fallback.test.ts b/src/lib/onboard/messaging-channel-setup-fallback.test.ts index 4e74cad24c..ab93b50bed 100644 --- a/src/lib/onboard/messaging-channel-setup-fallback.test.ts +++ b/src/lib/onboard/messaging-channel-setup-fallback.test.ts @@ -170,8 +170,7 @@ describe("setupMessagingChannels selector fallback", () => { const result = await setupMessagingChannels( { - name: "openclaw", - messagingPlatforms: ["unsupported-channel"], + name: "custom-agent", } as unknown as Parameters[0], null, { isNonInteractive: () => false }, diff --git a/src/lib/onboard/messaging-channel-setup.ts b/src/lib/onboard/messaging-channel-setup.ts index b26c4ea76b..74d802762b 100644 --- a/src/lib/onboard/messaging-channel-setup.ts +++ b/src/lib/onboard/messaging-channel-setup.ts @@ -77,7 +77,10 @@ export async function setupMessagingChannels( const isNonInteractive = deps.isNonInteractive ?? (() => process.env.NEMOCLAW_NON_INTERACTIVE === "1"); const manifestRegistry = createBuiltInChannelManifestRegistry(); - const availabilityContext = getMessagingManifestAvailabilityContext(agent); + const availabilityContext = getMessagingManifestAvailabilityContext( + agent, + manifestRegistry.list(), + ); const availableChannels = manifestRegistry.listAvailable(availabilityContext); const hasManifestRequiredInputs = (manifest: ChannelManifest) => hasMessagingManifestRequiredInputs(manifest, getMessagingInputValue); @@ -169,7 +172,7 @@ export async function setupSelectedMessagingChannels( return null; } - const agent = toMessagingAgentId(options.agent); + const agent = toMessagingAgentId(options.agent, registry.list()); const sandboxName = resolveMessagingSetupSandboxName(options); const planner = new MessagingWorkflowPlanner( registry, diff --git a/src/lib/onboard/messaging-state.test.ts b/src/lib/onboard/messaging-state.test.ts index e3c506d67c..c3a8e0e250 100644 --- a/src/lib/onboard/messaging-state.test.ts +++ b/src/lib/onboard/messaging-state.test.ts @@ -6,31 +6,33 @@ import { describe, expect, it } from "vitest"; import type { AgentDefinition } from "../agent/defs"; import { filterEnabledChannelsByAgent, resolveQrSelectedChannels } from "./messaging-state"; -function agent(messagingPlatforms: string[] | undefined): AgentDefinition { - return { messagingPlatforms } as unknown as AgentDefinition; +function agent(name: string): AgentDefinition { + return { name } as unknown as AgentDefinition; } describe("filterEnabledChannelsByAgent", () => { - it("drops channels not declared by the agent manifest", () => { - expect(filterEnabledChannelsByAgent(["whatsapp", "telegram"], agent(["telegram"]))).toEqual([ + it("keeps channels listed by the channel manifests for the current agent", () => { + expect(filterEnabledChannelsByAgent(["whatsapp", "telegram"], agent("openclaw"))).toEqual([ + "whatsapp", "telegram", ]); }); - it("drops every channel when the agent declares an explicit empty supported list", () => { - expect(filterEnabledChannelsByAgent(["whatsapp", "telegram"], agent([]))).toEqual([]); + it("drops every channel when no channel manifest supports the agent", () => { + expect( + filterEnabledChannelsByAgent(["whatsapp", "telegram"], agent("langchain-deepagents-code")), + ).toEqual([]); }); - it("keeps every channel when the agent has no supported-list metadata", () => { - expect(filterEnabledChannelsByAgent(["whatsapp", "telegram"], agent(undefined))).toEqual([ - "whatsapp", - "telegram", - ]); + it("drops every channel when the agent name is unknown", () => { + expect(filterEnabledChannelsByAgent(["whatsapp", "telegram"], agent("custom-agent"))).toEqual( + [], + ); }); it("returns null/undefined inputs unchanged", () => { - expect(filterEnabledChannelsByAgent(null, agent(["telegram"]))).toBeNull(); - expect(filterEnabledChannelsByAgent(undefined, agent(["telegram"]))).toBeUndefined(); + expect(filterEnabledChannelsByAgent(null, agent("openclaw"))).toBeNull(); + expect(filterEnabledChannelsByAgent(undefined, agent("openclaw"))).toBeUndefined(); }); it("preserves the list when the agent is null (no filter)", () => { diff --git a/src/lib/onboard/messaging-state.ts b/src/lib/onboard/messaging-state.ts index 98d151e8a3..800348c0a8 100644 --- a/src/lib/onboard/messaging-state.ts +++ b/src/lib/onboard/messaging-state.ts @@ -2,6 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 import type { AgentDefinition } from "../agent/defs"; +import { + createBuiltInChannelManifestRegistry, + getMessagingManifestAvailabilityContext, +} from "../messaging"; import { channelUsesInSandboxQrPairing, type ChannelDef } from "../sandbox/channels"; export type MessagingChannel = { name: string } & ChannelDef; @@ -25,8 +29,11 @@ export function filterEnabledChannelsByAgent supported.includes(n)) as T; + const registry = createBuiltInChannelManifestRegistry(); + const available = registry.listAvailable( + getMessagingManifestAvailabilityContext(agent, registry.list()), + ); + if (available.length === 0) return [] as unknown as T; + const supported = new Set(available.map((manifest) => manifest.id)); + return enabledChannels.filter((n) => supported.has(n)) as T; } diff --git a/test/channels-add-deepagents-rejection.test.ts b/test/channels-add-deepagents-rejection.test.ts index 469b2839e9..9f0a2804d2 100644 --- a/test/channels-add-deepagents-rejection.test.ts +++ b/test/channels-add-deepagents-rejection.test.ts @@ -103,7 +103,6 @@ rebuild.rebuildSandbox = async (name, args, opts) => { rebuildCalls.push({ name, const agentDefs = require(${d("agent/defs.js")}); agentDefs.loadAgent = () => ({ name: ${JSON.stringify(agentName)}, - messagingPlatforms: [], }); const channelModule = require(${d("actions/sandbox/policy-channel.js")}); @@ -130,8 +129,8 @@ module.exports = { `; } -describe("addSandboxChannel agent gate (behaviour)", () => { - it("DeepAgents channels add discord exits non-mutatingly with the unsupported-agent message", () => { +describe("addSandboxChannel channel/agent gate (behaviour)", () => { + it("DeepAgents channels add discord exits non-mutatingly with the unsupported channel-agent message", () => { const script = `${buildPreamble("langchain-deepagents-code")} const ctx = module.exports; (async () => { @@ -178,13 +177,19 @@ 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), + /Channel 'discord' does not support agent 'langchain-deepagents-code'/.test(msg), ), - `missing unsupported-agent error in stderr: ${JSON.stringify(payload.errors)}`, + `missing unsupported channel-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)}`, + payload.errors.some((msg) => /Channel-supported agents: openclaw, hermes/.test(msg)), + `missing channel-supported agents hint in stderr: ${JSON.stringify(payload.errors)}`, + ); + assert.ok( + payload.errors.some((msg) => + /Channels supported by agent 'langchain-deepagents-code': \(none\)/.test(msg), + ), + `missing agent-supported channels hint in stderr: ${JSON.stringify(payload.errors)}`, ); assert.deepEqual(payload.policyCalls.loadPreset, [], "loadPreset must not run before the gate"); diff --git a/test/dcode-start-keepalive.test.ts b/test/dcode-start-keepalive.test.ts index ee2125852c..ee819bcdea 100644 --- a/test/dcode-start-keepalive.test.ts +++ b/test/dcode-start-keepalive.test.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 diff --git a/test/hermes-secret-boundary-api-key.test.ts b/test/hermes-secret-boundary-api-key.test.ts index f2a8f1b11a..dec0bbbda7 100644 --- a/test/hermes-secret-boundary-api-key.test.ts +++ b/test/hermes-secret-boundary-api-key.test.ts @@ -17,6 +17,9 @@ const SECRET_BOUNDARY_VALIDATOR_SCRIPT = path.join( const GENERATED_HEX_TOKEN = Array.from({ length: 64 }, (_value, index) => (index % 16).toString(16), ).join(""); +const INHERITED_HEX_TOKEN = Array.from({ length: 64 }, (_value, index) => + (15 - (index % 16)).toString(16), +).join(""); function runEnvFileValidator(envFileContent: string) { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-api-key-boundary-")); @@ -75,14 +78,14 @@ describe("agents/hermes/validate-env-secret-boundary API_SERVER_KEY contract", ( const runtimeEnvResult = runRuntimeEnvValidator({ API_SERVER_HOST: "127.0.0.1", API_SERVER_PORT: "18642", - API_SERVER_KEY: "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210", + API_SERVER_KEY: INHERITED_HEX_TOKEN, }); expect(envFileResult.status, envFileResult.stderr).toBe(0); expect(runtimeEnvResult.status).toBe(1); expect(runtimeEnvResult.stderr).toContain("process environment"); expect(runtimeEnvResult.stderr).toContain("API_SERVER_KEY"); - expect(runtimeEnvResult.stderr).not.toContain("fedcba9876543210"); + expect(runtimeEnvResult.stderr).not.toContain(INHERITED_HEX_TOKEN.slice(0, 16)); }); it("rejects weak API_SERVER_KEY values in Hermes .env without printing the value", () => { diff --git a/test/hermes-start.test.ts b/test/hermes-start.test.ts index df246994c7..564108e85a 100644 --- a/test/hermes-start.test.ts +++ b/test/hermes-start.test.ts @@ -17,6 +17,9 @@ const SECRET_BOUNDARY_VALIDATOR_SCRIPT = path.join( "hermes", "validate-env-secret-boundary.py", ); +const GENERATED_API_SERVER_KEY = Array.from({ length: 64 }, (_value, index) => + (index % 16).toString(16), +).join(""); function bashPrintfQ(value: string): string { const result = spawnSync("bash", ["-c", "printf '%q' \"$1\"", "bash-printf-q", value], { @@ -353,6 +356,7 @@ function runTirithExplicitCommandDispatch(mode: "non-root" | "root") { function runHermesRootStartupMutableRootPreflight() { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-root-preflight-")); const hermesHome = path.join(tmpDir, ".hermes"); + const chmodLog = path.join(tmpDir, "chmod.log"); const scriptPath = path.join(tmpDir, "run.sh"); fs.mkdirSync(hermesHome, { recursive: true }); @@ -368,7 +372,10 @@ function runHermesRootStartupMutableRootPreflight() { extractShellFunctionFromSource(src, "hermes_config_root_is_locked"), extractShellFunctionFromSource(src, "ensure_hermes_config_root_mode"), 'id() { [ "${1:-}" = "-u" ] && printf "1000\\n" || command id "$@"; }', - 'dir_mode() { python3 -c "import os,sys; print(oct(os.stat(sys.argv[1]).st_mode & 0o777)[2:])" "$HERMES_DIR"; }', + `CHMOD_LOG=${shellQuote(chmodLog)}`, + "HERMES_DIR_MODE=750", + 'chmod() { if [ "${1:-}" = "3770" ] && [ "${2:-}" = "$HERMES_DIR" ]; then printf "%s\\n" "$1" > "$CHMOD_LOG"; HERMES_DIR_MODE=770; command chmod 770 "$2"; return 0; fi; command chmod "$@"; }', + 'dir_mode() { printf "%s\\n" "$HERMES_DIR_MODE"; }', 'verify_hermes_config_integrity() { printf "verify mode=%s\\n" "$(dir_mode)"; }', 'ensure_hermes_runtime_api_server_key() { printf "api-key mode=%s\\n" "$(dir_mode)"; }', "apply_shields_up_runtime_env() { :; }", @@ -381,7 +388,7 @@ function runHermesRootStartupMutableRootPreflight() { 'cleanup_stale_hermes_gateway_runtime() { echo "unexpected gateway cleanup" >&2; return 99; }', `HERMES_DIR=${shellQuote(hermesHome)}`, `HERMES_HASH_FILE=${shellQuote(path.join(tmpDir, "hermes.config-hash"))}`, - "STEP_DOWN_PREFIX_SANDBOX=()", + "STEP_DOWN_PREFIX_SANDBOX=(env)", "NEMOCLAW_CMD=(bash -c 'exit 0')", extractTirithDispatchBlock(src, "root"), ].join("\n"), @@ -396,7 +403,9 @@ function runHermesRootStartupMutableRootPreflight() { }); return { result, - hermesDirMode: (fs.statSync(hermesHome).mode & 0o7777).toString(8), + hermesDirMode: fs.existsSync(chmodLog) + ? fs.readFileSync(chmodLog, "utf-8").trim() + : (fs.statSync(hermesHome).mode & 0o7777).toString(8), }; } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); @@ -992,7 +1001,7 @@ describe("agents/hermes/start.sh env secret boundary", () => { envFile: [ "API_SERVER_PORT=18642", "API_SERVER_HOST=127.0.0.1", - "API_SERVER_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + `API_SERVER_KEY=${GENERATED_API_SERVER_KEY}`, "", ].join("\n"), }); @@ -1063,7 +1072,7 @@ describe("agents/hermes/start.sh env secret boundary", () => { }); it("rejects inherited API_SERVER_KEY process env values", () => { - const inheritedKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + const inheritedKey = GENERATED_API_SERVER_KEY; const result = runHermesRuntimeEnvSecretBoundary({ API_SERVER_HOST: "127.0.0.1", API_SERVER_PORT: "18642", diff --git a/test/langchain-deepagents-code-image.test.ts b/test/langchain-deepagents-code-image.test.ts index bc53e495b5..54635b4971 100644 --- a/test/langchain-deepagents-code-image.test.ts +++ b/test/langchain-deepagents-code-image.test.ts @@ -26,19 +26,21 @@ function policyBinaryPaths(policyText: string, policyName: string): string[] { expect(Array.isArray(binaries), `${policyName} policy must declare binary-scoped egress`).toBe( true, ); - return (binaries ?? []).map((entry) => (typeof entry.path === "string" ? entry.path : "")); + return (binaries ?? []).map((entry, index) => { + expect(typeof entry.path, `${policyName} binary #${index} must declare a string path`).toBe( + "string", + ); + return entry.path as 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)"', @@ -46,7 +48,7 @@ function makeStartScriptFixture(tempDir: string): { ); fs.writeFileSync(scriptPath, fixture, "utf8"); fs.chmodSync(scriptPath, 0o755); - return { envFile, messagingEnvFile, scriptPath }; + return { envFile, scriptPath }; } describe("LangChain Deep Agents Code image contracts", () => { @@ -64,14 +66,17 @@ describe("LangChain Deep Agents Code image contracts", () => { ); }); - it("declares the messaging plan build arg before the DeepAgents build applier runs", () => { + it("does not wire unsupported messaging artifacts into the DeepAgents image", () => { const dockerfile = readAgentFile("Dockerfile"); + const startScript = readAgentFile("start.sh"); - 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"), - ); + expect(dockerfile).not.toContain("NEMOCLAW_MESSAGING_PLAN_B64"); + expect(dockerfile).not.toContain("messaging-build-applier.mts"); + expect(startScript).toContain("Setting up NemoClaw Deep Agents Code runtime"); + expect(startScript).not.toContain("load_messaging_env"); + expect(startScript).not.toContain("TELEGRAM_BOT_TOKEN"); + expect(startScript).not.toContain("DISCORD_BOT_TOKEN"); + expect(startScript).not.toContain("SLACK_BOT_TOKEN"); }); it("prints NemoClaw setup output before idling as a terminal runtime", () => { @@ -115,49 +120,6 @@ 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 e7ded8c3bf..5538068689 100644 --- a/test/messaging-build-applier.test.ts +++ b/test/messaging-build-applier.test.ts @@ -943,82 +943,6 @@ 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 = { diff --git a/test/sandbox-init.test.ts b/test/sandbox-init.test.ts index f493b00a51..1ffd28256f 100644 --- a/test/sandbox-init.test.ts +++ b/test/sandbox-init.test.ts @@ -698,7 +698,7 @@ EOF }); it("uses bash builtin ulimit for nproc and nofile enforcement and verification", () => { - const nprocLimit = process.platform === "darwin" ? 4096 : 512; + const nprocLimit = process.platform === "darwin" ? 4000 : 512; const { stdout } = runWithLib( [ `NEMOCLAW_SANDBOX_NPROC_LIMIT=${nprocLimit}`, diff --git a/test/skills-frontmatter.test.ts b/test/skills-frontmatter.test.ts index 4037c5b09a..b36c877cee 100644 --- a/test/skills-frontmatter.test.ts +++ b/test/skills-frontmatter.test.ts @@ -88,6 +88,19 @@ describe("repo skill markdown files", () => { }); } + it("keeps messaging channel support guidance manifest-owned", () => { + const skillFile = path.join( + skillsRoot, + "nemoclaw-contributor-onboard-messaging-channel", + "SKILL.md", + ); + const raw = fs.readFileSync(skillFile, "utf8"); + + expect(raw).toContain("through `supportedAgents`"); + expect(raw).toContain("Do not edit agent manifests for channel availability"); + expect(raw).not.toContain("so supported platforms match the manifest `supportedAgents`"); + }); + it("keeps contributor PR creation anchored to the trusted base template", () => { const skillPath = path.join(skillsRoot, "nemoclaw-contributor-create-pr", "SKILL.md"); const skill = fs.readFileSync(skillPath, "utf8");