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