From 59c63094c3f51aeedc97c333b6083550f152f798 Mon Sep 17 00:00:00 2001 From: San Dang Date: Mon, 8 Jun 2026 15:50:09 +0530 Subject: [PATCH 01/23] feat(messaging): move build setup to manifest hooks --- Dockerfile | 52 +- agents/hermes/Dockerfile | 20 +- agents/hermes/config/build-env.ts | 55 -- agents/hermes/config/hermes-config.ts | 100 ++-- agents/hermes/config/hermes-env.ts | 23 + agents/hermes/config/manifest-hooks.ts | 202 ++++++++ agents/hermes/config/messaging-config.ts | 195 ------- agents/hermes/generate-config.ts | 22 +- agents/hermes/policy-additions.yaml | 6 +- scripts/generate-openclaw-config.mts | 453 ++++++++-------- scripts/lib/sandbox-init.sh | 6 +- scripts/openclaw-build-messaging-plugins.py | 184 ------- scripts/run-openclaw-build-hooks.mts | 249 +++++++++ scripts/seed-wechat-accounts.py | 407 --------------- src/ext/wechat/qr.ts | 8 +- src/lib/actions/sandbox/policy-channel.ts | 4 +- src/lib/actions/sandbox/rebuild.ts | 108 ++-- src/lib/adapters/openshell/client.ts | 2 + src/lib/adapters/openshell/runtime.ts | 2 + src/lib/messaging/applier/agent-config.ts | 8 + .../messaging/applier/setup-applier.test.ts | 13 +- .../messaging/channels/discord/manifest.ts | 65 ++- src/lib/messaging/channels/manifests.test.ts | 153 +++--- src/lib/messaging/channels/slack/manifest.ts | 69 ++- .../messaging/channels/telegram/manifest.ts | 48 +- src/lib/messaging/channels/wechat/manifest.ts | 24 + .../messaging/channels/whatsapp/manifest.ts | 87 +++- .../compiler/engines/agent-render-engine.ts | 135 +++-- .../compiler/engines/build-step-engine.ts | 84 ++- .../messaging/compiler/engines/template.ts | 248 ++++++++- .../compiler/manifest-compiler.test.ts | 70 ++- .../messaging/compiler/manifest-compiler.ts | 57 +- .../compiler/workflow-planner.test.ts | 19 + src/lib/messaging/hooks/common/index.ts | 11 +- .../messaging/hooks/common/static-outputs.ts | 38 ++ .../hooks/common/token-paste.test.ts | 21 +- src/lib/messaging/hooks/hook-runner.test.ts | 38 ++ src/lib/messaging/manifest/types.ts | 38 +- src/lib/onboard.ts | 66 +-- src/lib/onboard/dockerfile-patch.test.ts | 331 +++--------- src/lib/onboard/dockerfile-patch.ts | 49 +- src/lib/onboard/messaging-config.test.ts | 81 --- src/lib/onboard/messaging-config.ts | 83 +-- src/lib/onboard/wechat-config.ts | 4 +- src/lib/sandbox/build-context.ts | 11 +- test/e2e/docs/parity-inventory.generated.json | 2 +- test/e2e/test-messaging-providers.sh | 20 +- test/generate-hermes-config.test.ts | 44 +- test/generate-openclaw-config.test.ts | 61 ++- test/messaging-plan-test-helper.ts | 250 +++++++++ test/onboard-messaging.test.ts | 133 +++-- test/onboard.test.ts | 12 +- ...st.ts => run-openclaw-build-hooks.test.ts} | 53 +- test/sandbox-build-context.test.ts | 6 +- test/sandbox-provisioning.test.ts | 9 +- test/seed-wechat-accounts.test.ts | 489 ------------------ 56 files changed, 2429 insertions(+), 2599 deletions(-) create mode 100644 agents/hermes/config/hermes-env.ts create mode 100644 agents/hermes/config/manifest-hooks.ts delete mode 100644 agents/hermes/config/messaging-config.ts delete mode 100755 scripts/openclaw-build-messaging-plugins.py create mode 100755 scripts/run-openclaw-build-hooks.mts delete mode 100755 scripts/seed-wechat-accounts.py create mode 100644 src/lib/messaging/hooks/common/static-outputs.ts delete mode 100644 src/lib/onboard/messaging-config.test.ts create mode 100644 test/messaging-plan-test-helper.ts rename test/{openclaw-build-messaging-plugins.test.ts => run-openclaw-build-hooks.test.ts} (89%) delete mode 100644 test/seed-wechat-accounts.test.ts diff --git a/Dockerfile b/Dockerfile index 1eaede21ca..7fa3037abb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -410,14 +410,12 @@ COPY scripts/nemoclaw-start.sh /usr/local/bin/nemoclaw-start COPY nemoclaw-blueprint/scripts/*.js /usr/local/lib/nemoclaw/preloads/ COPY scripts/codex-acp-wrapper.sh /usr/local/bin/nemoclaw-codex-acp COPY scripts/generate-openclaw-config.mts /usr/local/lib/nemoclaw/generate-openclaw-config.mts -COPY scripts/openclaw-build-messaging-plugins.py /usr/local/lib/nemoclaw/openclaw-build-messaging-plugins.py -COPY scripts/seed-wechat-accounts.py /usr/local/lib/nemoclaw/seed-wechat-accounts.py +COPY scripts/run-openclaw-build-hooks.mts /usr/local/lib/nemoclaw/run-openclaw-build-hooks.mts COPY nemoclaw-blueprint/openclaw-plugins/ /usr/local/share/nemoclaw/openclaw-plugins/ RUN chmod 755 /usr/local/bin/nemoclaw-start /usr/local/bin/nemoclaw-codex-acp \ /usr/local/lib/nemoclaw/sandbox-init.sh \ /usr/local/lib/nemoclaw/generate-openclaw-config.mts \ - /usr/local/lib/nemoclaw/openclaw-build-messaging-plugins.py \ - /usr/local/lib/nemoclaw/seed-wechat-accounts.py \ + /usr/local/lib/nemoclaw/run-openclaw-build-hooks.mts \ && chmod 644 /usr/local/lib/nemoclaw/openclaw_device_approval_policy.py \ /usr/local/lib/nemoclaw/clean_runtime_shell_env_shim.py \ && if [ -d /usr/local/lib/nemoclaw/preloads ]; then find /usr/local/lib/nemoclaw/preloads -type f -name '*.js' -exec chmod 644 {} +; fi \ @@ -454,34 +452,10 @@ ARG NEMOCLAW_AGENT_TIMEOUT=600 # change at image build time. Ref: issue #2880 ARG NEMOCLAW_AGENT_HEARTBEAT_EVERY= ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30= -# Base64-encoded JSON list of messaging channel names to pre-configure -# (e.g. ["discord","telegram"]). Channels are added with placeholder tokens -# so the L7 proxy can rewrite them at egress. Default: empty list. -ARG NEMOCLAW_MESSAGING_CHANNELS_B64=W10= -# Base64-encoded JSON map of channel→allowed sender IDs for DM allowlisting -# (e.g. {"telegram":["123456789"]}). Channels with IDs get dmPolicy=allowlist. -# Slack also uses those IDs for channel @mention allowlisting. Channels without -# IDs keep the OpenClaw default (pairing). Default: empty map. -ARG NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=e30= -# Base64-encoded JSON map of Discord guild configs keyed by server ID -# (e.g. {"1234567890":{"requireMention":true,"users":["555"]}}). -# Used to enable guild-channel responses for native Discord. Default: empty map. -ARG NEMOCLAW_DISCORD_GUILDS_B64=e30= -# Base64-encoded JSON Telegram config (e.g. {"requireMention":true}). -# When requireMention is true, Telegram groups get groups: {"*": {"requireMention": true}} -# with groupPolicy: open. See #1737, #3022. Default: empty map. -ARG NEMOCLAW_TELEGRAM_CONFIG_B64=e30= -# Base64-encoded JSON WeChat config (e.g. -# {"accountId":"…","baseUrl":"https://…","userId":"…"}). -# Captured by the host-side iLink QR login during onboard. Non-secret per-account -# metadata only — the bot token flows through the OpenShell provider, never -# baked into the image. Default: empty map. -ARG NEMOCLAW_WECHAT_CONFIG_B64=e30= -# Base64-encoded JSON Slack config (e.g. -# {"allowedChannels":["C012AB3CD","C987ZY6XW"]}). -# Channel IDs scope Slack channel @mention handling. User allowlists still come -# from NEMOCLAW_MESSAGING_ALLOWED_IDS_B64. Default: empty map. -ARG NEMOCLAW_SLACK_CONFIG_B64=e30= +# Base64-encoded manifest hook plan for messaging build inputs and agent +# rendering. The plan contains placeholders only; secrets are resolved at +# runtime via OpenShell providers. +ARG NEMOCLAW_MESSAGING_PLAN_B64= # Base64-encoded JSON array of secondary OpenClaw agent config entries # (e.g. [{"id":"research","workspace":"/sandbox/.openclaw/workspace-research", # "agentDir":"/sandbox/.openclaw/agents/research", ...}]). @@ -535,12 +509,7 @@ ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \ NEMOCLAW_AGENT_TIMEOUT=${NEMOCLAW_AGENT_TIMEOUT} \ NEMOCLAW_AGENT_HEARTBEAT_EVERY=${NEMOCLAW_AGENT_HEARTBEAT_EVERY} \ NEMOCLAW_INFERENCE_COMPAT_B64=${NEMOCLAW_INFERENCE_COMPAT_B64} \ - NEMOCLAW_MESSAGING_CHANNELS_B64=${NEMOCLAW_MESSAGING_CHANNELS_B64} \ - NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=${NEMOCLAW_MESSAGING_ALLOWED_IDS_B64} \ - NEMOCLAW_DISCORD_GUILDS_B64=${NEMOCLAW_DISCORD_GUILDS_B64} \ - NEMOCLAW_TELEGRAM_CONFIG_B64=${NEMOCLAW_TELEGRAM_CONFIG_B64} \ - NEMOCLAW_WECHAT_CONFIG_B64=${NEMOCLAW_WECHAT_CONFIG_B64} \ - NEMOCLAW_SLACK_CONFIG_B64=${NEMOCLAW_SLACK_CONFIG_B64} \ + NEMOCLAW_MESSAGING_PLAN_B64=${NEMOCLAW_MESSAGING_PLAN_B64} \ NEMOCLAW_EXTRA_AGENTS_JSON_B64=${NEMOCLAW_EXTRA_AGENTS_JSON_B64} \ NEMOCLAW_OPENCLAW_WECHAT_PLUGIN_PREINSTALLED=1 \ NEMOCLAW_DISABLE_DEVICE_AUTH=${NEMOCLAW_DISABLE_DEVICE_AUTH} \ @@ -578,7 +547,7 @@ USER sandbox RUN NEMOCLAW_OPENCLAW_MANAGED_PROXY=0 node --experimental-strip-types /usr/local/lib/nemoclaw/generate-openclaw-config.mts # hadolint ignore=DL3059,DL4006 -RUN python3 /usr/local/lib/nemoclaw/openclaw-build-messaging-plugins.py +RUN node --experimental-strip-types /usr/local/lib/nemoclaw/run-openclaw-build-hooks.mts # Lock down npm for the next RUN: the local OpenClaw plugin install must # resolve from /opt/nemoclaw and the staged plugin-runtime-deps tree without @@ -592,8 +561,8 @@ ENV NPM_CONFIG_OFFLINE=true \ # This must fail the image build if registration fails; otherwise the sandbox # can boot with a discoverable plugin manifest but without the /nemoclaw runtime # command registered in the active Gateway. -# Re-apply WeChat account seeding after OpenClaw doctor/plugin-install touches -# openclaw.json; the seed script no-ops unless WeChat is actively configured. +# WeChat account seed files are written during config generation from +# serialized manifest hook build-file outputs before the sandbox starts. # Prune non-runtime metadata from staged bundled plugin dependencies before # this layer is committed; deleting it in a later layer would not reduce the # OCI image imported by k3s. @@ -601,7 +570,6 @@ ENV NPM_CONFIG_OFFLINE=true \ RUN openclaw plugins install /opt/nemoclaw \ && openclaw plugins enable nemoclaw \ && openclaw plugins inspect nemoclaw --json > /dev/null \ - && python3 /usr/local/lib/nemoclaw/seed-wechat-accounts.py \ && if [ -d /sandbox/.openclaw/plugin-runtime-deps ]; then \ find /sandbox/.openclaw/plugin-runtime-deps -type f \( \ -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' -o \ diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 483e11fd40..1d68f0e614 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -100,18 +100,7 @@ ARG NEMOCLAW_INFERENCE_API=openai-completions # Hermes this URL points at the browser dashboard. The OpenAI-compatible # API remains exposed separately on port 8642. ARG CHAT_UI_URL=http://127.0.0.1:18789 -ARG NEMOCLAW_MESSAGING_CHANNELS_B64=W10= -ARG NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=e30= -ARG NEMOCLAW_DISCORD_GUILDS_B64=e30= -ARG NEMOCLAW_TELEGRAM_CONFIG_B64=e30= -# Captured by NemoClaw's host-side iLink QR login during onboard (see -# src/lib/onboard/wechat-config.ts). Carries {accountId, baseUrl, userId} so -# the Hermes WeChat adapter starts with WEIXIN_ACCOUNT_ID/WEIXIN_BASE_URL -# already populated from .env; no in-sandbox QR re-scan. The token itself -# is never baked here — it flows through the OpenShell L7 proxy via the -# WECHAT_BOT_TOKEN credential slot. -ARG NEMOCLAW_WECHAT_CONFIG_B64=e30= -ARG NEMOCLAW_SLACK_CONFIG_B64=e30= +ARG NEMOCLAW_MESSAGING_PLAN_B64= ARG NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER=0 ARG NEMOCLAW_HERMES_TOOL_GATEWAY_PRESETS_B64=W10= ARG NEMOCLAW_BUILD_ID=default @@ -123,12 +112,7 @@ ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \ NEMOCLAW_INFERENCE_BASE_URL=${NEMOCLAW_INFERENCE_BASE_URL} \ NEMOCLAW_INFERENCE_API=${NEMOCLAW_INFERENCE_API} \ CHAT_UI_URL=${CHAT_UI_URL} \ - NEMOCLAW_MESSAGING_CHANNELS_B64=${NEMOCLAW_MESSAGING_CHANNELS_B64} \ - NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=${NEMOCLAW_MESSAGING_ALLOWED_IDS_B64} \ - NEMOCLAW_DISCORD_GUILDS_B64=${NEMOCLAW_DISCORD_GUILDS_B64} \ - NEMOCLAW_TELEGRAM_CONFIG_B64=${NEMOCLAW_TELEGRAM_CONFIG_B64} \ - NEMOCLAW_WECHAT_CONFIG_B64=${NEMOCLAW_WECHAT_CONFIG_B64} \ - NEMOCLAW_SLACK_CONFIG_B64=${NEMOCLAW_SLACK_CONFIG_B64} \ + NEMOCLAW_MESSAGING_PLAN_B64=${NEMOCLAW_MESSAGING_PLAN_B64} \ NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER=${NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER} \ NEMOCLAW_HERMES_TOOL_GATEWAY_PRESETS_B64=${NEMOCLAW_HERMES_TOOL_GATEWAY_PRESETS_B64} diff --git a/agents/hermes/config/build-env.ts b/agents/hermes/config/build-env.ts index 3646e860ac..212a1e5309 100644 --- a/agents/hermes/config/build-env.ts +++ b/agents/hermes/config/build-env.ts @@ -3,35 +3,6 @@ import { Buffer } from "node:buffer"; -export type MessagingAllowedIds = Record; - -export type DiscordGuilds = Record< - string, - { - requireMention?: boolean; - users?: (string | number)[]; - } ->; - -export type TelegramConfig = { - requireMention?: boolean; -}; - -// Non-secret per-account metadata captured by the host-side iLink QR login -// during onboard (src/lib/onboard/wechat-config.ts). The bot token itself -// stays in the OpenShell credential store; only these fields are serialized -// into the build arg, so the in-sandbox adapter can hydrate WEIXIN_ACCOUNT_ID -// and WEIXIN_BASE_URL without a fresh QR scan on rebuild. -export type WechatConfig = { - accountId?: string; - baseUrl?: string; - userId?: string; -}; - -export type SlackConfig = { - allowedChannels?: string[]; -}; - export type HermesBuildSettings = { model: string; baseUrl: string; @@ -41,14 +12,6 @@ export type HermesBuildSettings = { brokerEnabled: boolean; presets: string[]; }; - messaging: { - enabledChannels: Set; - allowedIds: MessagingAllowedIds; - discordGuilds: DiscordGuilds; - telegramConfig: TelegramConfig; - wechatConfig: WechatConfig; - slackConfig: SlackConfig; - }; }; export function readHermesBuildSettings(env: NodeJS.ProcessEnv): HermesBuildSettings { @@ -68,24 +31,6 @@ export function readHermesBuildSettings(env: NodeJS.ProcessEnv): HermesBuildSett "W10=", ), }, - messaging: { - enabledChannels: new Set( - readBase64Json(env, "NEMOCLAW_MESSAGING_CHANNELS_B64", "W10="), - ), - allowedIds: readBase64Json( - env, - "NEMOCLAW_MESSAGING_ALLOWED_IDS_B64", - "e30=", - ), - discordGuilds: readBase64Json(env, "NEMOCLAW_DISCORD_GUILDS_B64", "e30="), - telegramConfig: readBase64Json( - env, - "NEMOCLAW_TELEGRAM_CONFIG_B64", - "e30=", - ), - wechatConfig: readBase64Json(env, "NEMOCLAW_WECHAT_CONFIG_B64", "e30="), - slackConfig: readBase64Json(env, "NEMOCLAW_SLACK_CONFIG_B64", "e30="), - }, }; } diff --git a/agents/hermes/config/hermes-config.ts b/agents/hermes/config/hermes-config.ts index 4b1a4d7188..4b930cd407 100644 --- a/agents/hermes/config/hermes-config.ts +++ b/agents/hermes/config/hermes-config.ts @@ -6,7 +6,6 @@ import { applyManagedToolConfig, loadManagedToolGatewayMatrix, } from "./managed-tool-gateway.ts"; -import { buildDiscordConfig } from "./messaging-config.ts"; const REMOTE_PLATFORM_TOOLSETS = [ "web", @@ -26,19 +25,7 @@ const REMOTE_PLATFORM_TOOLSETS = [ "audio", ]; -const MESSAGING_PLATFORM_BY_CHANNEL: Record = { - discord: "discord", - slack: "slack", - telegram: "telegram", - wechat: "weixin", - whatsapp: "whatsapp", -}; - function hermesApiMode(inferenceApi: string): string | null { - // Source of truth: the host-side inference selector and Dockerfile patcher - // only write the closed set below into NEMOCLAW_INFERENCE_API. Fail fast for - // any other non-empty value so host/sandbox routing contract drift does not - // silently fall back to Hermes' default OpenAI-compatible mode. switch (inferenceApi) { case "": case "openai-completions": @@ -53,7 +40,7 @@ function hermesApiMode(inferenceApi: string): string | null { } export function buildHermesConfig(settings: HermesBuildSettings): Record { - const remotePlatformToolsets = [...REMOTE_PLATFORM_TOOLSETS]; + const remotePlatformToolsets = buildHermesRemotePlatformToolsets(settings); const modelConfig: Record = { default: settings.model, provider: "custom", @@ -91,15 +78,17 @@ export function buildHermesConfig(settings: HermesBuildSettings): Record 127.0.0.1:18642. - const platforms: Record = { - api_server: { - enabled: true, - extra: { - port: 18642, - host: "127.0.0.1", - }, - }, - }; +export function finalizeHermesPlatformToolsets( + config: Record, + settings: HermesBuildSettings, +): void { + addEnabledPlatformToolsets(config, buildHermesRemotePlatformToolsets(settings)); +} - if (settings.messaging.enabledChannels.has("slack")) { - platforms.slack = { enabled: true }; +function buildHermesRemotePlatformToolsets(settings: HermesBuildSettings): string[] { + const remotePlatformToolsets = [...REMOTE_PLATFORM_TOOLSETS]; + if ( + settings.managedToolGateways.brokerEnabled && + settings.managedToolGateways.presets.includes("nous-audio") && + !remotePlatformToolsets.includes("tts") + ) { + remotePlatformToolsets.push("tts"); } + return remotePlatformToolsets; +} - config.platforms = platforms; +function addEnabledPlatformToolsets( + config: Record, + remotePlatformToolsets: readonly string[], +): void { const platformToolsets = config.platform_toolsets as Record; - for (const channel of settings.messaging.enabledChannels) { - const platform = MESSAGING_PLATFORM_BY_CHANNEL[channel]; - if (platform) { - platformToolsets[platform] = [...remotePlatformToolsets]; - } + const platforms = config.platforms as Record; + for (const [platform, platformConfig] of Object.entries(platforms)) { + if (platform === "api_server" || !isEnabledPlatform(platformConfig)) continue; + platformToolsets[platform] = [...remotePlatformToolsets]; } +} - return config; +function isEnabledPlatform(value: unknown): boolean { + return isObject(value) && value.enabled === true; +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); } diff --git a/agents/hermes/config/hermes-env.ts b/agents/hermes/config/hermes-env.ts new file mode 100644 index 0000000000..973ffd1a3f --- /dev/null +++ b/agents/hermes/config/hermes-env.ts @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { HermesBuildSettings } from "./build-env.ts"; +import { loadManagedToolGatewayMatrix } from "./managed-tool-gateway.ts"; + +export function buildHermesEnvLines(settings: HermesBuildSettings): string[] { + const envLines = ["API_SERVER_PORT=18642", "API_SERVER_HOST=127.0.0.1"]; + + if (!settings.managedToolGateways.brokerEnabled) return envLines; + + const matrix = loadManagedToolGatewayMatrix(); + envLines.push("NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER=1"); + for (const preset of settings.managedToolGateways.presets) { + const entry = matrix[preset]; + if (!entry) { + throw new Error(`Unknown Hermes managed-tool gateway preset: ${preset}`); + } + envLines.push(`${entry.envKey}=${entry.envValue}`); + } + + return envLines; +} diff --git a/agents/hermes/config/manifest-hooks.ts b/agents/hermes/config/manifest-hooks.ts new file mode 100644 index 0000000000..294b3fbaf1 --- /dev/null +++ b/agents/hermes/config/manifest-hooks.ts @@ -0,0 +1,202 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Buffer } from "node:buffer"; + +type JsonObject = Record; + +type ManifestHookRenderResult = { + readonly appliedHooks: readonly string[]; + readonly appliedTargets: readonly string[]; + readonly unresolvedTemplateRefs: readonly string[]; +}; + +type MessagingRenderEntry = { + readonly channelId: string; + readonly agent: string; + readonly target: string; + readonly kind: "json-fragment" | "env-lines"; + readonly renderId?: string; + readonly hookId?: string; + readonly handler?: string; + readonly path?: string; + readonly value?: unknown; + readonly lines?: readonly string[]; + readonly templateRefs?: readonly string[]; +}; + +type HermesManifestHookPlan = { + readonly schemaVersion: 1; + readonly agent: "hermes"; + readonly channels: readonly { + readonly channelId: string; + readonly active?: boolean; + readonly disabled?: boolean; + }[]; + readonly agentRender: readonly MessagingRenderEntry[]; +}; + +const HERMES_CONFIG_TARGET = "~/.hermes/config.yaml"; +const HERMES_ENV_TARGET = "~/.hermes/.env"; + +export function readHermesManifestHookPlan( + env: NodeJS.ProcessEnv, +): HermesManifestHookPlan | null { + const encoded = env.NEMOCLAW_MESSAGING_PLAN_B64; + if (!encoded || encoded.trim() === "") return null; + + const parsed = JSON.parse(Buffer.from(encoded, "base64").toString("utf-8")) as unknown; + if ( + !isObject(parsed) || + parsed.schemaVersion !== 1 || + parsed.agent !== "hermes" || + !Array.isArray(parsed.channels) || + !Array.isArray(parsed.agentRender) + ) { + throw new Error("NEMOCLAW_MESSAGING_PLAN_B64 must contain a hermes messaging plan"); + } + + return parsed as HermesManifestHookPlan; +} + +export function applyHermesManifestHookRender( + config: JsonObject, + envLines: string[], + plan: HermesManifestHookPlan | null, +): ManifestHookRenderResult { + if (!plan) { + return { appliedHooks: [], appliedTargets: [], unresolvedTemplateRefs: [] }; + } + + const activeChannels = new Set( + plan.channels + .filter((channel) => channel.active === true && channel.disabled !== true) + .map((channel) => channel.channelId), + ); + const appliedHooks: string[] = []; + const appliedTargets: string[] = []; + const unresolvedTemplateRefs: string[] = []; + + for (const render of plan.agentRender) { + if (render.agent !== "hermes" || !activeChannels.has(render.channelId)) continue; + unresolvedTemplateRefs.push(...(render.templateRefs ?? [])); + if (render.kind === "json-fragment") { + applyJsonRender(config, render); + appliedTargets.push(render.target); + if (render.hookId) appliedHooks.push(`${render.channelId}:${render.hookId}`); + continue; + } + applyEnvRender(envLines, render); + appliedTargets.push(render.target); + if (render.hookId) appliedHooks.push(`${render.channelId}:${render.hookId}`); + } + + return { + appliedHooks: uniqueStrings(appliedHooks), + appliedTargets: uniqueStrings(appliedTargets), + unresolvedTemplateRefs: uniqueStrings(unresolvedTemplateRefs), + }; +} + +function applyJsonRender(config: JsonObject, render: MessagingRenderEntry): void { + if (render.target !== HERMES_CONFIG_TARGET) { + throw new Error(`Hermes manifest hook render target is not supported: ${render.target}`); + } + if (typeof render.path !== "string") { + throw new Error( + `Hermes manifest hook render '${render.renderId ?? render.channelId}' is missing a path.`, + ); + } + setJsonPath(config, render.path, render.value); +} + +function applyEnvRender(envLines: string[], render: MessagingRenderEntry): void { + if (render.target !== HERMES_ENV_TARGET) { + throw new Error(`Hermes manifest hook render target is not supported: ${render.target}`); + } + if (!Array.isArray(render.lines)) { + throw new Error( + `Hermes manifest hook render '${render.renderId ?? render.channelId}' is missing env lines.`, + ); + } + mergeEnvLines(envLines, render.lines); +} + +function setJsonPath(root: JsonObject, path: string, value: unknown): void { + const segments = path.split(".").filter(Boolean); + if (segments.length === 0) throw new Error("Hermes manifest hook render path must not be empty."); + let cursor = root; + for (const segment of segments.slice(0, -1)) { + assertSafeObjectKey(segment); + if (!isObject(cursor[segment])) cursor[segment] = {}; + cursor = cursor[segment] as JsonObject; + } + const finalSegment = segments[segments.length - 1] as string; + assertSafeObjectKey(finalSegment); + if (isObject(cursor[finalSegment]) && isObject(value)) { + mergeObjects(cursor[finalSegment] as JsonObject, value as JsonObject); + return; + } + cursor[finalSegment] = value; +} + +function mergeObjects(target: JsonObject, patch: JsonObject): void { + for (const [key, value] of Object.entries(patch)) { + assertSafeObjectKey(key); + const existing = target[key]; + if (isObject(existing) && isObject(value)) { + mergeObjects(existing as JsonObject, value as JsonObject); + } else if (Array.isArray(existing) && Array.isArray(value)) { + target[key] = [...new Set([...existing, ...value])]; + } else { + target[key] = value; + } + } +} + +function mergeEnvLines(existingLines: string[], desiredLines: readonly string[]): void { + const desired = new Map(); + const rawDesiredLines: string[] = []; + for (const line of desiredLines) { + const key = readEnvLineKey(line); + if (key) { + desired.set(key, line); + } else { + rawDesiredLines.push(line); + } + } + + const written = new Set(); + for (const [index, line] of existingLines.entries()) { + const key = readEnvLineKey(line); + if (!key || !desired.has(key)) continue; + existingLines[index] = desired.get(key) as string; + written.add(key); + } + + for (const [key, line] of desired) { + if (!written.has(key)) existingLines.push(line); + } + existingLines.push(...rawDesiredLines); +} + +function readEnvLineKey(line: string): string | null { + const index = line.indexOf("="); + if (index <= 0) return null; + const key = line.slice(0, index).trim(); + return key.length > 0 ? key : null; +} + +function assertSafeObjectKey(key: string): void { + if (key === "__proto__" || key === "prototype" || key === "constructor") { + throw new Error(`Hermes manifest hook render rejected unsafe object key '${key}'.`); + } +} + +function isObject(value: unknown): value is JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function uniqueStrings(values: readonly string[]): string[] { + return [...new Set(values)]; +} diff --git a/agents/hermes/config/messaging-config.ts b/agents/hermes/config/messaging-config.ts deleted file mode 100644 index 6871dfe5d0..0000000000 --- a/agents/hermes/config/messaging-config.ts +++ /dev/null @@ -1,195 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import type { DiscordGuilds, MessagingAllowedIds, SlackConfig, WechatConfig } from "./build-env.ts"; -import { loadManagedToolGatewayMatrix } from "./managed-tool-gateway.ts"; - -// Maps each Hermes-supported channel to the in-sandbox env-var name(s) the -// adapter reads. The values are the names Hermes expects — not the names -// NemoClaw's host-side capture uses. For WeChat, Hermes' upstream docs -// (https://hermes-agent.nousresearch.com/docs/user-guide/messaging/weixin) -// require WEIXIN_TOKEN, while NemoClaw's OpenShell credential store keys the -// secret under WECHAT_BOT_TOKEN (shared with OpenClaw's bridge). The -// placeholder pattern in buildTokenPlaceholder rewrites at L7 egress, so -// Hermes can read WEIXIN_TOKEN without the host secret rename. -const CHANNEL_TOKEN_ENVS: Record = { - telegram: ["TELEGRAM_BOT_TOKEN"], - discord: ["DISCORD_BOT_TOKEN"], - slack: ["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"], - wechat: ["WEIXIN_TOKEN"], -}; - -export function buildMessagingEnvLines( - enabledChannels: Set, - allowedIds: MessagingAllowedIds, - discordGuilds: DiscordGuilds, - wechatConfig: WechatConfig, - slackConfig: SlackConfig, - managedToolGatewayPresets: string[] = [], -): string[] { - const envLines = ["API_SERVER_PORT=18642", "API_SERVER_HOST=127.0.0.1"]; - - if (managedToolGatewayPresets.length > 0) { - const matrix = loadManagedToolGatewayMatrix(); - envLines.push("NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER=1"); - for (const preset of managedToolGatewayPresets) { - const entry = matrix[preset]; - if (!entry) { - throw new Error(`Unknown Hermes managed-tool gateway preset: ${preset}`); - } - envLines.push(`${entry.envKey}=${entry.envValue}`); - } - } - - for (const channel of enabledChannels) { - const envKeys = CHANNEL_TOKEN_ENVS[channel] ?? []; - for (const envKey of envKeys) { - envLines.push(`${envKey}=${buildTokenPlaceholder(channel, envKey)}`); - } - if (channel === "discord") { - const guildIds = Object.keys(discordGuilds).filter(Boolean); - if (guildIds.length > 0) { - envLines.push(`NEMOCLAW_DISCORD_GUILD_IDS=${guildIds.join(",")}`); - } - } - if (channel === "wechat") { - envLines.push(...buildWechatEnvLines(allowedIds, wechatConfig)); - } - if (channel === "whatsapp") { - envLines.push(...buildWhatsappEnvLines(allowedIds)); - } - } - - const discordAllowedUsers = collectDiscordAllowedUsers(allowedIds, discordGuilds); - if (discordAllowedUsers.length > 0) { - envLines.push(`DISCORD_ALLOWED_USERS=${discordAllowedUsers.join(",")}`); - } else if ( - enabledChannels.has("discord") && - Object.keys(discordGuilds).filter((guildId) => guildId.trim()).length > 0 - ) { - envLines.push("DISCORD_ALLOW_ALL_USERS=true"); - } - if (allowedIds.telegram?.length) { - envLines.push(`TELEGRAM_ALLOWED_USERS=${allowedIds.telegram.map(String).join(",")}`); - } - if (allowedIds.slack?.length) { - envLines.push(`SLACK_ALLOWED_USERS=${allowedIds.slack.map(String).join(",")}`); - } - const slackAllowedChannels = collectSlackAllowedChannels(slackConfig); - if (enabledChannels.has("slack") && slackAllowedChannels.length > 0) { - envLines.push(`SLACK_ALLOWED_CHANNELS=${slackAllowedChannels.join(",")}`); - } - - return envLines; -} - -function buildTokenPlaceholder(channel: string, envKey: string): string { - if (channel === "slack" && envKey === "SLACK_BOT_TOKEN") { - return "xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN"; - } - if (channel === "slack" && envKey === "SLACK_APP_TOKEN") { - return "xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN"; - } - // Hermes' WeChat adapter reads WEIXIN_TOKEN, but the OpenShell L7 proxy - // keys the credential by WECHAT_BOT_TOKEN (same slot OpenClaw uses), so - // the placeholder must reference the host-side credential name. - if (channel === "wechat" && envKey === "WEIXIN_TOKEN") { - return "openshell:resolve:env:WECHAT_BOT_TOKEN"; - } - return `openshell:resolve:env:${envKey}`; -} - -// Hermes WeChat adapter env contract per -// https://hermes-agent.nousresearch.com/docs/user-guide/messaging/weixin — -// WEIXIN_ACCOUNT_ID + WEIXIN_TOKEN are required; the remaining fields are -// optional and only emitted when set. Defaults match the upstream docs -// (WEIXIN_DM_POLICY=open, WEIXIN_GROUP_POLICY=disabled) so we leave them -// off when the operator hasn't customized them — Hermes applies the same -// defaults internally. -function buildWechatEnvLines( - allowedIds: MessagingAllowedIds, - wechatConfig: WechatConfig, -): string[] { - const lines: string[] = []; - const accountId = - typeof wechatConfig.accountId === "string" ? wechatConfig.accountId.trim() : ""; - if (!accountId) { - throw new Error("wechat is enabled but wechatConfig.accountId is missing"); - } - lines.push(`WEIXIN_ACCOUNT_ID=${accountId}`); - if (wechatConfig.baseUrl) { - lines.push(`WEIXIN_BASE_URL=${wechatConfig.baseUrl}`); - } - const wechatAllowed = (allowedIds.wechat ?? []).map(String).filter(Boolean); - // The operator's own WeChat user id (captured at QR login) is added to - // the allowlist so the bot can DM back the user who paired it without an - // extra prompt. The host-side handler already pushes this into - // allowedIds.wechat via defaultUserId, but include wechatConfig.userId - // defensively in case the channel was added pre-allowlist. - if (wechatConfig.userId && !wechatAllowed.includes(wechatConfig.userId)) { - wechatAllowed.unshift(wechatConfig.userId); - } - if (wechatAllowed.length > 0) { - lines.push(`WEIXIN_ALLOWED_USERS=${wechatAllowed.join(",")}`); - } - return lines; -} - -// Hermes' WhatsApp bridge is tokenless from NemoClaw's point of view: the -// operator pairs it inside the sandbox with `hermes whatsapp`, accepting -// Hermes-owned mutable session state under ~/.hermes/platforms/whatsapp/session. -// The gateway still needs the env feature flag baked into .env so the platform -// starts after rebuild. -function buildWhatsappEnvLines(allowedIds: MessagingAllowedIds): string[] { - const lines = ["WHATSAPP_ENABLED=true", "WHATSAPP_MODE=bot"]; - const allowedUsers = (allowedIds.whatsapp ?? []).map(String).filter(Boolean); - if (allowedUsers.length > 0) { - lines.push(`WHATSAPP_ALLOWED_USERS=${allowedUsers.join(",")}`); - } - return lines; -} - -export function buildDiscordConfig(discordGuilds: DiscordGuilds): Record { - return { - require_mention: getDiscordRequireMention(discordGuilds), - free_response_channels: "", - allowed_channels: "", - auto_thread: true, - reactions: true, - channel_prompts: {}, - }; -} - -function getDiscordRequireMention(discordGuilds: DiscordGuilds): boolean { - for (const guildConfig of Object.values(discordGuilds)) { - if (typeof guildConfig?.requireMention === "boolean") { - return guildConfig.requireMention; - } - } - return true; -} - -function collectDiscordAllowedUsers( - allowedIds: MessagingAllowedIds, - discordGuilds: DiscordGuilds, -): string[] { - const users = new Set(); - for (const user of allowedIds.discord ?? []) { - users.add(String(user)); - } - for (const guildConfig of Object.values(discordGuilds)) { - for (const user of guildConfig?.users ?? []) { - users.add(String(user)); - } - } - return [...users]; -} - -function collectSlackAllowedChannels(slackConfig: SlackConfig): string[] { - const channels = Array.isArray(slackConfig.allowedChannels) ? slackConfig.allowedChannels : []; - return [ - ...new Set( - channels.map((channel) => String(channel).replace(/[\r\n]/g, "").trim()).filter(Boolean), - ), - ]; -} diff --git a/agents/hermes/generate-config.ts b/agents/hermes/generate-config.ts index 35a9fe8d4e..e8577a1d70 100644 --- a/agents/hermes/generate-config.ts +++ b/agents/hermes/generate-config.ts @@ -14,13 +14,18 @@ // - Agent defaults (terminal, memory, skills, display) import { readHermesBuildSettings } from "./config/build-env.ts"; -import { buildHermesConfig } from "./config/hermes-config.ts"; -import { buildMessagingEnvLines } from "./config/messaging-config.ts"; +import { + applyHermesManifestHookRender, + readHermesManifestHookPlan, +} from "./config/manifest-hooks.ts"; +import { buildHermesEnvLines } from "./config/hermes-env.ts"; +import { buildHermesConfig, finalizeHermesPlatformToolsets } from "./config/hermes-config.ts"; import { discoverModelSpecificSetups } from "./config/model-specific-setup.ts"; import { writeHermesConfigFiles } from "./config/write-config.ts"; function main(): void { const settings = readHermesBuildSettings(process.env); + const messagingPlan = readHermesManifestHookPlan(process.env); discoverModelSpecificSetups( "hermes", @@ -37,16 +42,9 @@ function main(): void { ); const config = buildHermesConfig(settings); - const envLines = buildMessagingEnvLines( - settings.messaging.enabledChannels, - settings.messaging.allowedIds, - settings.messaging.discordGuilds, - settings.messaging.wechatConfig, - settings.messaging.slackConfig, - settings.managedToolGateways.brokerEnabled - ? settings.managedToolGateways.presets - : [], - ); + const envLines = buildHermesEnvLines(settings); + applyHermesManifestHookRender(config, envLines, messagingPlan); + finalizeHermesPlatformToolsets(config, settings); const written = writeHermesConfigFiles(config, envLines); console.log(`[config] Wrote ${written.configPath} (model=${settings.model}, provider=custom)`); diff --git a/agents/hermes/policy-additions.yaml b/agents/hermes/policy-additions.yaml index cfef5bf5da..ac6ae74839 100644 --- a/agents/hermes/policy-additions.yaml +++ b/agents/hermes/policy-additions.yaml @@ -276,9 +276,9 @@ network_policies: # WeChat (personal) via Tencent's iLink Bot API. The Hermes adapter uses # HTTP long-polling (no WebSocket). WEIXIN_TOKEN is L7-resolved at egress - # from WECHAT_BOT_TOKEN (same credential slot OpenClaw's bridge uses) — see - # agents/hermes/config/messaging-config.ts and - # nemoclaw-blueprint/policies/presets/wechat.yaml for the shared host set. + # from WECHAT_BOT_TOKEN (same credential slot OpenClaw's bridge uses) via + # manifest hook render outputs. See nemoclaw-blueprint/policies/presets/wechat.yaml + # for the shared host set. wechat_bridge: name: wechat_bridge endpoints: diff --git a/scripts/generate-openclaw-config.mts b/scripts/generate-openclaw-config.mts index 167cd7992d..8525893854 100755 --- a/scripts/generate-openclaw-config.mts +++ b/scripts/generate-openclaw-config.mts @@ -14,10 +14,8 @@ // NEMOCLAW_INFERENCE_INPUTS, NEMOCLAW_CONTEXT_WINDOW, // NEMOCLAW_MAX_TOKENS, NEMOCLAW_REASONING, // NEMOCLAW_AGENT_TIMEOUT, NEMOCLAW_AGENT_HEARTBEAT_EVERY, -// NEMOCLAW_INFERENCE_COMPAT_B64, NEMOCLAW_MESSAGING_CHANNELS_B64, -// NEMOCLAW_MESSAGING_ALLOWED_IDS_B64, NEMOCLAW_DISCORD_GUILDS_B64, -// NEMOCLAW_TELEGRAM_CONFIG_B64, NEMOCLAW_WECHAT_CONFIG_B64, -// NEMOCLAW_SLACK_CONFIG_B64, NEMOCLAW_DISABLE_DEVICE_AUTH, +// NEMOCLAW_INFERENCE_COMPAT_B64, NEMOCLAW_MESSAGING_PLAN_B64, +// NEMOCLAW_DISABLE_DEVICE_AUTH, // NEMOCLAW_EXTRA_AGENTS_JSON_B64, // NEMOCLAW_PROXY_HOST, NEMOCLAW_PROXY_PORT, // NEMOCLAW_OPENCLAW_MANAGED_PROXY, NEMOCLAW_WEB_SEARCH_ENABLED, @@ -35,11 +33,227 @@ import { } from "node:fs"; import { dirname, isAbsolute, join, resolve, sep } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { spawnSync } from "node:child_process"; type Env = Record; type JsonObject = Record; +type MessagingPlan = { + readonly schemaVersion: 1; + readonly agent: string; + readonly channels: readonly MessagingPlanChannel[]; + readonly agentRender: readonly MessagingRenderEntry[]; + readonly buildSteps: readonly MessagingBuildStep[]; +}; + +type MessagingPlanChannel = { + readonly channelId: string; + readonly active?: boolean; + readonly disabled?: boolean; +}; + +type MessagingRenderEntry = { + readonly channelId: string; + readonly agent: string; + readonly target: string; + readonly kind: "json-fragment" | "env-lines"; + readonly path?: string; + readonly value?: unknown; +}; + +type MessagingBuildStep = { + readonly channelId: string; + readonly kind: "build-arg" | "build-file" | "package-install"; + readonly outputId: string; + readonly required?: boolean; + readonly value?: unknown; +}; + +function readMessagingPlanFromEnv(env: Env, agent: string): MessagingPlan | null { + const encoded = env.NEMOCLAW_MESSAGING_PLAN_B64; + if (!encoded || encoded.trim() === "") return null; + let parsed: unknown; + try { + parsed = JSON.parse(Buffer.from(encoded, "base64").toString("utf-8")); + } catch (error) { + throw new Error( + `NEMOCLAW_MESSAGING_PLAN_B64 must be a base64-encoded messaging plan: ${error instanceof Error ? error.message : String(error)}`, + ); + } + if ( + !isObject(parsed) || + parsed.schemaVersion !== 1 || + parsed.agent !== agent || + !Array.isArray(parsed.channels) || + !Array.isArray(parsed.agentRender) || + !Array.isArray(parsed.buildSteps) + ) { + throw new Error(`NEMOCLAW_MESSAGING_PLAN_B64 must contain a ${agent} messaging plan`); + } + return parsed as MessagingPlan; +} + +function activeMessagingPlanChannels(plan: MessagingPlan | null): string[] { + if (!plan) return []; + return plan.channels + .filter((channel) => channel.active === true && channel.disabled !== true) + .map((channel) => channel.channelId); +} + +function isPlanChannelActive(plan: MessagingPlan, channelId: string): boolean { + return activeMessagingPlanChannels(plan).includes(channelId); +} + +function applyMessagingAgentRender( + config: JsonObject, + plan: MessagingPlan | null, + target: string, +): void { + if (!plan) return; + for (const render of plan.agentRender) { + if ( + render.kind !== "json-fragment" || + render.target !== target || + typeof render.path !== "string" || + !isPlanChannelActive(plan, render.channelId) + ) { + continue; + } + setMessagingJsonPath(config, render.path, toMessagingJsonValue(render.value)); + } +} + +function applyMessagingBuildFiles(config: JsonObject, plan: MessagingPlan | null): void { + if (!plan) return; + for (const step of plan.buildSteps) { + if (step.kind !== "build-file" || !isPlanChannelActive(plan, step.channelId)) continue; + if (step.value === undefined) { + if (step.required) throw new Error(`Messaging build-file output ${step.outputId} is missing`); + continue; + } + applyMessagingBuildFile(config, toMessagingBuildFile(step.value)); + } +} + +function applyMessagingBuildFile( + config: JsonObject, + file: { readonly path: string; readonly mode?: string; readonly content?: unknown; readonly merge?: unknown }, +): void { + const relativePath = normalizeMessagingBuildFilePath(file.path); + if (relativePath === "openclaw.json") { + if (file.merge !== undefined) mergeJsonObjects(config, toMessagingObject(file.merge)); + if (file.content !== undefined) { + const replacement = toMessagingObject(file.content); + for (const key of Object.keys(config)) delete config[key]; + mergeJsonObjects(config, replacement); + } + return; + } + + const stateRoot = expandUser("~/.openclaw"); + const target = resolve(stateRoot, relativePath); + const normalizedRoot = resolve(stateRoot); + if (target !== normalizedRoot && !target.startsWith(`${normalizedRoot}${sep}`)) { + throw new Error(`Messaging build-file path ${file.path} must stay inside ~/.openclaw`); + } + mkdirSync(dirname(target), { recursive: true }); + const contents = serializeMessagingBuildFileContent(file.content); + writeFileSync(target, contents); + if (file.mode) chmodSync(target, parseMessagingFileMode(file.path, file.mode)); +} + +function setMessagingJsonPath(root: JsonObject, pathValue: string, value: unknown): void { + const segments = pathValue.split(".").filter(Boolean); + if (segments.length === 0) throw new Error("Messaging render path must not be empty"); + let cursor = root; + for (const segment of segments.slice(0, -1)) { + assertSafeMessagingObjectKey(segment, "Messaging render path"); + if (!isObject(cursor[segment])) cursor[segment] = {}; + cursor = cursor[segment] as JsonObject; + } + const finalSegment = segments[segments.length - 1] as string; + assertSafeMessagingObjectKey(finalSegment, "Messaging render path"); + if (isObject(cursor[finalSegment]) && isObject(value)) { + mergeJsonObjects(cursor[finalSegment] as JsonObject, value as JsonObject); + return; + } + cursor[finalSegment] = value; +} + +function mergeJsonObjects(target: JsonObject, patch: JsonObject): void { + for (const [key, value] of Object.entries(patch)) { + assertSafeMessagingObjectKey(key, "Messaging object merge"); + const existing = target[key]; + if (isObject(existing) && isObject(value)) { + mergeJsonObjects(existing as JsonObject, value as JsonObject); + } else if (Array.isArray(existing) && Array.isArray(value)) { + target[key] = unique([...existing, ...value]); + } else { + target[key] = value; + } + } +} + +function toMessagingJsonValue(value: unknown): unknown { + if (value === undefined) throw new Error("Messaging render value is missing"); + return value; +} + +function toMessagingObject(value: unknown): JsonObject { + if (!isObject(value)) throw new Error("Messaging build-file merge/content must be an object"); + return value; +} + +function toMessagingBuildFile(value: unknown): { + readonly path: string; + readonly mode?: string; + readonly content?: unknown; + readonly merge?: unknown; +} { + if (!isObject(value) || typeof value.path !== "string" || value.path.trim().length === 0) { + throw new Error("Messaging build-file output must include a path"); + } + return value as { + readonly path: string; + readonly mode?: string; + readonly content?: unknown; + readonly merge?: unknown; + }; +} + +function normalizeMessagingBuildFilePath(pathValue: string): string { + if (pathValue.startsWith("/") || pathValue.includes("\\") || /[\0-\x1F\x7F]/.test(pathValue)) { + throw new Error(`Messaging build-file path ${pathValue} must be a safe relative path`); + } + const segments = pathValue.split("/"); + if (segments.some((segment) => !segment || segment === "." || segment === "..")) { + throw new Error(`Messaging build-file path ${pathValue} must not traverse directories`); + } + return pathValue; +} + +function serializeMessagingBuildFileContent(value: unknown): string { + if (value === undefined) return ""; + if (typeof value === "string") return value.endsWith("\n") ? value : `${value}\n`; + return `${JSON.stringify(value, null, 2)}\n`; +} + +function parseMessagingFileMode(pathValue: string, mode: string): number { + if (!/^[0-7]{3,4}$/.test(mode) || (mode.length === 4 && mode[0] !== "0")) { + throw new Error(`Messaging build-file ${pathValue} mode must be an octal file mode`); + } + const parsed = Number.parseInt(mode, 8); + if ((parsed & 0o022) !== 0) { + throw new Error(`Messaging build-file ${pathValue} mode must not be group/world writable`); + } + return parsed; +} + +function assertSafeMessagingObjectKey(key: string, context: string): void { + if (key === "__proto__" || key === "prototype" || key === "constructor") { + throw new Error(`${context} rejected unsafe object key ${key}`); + } +} + const KNOWN_MODEL_SETUP_AGENTS = new Set(["openclaw", "hermes"]); const MODEL_SETUP_EFFECT_KEYS: Record> = { openclaw: new Set(["openclawCompat", "openclawPlugins", "openclawTools"]), @@ -833,112 +1047,7 @@ export function buildConfig(env: Env = process.env): JsonObject { inferenceCompat.supportsUsageInStreaming ??= true; } - const msgChannels = decodeJsonEnv(env, "NEMOCLAW_MESSAGING_CHANNELS_B64", "W10="); - const allowedIds = decodeJsonEnv(env, "NEMOCLAW_MESSAGING_ALLOWED_IDS_B64", "e30="); - const discordGuilds = decodeJsonEnv(env, "NEMOCLAW_DISCORD_GUILDS_B64", "e30="); - const telegramConfig = decodeJsonEnv(env, "NEMOCLAW_TELEGRAM_CONFIG_B64", "e30="); - const slackConfig = decodeJsonEnv(env, "NEMOCLAW_SLACK_CONFIG_B64", "e30="); - const rawSlackChannels = isObject(slackConfig) ? slackConfig.allowedChannels : []; - const slackAllowedChannels = Array.isArray(rawSlackChannels) - ? unique( - rawSlackChannels - .map((channel) => String(channel).replaceAll("\r", "").replaceAll("\n", "").trim()) - .filter(Boolean), - ) - : []; - - const tokenKeys: Record = { - discord: "token", - telegram: "botToken", - slack: "botToken", - }; - const envKeys: Record = { - discord: "DISCORD_BOT_TOKEN", - telegram: "TELEGRAM_BOT_TOKEN", - slack: "SLACK_BOT_TOKEN", - }; - - function placeholder(channel: string, envKey: string): string { - if (channel === "slack" && envKey === "SLACK_BOT_TOKEN") { - return `xoxb-OPENSHELL-RESOLVE-ENV-${envKey}`; - } - if (channel === "slack" && envKey === "SLACK_APP_TOKEN") { - return `xapp-OPENSHELL-RESOLVE-ENV-${envKey}`; - } - return `openshell:resolve:env:${envKey}`; - } - - const channelConfig: JsonObject = {}; - for (const channel of Array.isArray(msgChannels) ? msgChannels : []) { - const ch = String(channel); - if (ch === "whatsapp") { - channelConfig[ch] = { - enabled: true, - accounts: { - default: { enabled: true, healthMonitor: { enabled: false } }, - }, - }; - continue; - } - if (!(ch in tokenKeys)) { - continue; - } - const account: JsonObject = { - [tokenKeys[ch]]: placeholder(ch, envKeys[ch]), - enabled: true, - healthMonitor: { enabled: false }, - }; - if (ch === "slack") { - account.appToken = placeholder(ch, "SLACK_APP_TOKEN"); - } - if (ch === "telegram") { - account.proxy = proxyUrl; - account.groupPolicy = "open"; - } - if (isObject(allowedIds) && ch in allowedIds && allowedIds[ch]) { - account.dmPolicy = "allowlist"; - account.allowFrom = allowedIds[ch]; - if (ch === "slack") { - account.groupPolicy = "allowlist"; - account.channels = { - "*": { - enabled: true, - requireMention: true, - users: allowedIds[ch], - }, - }; - } - } - if (ch === "slack" && slackAllowedChannels.length > 0) { - account.groupPolicy = "allowlist"; - const slackChannelConfig: JsonObject = { - enabled: true, - requireMention: true, - }; - if (isObject(allowedIds) && ch in allowedIds && allowedIds[ch]) { - slackChannelConfig.users = allowedIds[ch]; - } - account.channels = Object.fromEntries( - slackAllowedChannels.map((channelId) => [channelId, { ...slackChannelConfig }]), - ); - } - channelConfig[ch] = { enabled: true, accounts: { default: account } }; - } - - if ( - "discord" in channelConfig && - isObject(discordGuilds) && - Object.keys(discordGuilds).length > 0 - ) { - Object.assign(channelConfig.discord, { - groupPolicy: "allowlist", - guilds: discordGuilds, - }); - } - - if ("telegram" in channelConfig && isObject(telegramConfig) && telegramConfig.requireMention) { - channelConfig.telegram.groups = { "*": { requireMention: true } }; - } + const messagingPlan = readMessagingPlanFromEnv(env, "openclaw"); const normalizedUrl = normalizeUrlForParse(chatUiUrl); const parsed = parseUrl(normalizedUrl); @@ -985,11 +1094,6 @@ export function buildConfig(env: Env = process.env): JsonObject { qqbot: { enabled: false }, "openclaw-weixin": { enabled: true }, }; - for (const ch of ["discord", "slack", "telegram", "whatsapp"]) { - if (ch in channelConfig) { - pluginEntries[ch] = { enabled: true }; - } - } const bundledProviderPlugins: Record> = { "amazon-bedrock": new Set(["amazon-bedrock", "bedrock"]), "amazon-bedrock-mantle": new Set(["amazon-bedrock-mantle"]), @@ -1036,7 +1140,7 @@ export function buildConfig(env: Env = process.env): JsonObject { const config: JsonObject = { agents: { defaults: agentDefaults, list: buildAgentsList(extraAgents) }, models: { mode: "merge", providers }, - channels: { defaults: {}, ...channelConfig }, + channels: { defaults: {} }, tools: openclawTools, update: { checkOnStart: false }, plugins, @@ -1079,6 +1183,8 @@ export function buildConfig(env: Env = process.env): JsonObject { }; } + applyMessagingAgentRender(config, messagingPlan, "openclaw.json"); + return config; } @@ -1108,124 +1214,15 @@ function preserveExistingPluginInstalls(config: JsonObject, configPath: string): Object.assign(currentPlugins.installs, existingInstalls); } -function hasPluginInstall(config: JsonObject, pluginId: string): boolean { - const plugins = config.plugins; - if (!isObject(plugins)) { - return false; - } - const installs = plugins.installs; - return isObject(installs) && pluginId in installs; -} - -function readJsonFile(pathValue: string): unknown { - return JSON.parse(readFileSync(pathValue, "utf-8")); -} - -function looksLikeWechatPluginMetadata(metadata: unknown, pathValue: string): boolean { - return ( - isObject(metadata) && - (metadata.id === "openclaw-weixin" || - metadata.name === "@tencent-weixin/openclaw-weixin" || - pathValue.toLowerCase().includes("openclaw-weixin")) - ); -} - -function hasInstalledWechatPluginMetadata(): boolean { - const stateDir = expandUser("~/.openclaw"); - const candidates = [ - join(stateDir, "extensions", "openclaw-weixin", "openclaw.plugin.json"), - join(stateDir, "extensions", "openclaw-weixin", "package.json"), - join( - stateDir, - "npm", - "node_modules", - "@tencent-weixin", - "openclaw-weixin", - "openclaw.plugin.json", - ), - join(stateDir, "npm", "node_modules", "@tencent-weixin", "openclaw-weixin", "package.json"), - ]; - for (const candidate of candidates) { - try { - if (looksLikeWechatPluginMetadata(readJsonFile(candidate), candidate)) { - return true; - } - } catch { - // Keep scanning; stale metadata should not break config generation. - } - } - - const extensionsDir = join(stateDir, "extensions"); - if (!existsSync(extensionsDir)) { - return false; - } - - const ignoredDirs = new Set(["node_modules", "plugin-runtime-deps", ".git"]); - const stack = [extensionsDir]; - while (stack.length > 0) { - const dir = stack.pop() as string; - for (const entry of readdirSync(dir, { withFileTypes: true })) { - if (entry.isDirectory()) { - if (!ignoredDirs.has(entry.name)) { - stack.push(join(dir, entry.name)); - } - continue; - } - if (!entry.isFile() || !["openclaw.plugin.json", "package.json"].includes(entry.name)) { - continue; - } - const pathValue = join(dir, entry.name); - try { - if (looksLikeWechatPluginMetadata(readJsonFile(pathValue), pathValue)) { - return true; - } - } catch { - // Keep scanning; corrupt package metadata is ignored like the Python path. - } - } - } - return false; -} - -function hasPreinstalledWechatPluginSignal(): boolean { - return ["1", "true", "yes", "on"].includes( - (process.env.NEMOCLAW_OPENCLAW_WECHAT_PLUGIN_PREINSTALLED || "").trim().toLowerCase(), - ); -} - -function seedWechatAccountsIfAvailable(config: JsonObject): void { - if ( - !hasPluginInstall(config, "openclaw-weixin") && - !hasInstalledWechatPluginMetadata() && - !hasPreinstalledWechatPluginSignal() - ) { - return; - } - - const seedScript = resolve(SCRIPT_DIR, "seed-wechat-accounts.py"); - const result = spawnSync("python3", [seedScript], { - stdio: "inherit", - env: process.env, - }); - if (result.error) { - throw result.error; - } - if (result.status !== null && result.status !== 0) { - process.exit(result.status); - } - if (result.signal) { - throw new Error(`${seedScript} terminated with signal ${result.signal}`); - } -} - export function main(): void { const config = buildConfig(); const configPath = expandUser("~/.openclaw/openclaw.json"); + const messagingPlan = readMessagingPlanFromEnv(process.env, "openclaw"); preserveExistingPluginInstalls(config, configPath); mkdirSync(dirname(configPath), { recursive: true }); + applyMessagingBuildFiles(config, messagingPlan); writeFileSync(configPath, JSON.stringify(config, null, 2)); chmodSync(configPath, 0o600); - seedWechatAccountsIfAvailable(config); } function isMainModule(): boolean { diff --git a/scripts/lib/sandbox-init.sh b/scripts/lib/sandbox-init.sh index 0188fbd7d9..c687116321 100755 --- a/scripts/lib/sandbox-init.sh +++ b/scripts/lib/sandbox-init.sh @@ -741,9 +741,9 @@ harden_config_symlinks() { # ── Messaging channels ────────────────────────────────────────── # Channel entries are baked into the config at image build time via -# NEMOCLAW_MESSAGING_CHANNELS_B64. Placeholder tokens flow through -# to the L7 proxy for rewriting at egress. Real tokens are never -# visible inside the sandbox. +# NEMOCLAW_MESSAGING_PLAN_B64 manifest render hooks. Placeholder tokens +# flow through to the L7 proxy for rewriting at egress. Real tokens are +# never visible inside the sandbox. # # This function just logs which channels are active. Runtime patching # of config files is not possible — Landlock enforces read-only at diff --git a/scripts/openclaw-build-messaging-plugins.py b/scripts/openclaw-build-messaging-plugins.py deleted file mode 100755 index 9b2b3f413a..0000000000 --- a/scripts/openclaw-build-messaging-plugins.py +++ /dev/null @@ -1,184 +0,0 @@ -#!/usr/bin/env python3 -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 -"""Install OpenClaw plugins that match the bundled OpenClaw version. - -OpenClaw's doctor repair uses the official catalog's unversioned plugin specs. -That can drift to a newer external messaging plugin than the host OpenClaw -runtime. NemoClaw pins the runtime with OPENCLAW_VERSION, so build-time channel -activation must force explicit npm installs for external messaging plugins and -pin them to that same version. -""" - -from __future__ import annotations - -import argparse -import base64 -import json -import os -import subprocess -import sys -from typing import Iterable - - -DEFAULT_CHANNELS_B64 = "W10=" - -EXTERNAL_CHANNEL_PACKAGES = { - "discord": "@openclaw/discord", - "slack": "@openclaw/slack", - "whatsapp": "@openclaw/whatsapp", -} -DIAGNOSTICS_OTEL_PACKAGE = "@openclaw/diagnostics-otel" - -DOCTOR_ENV_BY_CHANNEL = { - "telegram": { - "TELEGRAM_BOT_TOKEN": "openshell:resolve:env:TELEGRAM_BOT_TOKEN", - }, - "discord": { - "DISCORD_BOT_TOKEN": "openshell:resolve:env:DISCORD_BOT_TOKEN", - }, - "slack": { - "SLACK_BOT_TOKEN": "xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN", - "SLACK_APP_TOKEN": "xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN", - }, -} - - -class BuildMessagingPluginError(RuntimeError): - """Raised for configuration errors that should fail the image build.""" - - -def decode_channels(raw: str) -> list[str]: - try: - decoded = base64.b64decode(raw, validate=True) - parsed = json.loads(decoded.decode("utf-8")) - except Exception as exc: # noqa: BLE001 - keep the build error actionable. - raise BuildMessagingPluginError( - "NEMOCLAW_MESSAGING_CHANNELS_B64 must be base64-encoded JSON array" - ) from exc - - if not isinstance(parsed, list): - raise BuildMessagingPluginError( - "NEMOCLAW_MESSAGING_CHANNELS_B64 must decode to a JSON array" - ) - - channels: list[str] = [] - seen: set[str] = set() - for item in parsed: - if not isinstance(item, str): - raise BuildMessagingPluginError( - "NEMOCLAW_MESSAGING_CHANNELS_B64 may contain only string channel names" - ) - channel = item.strip().lower() - if not channel or channel in seen: - continue - seen.add(channel) - channels.append(channel) - return channels - - -def is_truthy_env(value: str | None) -> bool: - if value is None or value.strip() == "": - return False - return value.strip().lower() not in {"0", "false", "no", "off"} - - -def require_openclaw_version( - channels: Iterable[str], - env: dict[str, str], - *, - diagnostics_otel_enabled: bool, -) -> str: - needs_external_install = any(channel in EXTERNAL_CHANNEL_PACKAGES for channel in channels) - needs_external_install = needs_external_install or diagnostics_otel_enabled - version = (env.get("OPENCLAW_VERSION") or "").strip() - if needs_external_install and not version: - raise BuildMessagingPluginError( - "OPENCLAW_VERSION is required when external OpenClaw plugins are enabled" - ) - return version - - -def plugin_specs( - channels: Iterable[str], - openclaw_version: str, - *, - diagnostics_otel_enabled: bool, -) -> list[str]: - specs: list[str] = [] - for channel in channels: - package_name = EXTERNAL_CHANNEL_PACKAGES.get(channel) - if package_name: - specs.append(f"npm:{package_name}@{openclaw_version}") - if diagnostics_otel_enabled: - specs.append(f"npm:{DIAGNOSTICS_OTEL_PACKAGE}@{openclaw_version}") - return specs - - -def doctor_env_overrides(channels: Iterable[str]) -> dict[str, str]: - overrides: dict[str, str] = {} - for channel in channels: - overrides.update(DOCTOR_ENV_BY_CHANNEL.get(channel, {})) - return overrides - - -def run_command(args: list[str], *, env: dict[str, str] | None = None) -> None: - print("+ " + " ".join(args), flush=True) - subprocess.run(args, check=True, env=env) - - -def main(argv: list[str]) -> int: - parser = argparse.ArgumentParser() - parser.add_argument( - "--dry-run", - action="store_true", - help="Print the derived plugin specs and doctor env overrides as JSON.", - ) - args = parser.parse_args(argv) - - raw_channels = os.environ.get("NEMOCLAW_MESSAGING_CHANNELS_B64", DEFAULT_CHANNELS_B64) - channels = decode_channels(raw_channels or DEFAULT_CHANNELS_B64) - diagnostics_otel_enabled = is_truthy_env(os.environ.get("NEMOCLAW_OPENCLAW_OTEL")) - openclaw_version = require_openclaw_version( - channels, - os.environ, - diagnostics_otel_enabled=diagnostics_otel_enabled, - ) - specs = plugin_specs( - channels, - openclaw_version, - diagnostics_otel_enabled=diagnostics_otel_enabled, - ) - env_overrides = doctor_env_overrides(channels) - - if args.dry_run: - print( - json.dumps( - { - "channels": channels, - "diagnosticsOtelEnabled": diagnostics_otel_enabled, - "doctorEnv": env_overrides, - "installSpecs": specs, - "openclawVersion": openclaw_version, - }, - indent=2, - sort_keys=True, - ) - ) - return 0 - - for spec in specs: - run_command(["openclaw", "plugins", "install", spec, "--pin"]) - - doctor_env = os.environ.copy() - doctor_env.update(env_overrides) - run_command(["openclaw", "doctor", "--fix", "--non-interactive"], env=doctor_env) - return 0 - - -if __name__ == "__main__": - try: - raise SystemExit(main(sys.argv[1:])) - except BuildMessagingPluginError as exc: - print(f"ERROR: {exc}", file=sys.stderr) - raise SystemExit(2) diff --git a/scripts/run-openclaw-build-hooks.mts b/scripts/run-openclaw-build-hooks.mts new file mode 100755 index 0000000000..9e7714e684 --- /dev/null +++ b/scripts/run-openclaw-build-hooks.mts @@ -0,0 +1,249 @@ +#!/usr/bin/env -S node --experimental-strip-types +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; + +type Env = Record; +type JsonObject = Record; + +type MessagingPlan = { + readonly schemaVersion: 1; + readonly agent: string; + readonly channels: readonly MessagingPlanChannel[]; + readonly credentialBindings: readonly MessagingCredentialBinding[]; + readonly buildSteps: readonly MessagingBuildStep[]; +}; + +type MessagingPlanChannel = { + readonly channelId: string; + readonly active?: boolean; + readonly disabled?: boolean; +}; + +type MessagingCredentialBinding = { + readonly channelId: string; + readonly providerEnvKey?: unknown; + readonly placeholder?: unknown; +}; + +type MessagingBuildStep = { + readonly channelId: string; + readonly kind: string; + readonly outputId?: string; + readonly required?: boolean; + readonly value?: unknown; +}; + +type OpenClawPackageInstall = { + readonly manager: "openclaw-plugin"; + readonly spec: string; + readonly pin?: boolean; +}; + +const FALSE_VALUES = new Set(["0", "false", "no", "off"]); +const DIAGNOSTICS_OTEL_PACKAGE = "@openclaw/diagnostics-otel"; + +class OpenClawBuildHookError extends Error {} + +function readMessagingPlanFromEnv(env: Env): MessagingPlan | null { + const raw = env.NEMOCLAW_MESSAGING_PLAN_B64; + if (!raw || raw.trim() === "") return null; + + let parsed: unknown; + try { + parsed = JSON.parse(Buffer.from(raw, "base64").toString("utf-8")); + } catch (error) { + throw new OpenClawBuildHookError( + `NEMOCLAW_MESSAGING_PLAN_B64 must be base64-encoded JSON: ${formatError(error)}`, + ); + } + + if ( + !isObject(parsed) || + parsed.schemaVersion !== 1 || + parsed.agent !== "openclaw" || + !Array.isArray(parsed.channels) || + !Array.isArray(parsed.credentialBindings) || + !Array.isArray(parsed.buildSteps) + ) { + throw new OpenClawBuildHookError( + "NEMOCLAW_MESSAGING_PLAN_B64 must contain an openclaw messaging plan", + ); + } + return parsed as MessagingPlan; +} + +function activeChannels(plan: MessagingPlan | null): string[] { + if (!plan) return []; + const seen = new Set(); + const channels: string[] = []; + for (const item of plan.channels) { + if (!isObject(item)) continue; + const channel = String(item.channelId || "").trim().toLowerCase(); + if (!channel || seen.has(channel)) continue; + if (item.active === true && item.disabled !== true) { + seen.add(channel); + channels.push(channel); + } + } + return channels; +} + +function collectOpenClawInstallSpecs(plan: MessagingPlan | null, env: Env): string[] { + if (!plan) return []; + const active = new Set(activeChannels(plan)); + const specs: string[] = []; + for (const step of plan.buildSteps) { + if (step.kind !== "package-install" || !active.has(step.channelId)) continue; + if (step.value === undefined) { + if (step.required) { + throw new OpenClawBuildHookError( + `Messaging package-install output ${step.outputId || ""} is missing`, + ); + } + continue; + } + const install = readOpenClawPackageInstall(step.value, step.outputId || ""); + specs.push(resolveOpenClawPackageSpec(install.spec, env)); + } + return unique(specs); +} + +function readOpenClawPackageInstall(value: unknown, outputId: string): OpenClawPackageInstall { + if (!isObject(value)) { + throw new OpenClawBuildHookError( + `Messaging package-install output ${outputId} must be an object`, + ); + } + if (value.manager !== "openclaw-plugin") { + throw new OpenClawBuildHookError( + `Messaging package-install output ${outputId} must use manager 'openclaw-plugin'`, + ); + } + if (typeof value.spec !== "string" || value.spec.trim().length === 0) { + throw new OpenClawBuildHookError( + `Messaging package-install output ${outputId} must include a package spec`, + ); + } + if (value.pin !== undefined && typeof value.pin !== "boolean") { + throw new OpenClawBuildHookError( + `Messaging package-install output ${outputId} pin must be boolean`, + ); + } + return value as OpenClawPackageInstall; +} + +function resolveOpenClawPackageSpec(spec: string, env: Env): string { + const version = (env.OPENCLAW_VERSION || "").trim(); + const resolved = spec.replaceAll("{{openclaw.version}}", () => { + if (!version) { + throw new OpenClawBuildHookError( + "OPENCLAW_VERSION is required when OpenClaw package install hooks are active", + ); + } + return version; + }); + if (/\{\{\s*[^}]+\s*\}\}/.test(resolved)) { + throw new OpenClawBuildHookError(`Unresolved package-install template in ${spec}`); + } + return resolved; +} + +function diagnosticsOtelSpec(env: Env): string | null { + if (!isTruthyEnv(env.NEMOCLAW_OPENCLAW_OTEL)) return null; + const version = (env.OPENCLAW_VERSION || "").trim(); + if (!version) { + throw new OpenClawBuildHookError( + "OPENCLAW_VERSION is required when OpenClaw OTEL is enabled", + ); + } + return `npm:${DIAGNOSTICS_OTEL_PACKAGE}@${version}`; +} + +function doctorEnvOverrides(plan: MessagingPlan | null): Record { + if (!plan) return {}; + const active = new Set(activeChannels(plan)); + const overrides: Record = {}; + for (const binding of plan.credentialBindings) { + if (!active.has(binding.channelId)) continue; + if (typeof binding.providerEnvKey === "string" && typeof binding.placeholder === "string") { + overrides[binding.providerEnvKey] = binding.placeholder; + } + } + return overrides; +} + +function runCommand(args: readonly string[], env: NodeJS.ProcessEnv = process.env): void { + console.log(`+ ${args.join(" ")}`); + const result = spawnSync(args[0] as string, args.slice(1), { + env, + stdio: "inherit", + }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new OpenClawBuildHookError( + `${args[0]} exited with status ${String(result.status ?? "unknown")}`, + ); + } +} + +function isTruthyEnv(value: string | undefined): boolean { + if (value === undefined || value.trim() === "") return false; + return !FALSE_VALUES.has(value.trim().toLowerCase()); +} + +function isObject(value: unknown): value is JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function unique(values: readonly string[]): string[] { + return [...new Set(values)]; +} + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function main(argv: readonly string[]): void { + const dryRun = argv.includes("--dry-run"); + const plan = readMessagingPlanFromEnv(process.env); + const channels = activeChannels(plan); + const installSpecs = collectOpenClawInstallSpecs(plan, process.env); + const otelSpec = diagnosticsOtelSpec(process.env); + if (otelSpec) installSpecs.push(otelSpec); + const doctorEnv = doctorEnvOverrides(plan); + + if (dryRun) { + console.log( + JSON.stringify( + { + channels, + diagnosticsOtelEnabled: isTruthyEnv(process.env.NEMOCLAW_OPENCLAW_OTEL), + doctorEnv, + installSpecs: unique(installSpecs), + openclawVersion: process.env.OPENCLAW_VERSION || "", + }, + null, + 2, + ), + ); + return; + } + + for (const spec of unique(installSpecs)) { + runCommand(["openclaw", "plugins", "install", spec, "--pin"]); + } + + runCommand(["openclaw", "doctor", "--fix", "--non-interactive"], { + ...process.env, + ...doctorEnv, + }); +} + +try { + main(process.argv.slice(2)); +} catch (error) { + console.error(`ERROR: ${formatError(error)}`); + process.exit(2); +} diff --git a/scripts/seed-wechat-accounts.py b/scripts/seed-wechat-accounts.py deleted file mode 100755 index c3e44f213b..0000000000 --- a/scripts/seed-wechat-accounts.py +++ /dev/null @@ -1,407 +0,0 @@ -#!/usr/bin/env python3 -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 -# -# Seed @tencent-weixin/openclaw-weixin's local account store with the -# session metadata captured by NemoClaw's host-side QR login (see -# src/lib/wechat/login.ts). Runs once at sandbox image build time. -# -# Skips the upstream plugin's own `openclaw channels login` flow, which -# would otherwise drive an in-sandbox QR scan that has no terminal and no -# paired phone access. -# -# Files written (matching auth/accounts.ts in @tencent-weixin/openclaw-weixin@2.4.3): -# /openclaw-weixin/accounts.json — JSON array of accountIds -# /openclaw-weixin/accounts/.json — { token, savedAt, baseUrl, userId } -# /openclaw.json (plugins.load.paths + channels.openclaw-weixin) -# — registered plugin/channel + accounts..enabled -# -# The third file is the one OpenClaw consults at startup to know the channel -# is registered. Without channels.openclaw-weixin.accounts..enabled=true -# in openclaw.json, the plugin's auth/accounts.ts considers the account -# disabled and the bridge won't start, even if the per-account state files -# above exist. The patch also restores the openclaw-weixin plugin registry and -# load path because later OpenClaw config rewrites can drop them while leaving -# the pre-installed extension files in place. generate-openclaw-config.mts -# invokes this only after the base image's installed plugin metadata, install -# registry, or preinstalled-plugin signal proves OpenClaw knows the WeChat -# channel id. -# -# State dir resolution mirrors the upstream's resolveStateDir(): -# $OPENCLAW_STATE_DIR || $CLAWDBOT_STATE_DIR || ~/.openclaw -# -# Token field carries the canonical NemoClaw placeholder -# `openshell:resolve:env:WECHAT_BOT_TOKEN`. The OpenShell L7 proxy rewrites -# that string to the real bot token at egress, so the secret never lands -# on disk inside the image. -# -# Inputs (from environment, populated by the Dockerfile patcher): -# NEMOCLAW_WECHAT_CONFIG_B64 Base64-encoded JSON: {accountId, baseUrl, userId}. -# When accountId is empty (no host-side QR login -# captured), the script no-ops cleanly. -# NEMOCLAW_MESSAGING_CHANNELS_B64 Base64-encoded JSON array of active channel names. -# When "wechat" is absent (operator stopped the -# channel via `nemoclaw channels stop -# wechat`), we still write the per-account state -# files so a later `channels start wechat` can -# revive the bridge without a fresh QR scan — but -# we skip patching openclaw.json, so the bridge -# stays dormant until the channel is re-enabled. - -from __future__ import annotations - -import base64 -import datetime as _dt -import json -import os -import pathlib -import sys -from collections.abc import Iterable - - -WECHAT_TOKEN_PLACEHOLDER = "openshell:resolve:env:WECHAT_BOT_TOKEN" -WECHAT_PLUGIN_ID = "openclaw-weixin" -WECHAT_PLUGIN_PACKAGE = "@tencent-weixin/openclaw-weixin" -WECHAT_PLUGIN_SPEC = f"{WECHAT_PLUGIN_PACKAGE}@2.4.3" -LEGACY_WECHAT_CHANNEL_IDS = (WECHAT_PLUGIN_ID,) - - -def _wechat_enabled() -> bool: - """Decide whether wechat is in the active-channel whitelist for this build. - - NEMOCLAW_MESSAGING_CHANNELS_B64 carries the list of channels onboard - selected after applying the disable filter. When wechat is absent the - bridge must stay dormant on this image, so we skip the openclaw.json - patch even though the per-account state files still get written. - """ - raw = os.environ.get("NEMOCLAW_MESSAGING_CHANNELS_B64", "W10=") or "W10=" - try: - channels = json.loads(base64.b64decode(raw).decode("utf-8")) - except (ValueError, json.JSONDecodeError): - return False - return isinstance(channels, list) and "wechat" in channels - - -def _state_dir() -> pathlib.Path: - raw = ( - os.environ.get("OPENCLAW_STATE_DIR") - or os.environ.get("CLAWDBOT_STATE_DIR") - or os.path.join(os.path.expanduser("~"), ".openclaw") - ) - return pathlib.Path(raw.strip()).resolve() - - -def _legacy_wechat_extension_path() -> pathlib.Path: - return _state_dir() / "extensions" / WECHAT_PLUGIN_ID - - -def _wechat_npm_package_path() -> pathlib.Path: - return _state_dir() / "npm" / "node_modules" / "@tencent-weixin" / "openclaw-weixin" - - -def _wechat_plugin_install_path(install_record: object | None = None) -> str: - if isinstance(install_record, dict): - install_path = install_record.get("installPath") - if isinstance(install_path, str) and install_path.strip(): - return install_path.strip() - npm_path = _wechat_npm_package_path() - if npm_path.exists(): - return str(npm_path) - return str(_legacy_wechat_extension_path()) - - -def _decode_config() -> dict: - raw = os.environ.get("NEMOCLAW_WECHAT_CONFIG_B64", "e30=") or "e30=" - try: - decoded = base64.b64decode(raw).decode("utf-8") - parsed = json.loads(decoded) - except (ValueError, json.JSONDecodeError) as err: - print( - f"[seed-wechat-accounts] could not decode NEMOCLAW_WECHAT_CONFIG_B64: {err}", - file=sys.stderr, - ) - return {} - return parsed if isinstance(parsed, dict) else {} - - -def _atomic_write(path: pathlib.Path, payload: str, mode: int) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - tmp = path.with_suffix(path.suffix + ".tmp") - tmp.write_text(payload, encoding="utf-8") - os.chmod(tmp, mode) - os.replace(tmp, path) - - -def _js_iso_utc() -> str: - """ISO-8601 UTC with millisecond precision and trailing 'Z' — the format - JavaScript's Date.toISOString() emits, which is what the upstream plugin - writes to channelConfigUpdatedAt.""" - now = _dt.datetime.now(_dt.timezone.utc) - return f"{now.strftime('%Y-%m-%dT%H:%M:%S')}.{now.microsecond // 1000:03d}Z" - - -def _dedupe(values: Iterable[str]) -> list[str]: - seen: set[str] = set() - result: list[str] = [] - for value in values: - item = str(value or "").strip() - if not item or item in seen: - continue - seen.add(item) - result.append(item) - return result - - -def _read_json_file(path: pathlib.Path) -> dict: - try: - parsed = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - return {} - return parsed if isinstance(parsed, dict) else {} - - -def _metadata_files() -> Iterable[pathlib.Path]: - matches: list[pathlib.Path] = [] - package_dir = _wechat_npm_package_path() - for filename in ("openclaw.plugin.json", "package.json"): - candidate = package_dir / filename - if candidate.exists(): - matches.append(candidate) - - extensions_dir = _state_dir() / "extensions" - if not extensions_dir.exists(): - return matches - - for root, dirs, files in os.walk(extensions_dir): - dirs[:] = [ - item - for item in dirs - if item not in {"node_modules", "plugin-runtime-deps", ".git"} - ] - root_path = pathlib.Path(root) - for filename in files: - if filename in {"openclaw.plugin.json", "package.json"}: - matches.append(root_path / filename) - return matches - - -def _declared_channel_ids_from_metadata() -> list[str]: - ids: list[str] = [] - for path in _metadata_files(): - metadata = _read_json_file(path) - if not metadata: - continue - - path_hint = str(path).lower() - package_name = str(metadata.get("name") or "") - plugin_id = str(metadata.get("id") or "") - openclaw = metadata.get("openclaw") - is_wechat_plugin = ( - plugin_id == WECHAT_PLUGIN_ID - or package_name == WECHAT_PLUGIN_PACKAGE - or WECHAT_PLUGIN_ID in path_hint - ) - if not is_wechat_plugin: - continue - - channels = metadata.get("channels") - if isinstance(channels, list): - ids.extend(item for item in channels if isinstance(item, str)) - - channel_configs = metadata.get("channelConfigs") - if isinstance(channel_configs, dict): - ids.extend(str(key) for key in channel_configs.keys()) - - if isinstance(openclaw, dict): - channel = openclaw.get("channel") - if isinstance(channel, dict) and isinstance(channel.get("id"), str): - ids.append(channel["id"]) - elif isinstance(channel, str): - ids.append(channel) - - channels = openclaw.get("channels") - if isinstance(channels, list): - ids.extend(item for item in channels if isinstance(item, str)) - - channel_configs = openclaw.get("channelConfigs") - if isinstance(channel_configs, dict): - ids.extend(str(key) for key in channel_configs.keys()) - - return _dedupe(ids) - - -def _wechat_channel_ids() -> list[str]: - return _dedupe([*_declared_channel_ids_from_metadata(), *LEGACY_WECHAT_CHANNEL_IDS]) - - -def _patch_openclaw_config(account_id: str) -> None: - """Register enabled WeChat account blocks under the plugin channel ids - OpenClaw can load. The upstream plugin's auth/accounts.ts reads these blocks - to decide which accounts to start at boot.""" - cfg_path = _state_dir() / "openclaw.json" - if not cfg_path.exists(): - # generate-openclaw-config.mts runs before us and is responsible for - # producing openclaw.json. If it's missing, something else broke; bail - # without inventing a config. - print( - f"[seed-wechat-accounts] {cfg_path} not found; cannot register channel", - file=sys.stderr, - ) - return - - try: - cfg = json.loads(cfg_path.read_text(encoding="utf-8")) - except json.JSONDecodeError as err: - print( - f"[seed-wechat-accounts] could not parse {cfg_path}: {err}", - file=sys.stderr, - ) - return - if not isinstance(cfg, dict): - print( - f"[seed-wechat-accounts] {cfg_path} root is not a JSON object; cannot register channel", - file=sys.stderr, - ) - return - - plugins = cfg.setdefault("plugins", {}) - if not isinstance(plugins, dict): - plugins = {} - cfg["plugins"] = plugins - installs = plugins.setdefault("installs", {}) - if not isinstance(installs, dict): - installs = {} - plugins["installs"] = installs - wechat_install = installs.get(WECHAT_PLUGIN_ID) - if not isinstance(wechat_install, dict): - wechat_install = {} - wechat_install_path = _wechat_plugin_install_path(wechat_install) - if wechat_install.get("source") != "npm": - wechat_install["source"] = "npm" - if not isinstance(wechat_install.get("spec"), str) or not wechat_install["spec"].strip(): - wechat_install["spec"] = WECHAT_PLUGIN_SPEC - if ( - not isinstance(wechat_install.get("installPath"), str) - or not wechat_install["installPath"].strip() - ): - wechat_install["installPath"] = wechat_install_path - installs[WECHAT_PLUGIN_ID] = wechat_install - - load = plugins.setdefault("load", {}) - if not isinstance(load, dict): - load = {} - plugins["load"] = load - load_paths = load.get("paths") - normalized_paths = ( - [item.strip() for item in load_paths if isinstance(item, str) and item.strip()] - if isinstance(load_paths, list) - else [] - ) - if wechat_install_path not in normalized_paths: - normalized_paths.append(wechat_install_path) - load["paths"] = normalized_paths - - entries = plugins.setdefault("entries", {}) - if not isinstance(entries, dict): - entries = {} - plugins["entries"] = entries - wechat_entry = entries.setdefault(WECHAT_PLUGIN_ID, {}) - if not isinstance(wechat_entry, dict): - wechat_entry = {} - entries[WECHAT_PLUGIN_ID] = wechat_entry - wechat_entry["enabled"] = True - - channels = cfg.setdefault("channels", {}) - if not isinstance(channels, dict): - channels = {} - cfg["channels"] = channels - - channel_ids = _wechat_channel_ids() - for channel_id in channel_ids: - channel_cfg = channels.setdefault(channel_id, {}) - if not isinstance(channel_cfg, dict): - channel_cfg = {} - channels[channel_id] = channel_cfg - channel_cfg["channelConfigUpdatedAt"] = _js_iso_utc() - accounts = channel_cfg.setdefault("accounts", {}) - if not isinstance(accounts, dict): - accounts = {} - channel_cfg["accounts"] = accounts - accounts[account_id] = {"enabled": True} - - _atomic_write(cfg_path, json.dumps(cfg, indent=2) + "\n", 0o600) - print( - "[seed-wechat-accounts] registered " - f"{', '.join(f'channels.{channel_id}.accounts.{account_id}' for channel_id in channel_ids)} " - f"in {cfg_path}" - ) - - -def main() -> int: - config = _decode_config() - account_id = (config.get("accountId") or "").strip() - base_url = (config.get("baseUrl") or "").strip() - user_id = (config.get("userId") or "").strip() - - # accountId is non-secret but mandatory: without it we can't pick a - # filename, and the upstream plugin won't see any registered accounts. - # Empty accountId is the expected state when the operator did not go - # through a host-side QR login (e.g. wechat channel never picked) — - # no-op silently instead of warning, since this script now runs on - # every build from generate-openclaw-config.mts. - if not account_id: - return 0 - - plugin_dir = _state_dir() / "openclaw-weixin" - accounts_index = plugin_dir / "accounts.json" - account_file = plugin_dir / "accounts" / f"{account_id}.json" - - # Per-account credential file. Schema mirrors WeixinAccountData; ordering - # mirrors saveWeixinAccount() so a future upstream save merges cleanly. - account_payload: dict[str, str] = { - "token": WECHAT_TOKEN_PLACEHOLDER, - "savedAt": _dt.datetime.now(_dt.timezone.utc).isoformat(), - } - if base_url: - account_payload["baseUrl"] = base_url - if user_id: - account_payload["userId"] = user_id - - _atomic_write(account_file, json.dumps(account_payload, indent=2) + "\n", 0o600) - - # Account index. Append-only semantics: if the upstream plugin or a prior - # seed step already registered other accountIds, preserve them. - existing: list[str] = [] - if accounts_index.exists(): - try: - raw = json.loads(accounts_index.read_text(encoding="utf-8")) - if isinstance(raw, list): - existing = [item for item in raw if isinstance(item, str) and item.strip()] - except json.JSONDecodeError: - existing = [] - - if account_id not in existing: - existing.append(account_id) - _atomic_write(accounts_index, json.dumps(existing, indent=2) + "\n", 0o600) - - print( - f"[seed-wechat-accounts] seeded {account_file} and registered {account_id} in {accounts_index}" - ) - - # Only register the channel in openclaw.json when wechat is enabled for - # this build. When the operator stopped the channel before rebuild, - # NEMOCLAW_MESSAGING_CHANNELS_B64 omits "wechat" and we leave the patch - # off — the account state files above are still on disk and ready for a - # later `channels start wechat` rebuild to activate. - if _wechat_enabled(): - _patch_openclaw_config(account_id) - else: - print( - "[seed-wechat-accounts] wechat not in active channels; preserving account " - "state files but skipping openclaw.json channel registration." - ) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/src/ext/wechat/qr.ts b/src/ext/wechat/qr.ts index ce80732642..996bd54ba5 100644 --- a/src/ext/wechat/qr.ts +++ b/src/ext/wechat/qr.ts @@ -10,10 +10,10 @@ // token and per-account metadata up front, store the secret in OpenShell // as a provider credential, and never persist it inside the sandbox image // or its state directory. The captured session is then seeded into the -// upstream plugin's on-disk account store at image build time (see -// scripts/seed-wechat-accounts.py), so the upstream plugin starts -// already-logged-in and never tries to drive its own QR login inside the -// sandbox. +// upstream plugin's on-disk account store at image build time via the +// wechat.seedOpenClawAccount post-agent-install hook, so the upstream +// plugin starts already logged in and never tries to drive its own QR +// login inside the sandbox. // // Endpoints (Tencent iLink CGI, observed against the public gateway): // GET https://ilinkai.weixin.qq.com/ilink/bot/get_bot_qrcode?bot_type=3 diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index 3cfbeec6b7..4abacf851b 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -648,8 +648,8 @@ async function promptAndRebuild(sandboxName: string, actionDesc: string): Promis // and emit `[] [default]` startup breadcrumbs in /tmp/gateway.log. // WhatsApp is QR-only (no host-side bridge process at this point), and WeChat // is recorded under the `openclaw-weixin` channel id with its own per-account -// metadata flow seeded by seed-wechat-accounts.py — neither match the probe -// shape and would produce false-negative warnings here. +// metadata flow seeded by the manifest post-agent-install hook — neither match +// the probe shape and would produce false-negative warnings here. const OPENCLAW_BRIDGE_VERIFIABLE_CHANNELS = new Set(["telegram", "discord", "slack"]); // Probe OpenClaw runtime state for a freshly added messaging channel. Runs diff --git a/src/lib/actions/sandbox/rebuild.ts b/src/lib/actions/sandbox/rebuild.ts index 63f06a0219..3c35cd407a 100644 --- a/src/lib/actions/sandbox/rebuild.ts +++ b/src/lib/actions/sandbox/rebuild.ts @@ -43,13 +43,18 @@ import * as agentRuntime from "../../agent/runtime"; import { RD as _RD, B, D, G, R, YW } from "../../cli/terminal-style"; import { getSandboxDeleteOutcome } from "../../domain/sandbox/destroy"; import * as nim from "../../inference/nim"; +import type { + MessagingHookApplyRequest, + MessagingHookOutputMap, + MessagingOpenShellRunner, + SandboxMessagingPlan, +} from "../../messaging"; import { createBuiltInChannelManifestRegistry, MessagingSetupApplier, MessagingWorkflowPlanner, toMessagingAgentId, } from "../../messaging"; -import type { SandboxMessagingPlan } from "../../messaging/manifest"; import { pruneDisabledMessagingPolicyPresets } from "../../onboard/messaging-policy-presets"; import { captureSandboxListWithGatewayRecovery, @@ -200,6 +205,64 @@ async function stageMessagingManifestPlanForRebuild( return plan; } +const runMessagingOpenshell: MessagingOpenShellRunner = (args, options = {}) => + runOpenshell([...args], { + env: options.env as NodeJS.ProcessEnv | undefined, + ignoreError: options.ignoreError, + input: options.input, + stdio: options.stdio as never, + }); + +function hookOutputsFromBuildSteps( + plan: SandboxMessagingPlan, + request: MessagingHookApplyRequest, +): { readonly outputs: MessagingHookOutputMap } { + const outputs: Record = {}; + for (const step of plan.buildSteps) { + if ( + step.channelId !== request.channelId || + step.hookId !== request.hookId || + step.value === undefined + ) { + continue; + } + outputs[step.outputId] = { + kind: step.kind, + value: step.value, + }; + } + return { outputs }; +} + +async function reapplyMessagingManifestAfterOpenClawDoctor( + sandboxName: string, + plan: SandboxMessagingPlan | null, + log: (msg: string) => void, +): Promise { + if (!plan || plan.agent !== "openclaw") { + log("Messaging manifest reapply skipped: no OpenClaw messaging plan"); + return; + } + + try { + log("Reapplying messaging manifest render and post-agent-install hooks after doctor"); + const result = await MessagingSetupApplier.applyAgentConfigAtOpenShell(plan, { + runOpenshell: runMessagingOpenshell, + runHook: (request) => hookOutputsFromBuildSteps(plan, request), + }); + log( + `messaging manifest reapply: targets=${result.appliedTargets.join(",")}, hooks=${result.appliedHooks.join(",")}`, + ); + if (result.appliedTargets.length > 0 || result.appliedHooks.length > 0) { + console.log(` ${G}✓${R} Messaging manifest config reapplied`); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log(`Messaging manifest reapply failed: ${message}`); + console.log(` ${D}Messaging manifest config reapply skipped (${message})${R}`); + } +} + /** * Rebuild a live sandbox while preserving registered agent state and policies. * @@ -274,10 +337,10 @@ export async function rebuildSandbox( // / WECHAT_USER_ID lets the in-process onboard --resume that fires later // see it directly via the wechatConfig builder's process.env path. // `openclaw-weixin/` runtime state is intentionally NOT in state_dirs — - // seed-wechat-accounts.py rebuilds the account files from these envs - // every image build, so keeping the envs here is the only thing the next - // image needs to put the right accountId/baseUrl/userId back into - // openclaw.json + the accounts state file. + // the manifest post-agent-install hook rebuilds account files from these + // env-backed config inputs every image build, so keeping the envs here is + // what the next image needs to put the right accountId/baseUrl/userId back + // into openclaw.json + the accounts state file. { // Only hydrate from the session when it belongs to THIS sandbox. The // global session file holds the most recent onboard, which may be for a @@ -913,39 +976,18 @@ export async function rebuildSandbox( ); } - // doctor --fix may rewrite openclaw.json after the image build seeded the - // WeChat account/channel block. Re-run the image-bundled seed helper when - // present so channels.openclaw-weixin remains paired with the preserved - // openclaw-weixin extension after rebuild restore. - log("Reapplying WeChat account seed after post-upgrade structure repair"); - const seedWechatCommand = [ - "if [ -f /usr/local/lib/nemoclaw/seed-wechat-accounts.py ]; then", - "python3 /usr/local/lib/nemoclaw/seed-wechat-accounts.py;", - "else", - "echo '[nemoclaw] seed-wechat-accounts.py not present; skipping';", - "fi", - ].join(" "); - const seedWechatResult = executeSandboxCommand(sandboxName, seedWechatCommand); - log( - `seed-wechat-accounts.py: exit=${seedWechatResult?.status}, stdout=${(seedWechatResult?.stdout || "").substring(0, 200)}`, - ); - if (seedWechatResult && seedWechatResult.status === 0) { - const seedWechatStdout = seedWechatResult.stdout ?? ""; - if (!seedWechatStdout.includes("not present; skipping")) { - console.log(` ${G}\u2713${R} WeChat account seed reapplied`); - } - } else { - console.log( - ` ${D}WeChat account seed skipped (seed helper returned ${seedWechatResult?.status ?? "null"})${R}`, - ); - } + // doctor --fix may rewrite openclaw.json after the image build applied + // manifest-owned messaging render and post-agent-install build-file outputs. + // Reapply the staged plan so channel config and WeChat account seed files + // remain paired with the restored OpenClaw extension state. + await reapplyMessagingManifestAfterOpenClawDoctor(sandboxName, rebuildMessagingPlan, log); // #4538: `openclaw doctor --fix` enforces a single-user 700/600 state // layout, which silently tightens NemoClaw's mutable config contract // (setgid + group-writable /sandbox/.openclaw and group-writable // openclaw.json). Run this LAST in the OpenClaw post-restore sequence — - // after doctor --fix and the WeChat seed helper, both of which rewrite - // openclaw.json (the seed helper atomically writes it 0600) — so the + // after doctor --fix and messaging manifest reapply, both of which can + // rewrite openclaw.json — so the // restored contract is not immediately undone. No-op for shields-up // sandboxes (config is intentionally root-owned/locked). log("Restoring mutable OpenClaw config permissions after post-restore config writes"); diff --git a/src/lib/adapters/openshell/client.ts b/src/lib/adapters/openshell/client.ts index 940445dbd0..a41004f9fe 100644 --- a/src/lib/adapters/openshell/client.ts +++ b/src/lib/adapters/openshell/client.ts @@ -30,6 +30,7 @@ interface OpenshellSpawnOptions { export interface RunOpenshellOptions extends OpenshellSpawnOptions { stdio?: SpawnSyncOptions["stdio"]; + input?: string; } export interface CaptureOpenshellOptions extends OpenshellSpawnOptions { @@ -134,6 +135,7 @@ export function runOpenshellCommand( env: { ...process.env, ...opts.env }, encoding: "utf-8", stdio: opts.stdio ?? "inherit", + input: opts.input, timeout: opts.timeout, }); if (result.error) { diff --git a/src/lib/adapters/openshell/runtime.ts b/src/lib/adapters/openshell/runtime.ts index f69c5c358d..8418be7585 100644 --- a/src/lib/adapters/openshell/runtime.ts +++ b/src/lib/adapters/openshell/runtime.ts @@ -20,6 +20,7 @@ type CommandArgs = string[]; type RunnerOptions = { env?: NodeJS.ProcessEnv; stdio?: StdioOptions; + input?: string; ignoreError?: boolean; timeout?: number; }; @@ -42,6 +43,7 @@ export function runOpenshell(args: CommandArgs, opts: RunnerOptions = {}) { cwd: ROOT, env: opts.env, stdio: opts.stdio, + input: opts.input, ignoreError: opts.ignoreError, timeout: opts.timeout, errorLine: console.error, diff --git a/src/lib/messaging/applier/agent-config.ts b/src/lib/messaging/applier/agent-config.ts index fd4026df24..f27ae3855c 100644 --- a/src/lib/messaging/applier/agent-config.ts +++ b/src/lib/messaging/applier/agent-config.ts @@ -257,6 +257,14 @@ function setJsonPath( } const finalSegment = segments[segments.length - 1] as string; assertSafeObjectKey(finalSegment, "Messaging render path"); + const existing = cursor[finalSegment]; + if (isObject(existing) && isObject(value)) { + mergeObjects( + existing as Record, + value as Record, + ); + return; + } cursor[finalSegment] = value; } diff --git a/src/lib/messaging/applier/setup-applier.test.ts b/src/lib/messaging/applier/setup-applier.test.ts index e795474b8e..5d4a0adb30 100644 --- a/src/lib/messaging/applier/setup-applier.test.ts +++ b/src/lib/messaging/applier/setup-applier.test.ts @@ -6,8 +6,8 @@ import { describe, expect, it } from "vitest"; import { createBuiltInChannelManifestRegistry } from "../channels"; import { MessagingWorkflowPlanner } from "../compiler"; import { createBuiltInMessagingHookRegistry, runMessagingHook } from "../hooks"; -import type { ChannelHookSpec } from "../manifest"; import type { + ChannelHookSpec, MessagingAgentId, MessagingSerializableObject, SandboxMessagingPlan, @@ -353,14 +353,10 @@ describe("MessagingSetupApplier", () => { enabled: true, groupPolicy: "open", }); - expect(openclawConfig.channels.telegram.groups["*"]).toEqual({ - requireMention: "{{telegramConfig.requireMention}}", - }); + expect(openclawConfig.channels.telegram.groups).toBeUndefined(); expect(result.appliedTargets).toEqual(["/sandbox/.openclaw/openclaw.json"]); expect(result.appliedHooks).toEqual([]); - expect(result.unresolvedTemplateRefs).toEqual( - expect.arrayContaining(["proxyUrl", "telegramConfig.requireMention"]), - ); + expect(result.unresolvedTemplateRefs).toEqual([]); }); it("excludes disabled channels at the applier boundary", async () => { @@ -395,6 +391,7 @@ describe("MessagingSetupApplier", () => { (request) => `${request.channelId}:${request.hookId}`, ), ).toEqual([ + "slack:slack-openclaw-package-install", "slack:slack-token-paste", "slack:slack-config-prompt", "slack:slack-credential-validation", @@ -538,9 +535,9 @@ describe("MessagingSetupApplier", () => { enabled: true, }); expect(result.appliedTargets).toEqual([ + "/sandbox/.openclaw/openclaw.json", "/sandbox/.openclaw/openclaw-weixin/accounts.json", "/sandbox/.openclaw/openclaw-weixin/accounts/wechat-account.json", - "/sandbox/.openclaw/openclaw.json", ]); expect(result.appliedHooks).toEqual(["wechat:wechat-seed-openclaw-account"]); }); diff --git a/src/lib/messaging/channels/discord/manifest.ts b/src/lib/messaging/channels/discord/manifest.ts index 212d48db8e..9647bca3bf 100644 --- a/src/lib/messaging/channels/discord/manifest.ts +++ b/src/lib/messaging/channels/discord/manifest.ts @@ -74,21 +74,26 @@ export const discordManifest = { policyPresets: ["discord"], render: [ { - id: "discord-openclaw-account", + id: "discord-openclaw-channel", kind: "json-fragment", agent: "openclaw", target: "openclaw.json", fragment: { - path: "channels.discord.accounts.default", + path: "channels.discord", value: { - token: "{{credential.discordBotToken.placeholder}}", enabled: true, - healthMonitor: { - enabled: false, + accounts: { + default: { + token: "{{credential.discordBotToken.placeholder}}", + enabled: true, + healthMonitor: { + enabled: false, + }, + proxy: "{{discordProxyUrl}}", + dmPolicy: "{{discord.allowedUsers.dmPolicy}}", + allowFrom: "{{discord.allowedUsers.values}}", + }, }, - proxy: "{{discordProxyUrl}}", - dmPolicy: "{{discord.allowedUsers.dmPolicy}}", - allowFrom: "{{discord.allowedUsers.values}}", }, }, }, @@ -97,6 +102,7 @@ export const discordManifest = { kind: "json-fragment", agent: "openclaw", target: "openclaw.json", + when: "{{discord.hasGuilds}}", fragment: { path: "channels.discord", value: { @@ -105,6 +111,18 @@ export const discordManifest = { }, }, }, + { + id: "discord-openclaw-plugin", + kind: "json-fragment", + agent: "openclaw", + target: "openclaw.json", + fragment: { + path: "plugins.entries.discord", + value: { + enabled: true, + }, + }, + }, { id: "discord-hermes-env", kind: "env-lines", @@ -134,6 +152,18 @@ export const discordManifest = { }, }, }, + { + id: "discord-hermes-platform", + kind: "json-fragment", + agent: "hermes", + target: "~/.hermes/config.yaml", + fragment: { + path: "platforms.discord", + value: { + enabled: true, + }, + }, + }, ], state: { persist: { @@ -155,6 +185,25 @@ export const discordManifest = { ], }, hooks: [ + { + id: "discord-openclaw-package-install", + phase: "agent-install", + handler: "common.staticOutputs", + agents: ["openclaw"], + outputs: [ + { + id: "openclawPluginPackage", + kind: "package-install", + required: true, + value: { + manager: "openclaw-plugin", + spec: "npm:@openclaw/discord@{{openclaw.version}}", + pin: true, + }, + }, + ], + onFailure: "abort", + }, { id: "discord-token-paste", phase: "enroll", diff --git a/src/lib/messaging/channels/manifests.test.ts b/src/lib/messaging/channels/manifests.test.ts index 189be24cd1..9cae79ee11 100644 --- a/src/lib/messaging/channels/manifests.test.ts +++ b/src/lib/messaging/channels/manifests.test.ts @@ -5,17 +5,11 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; -import { - buildDiscordConfig, - buildMessagingEnvLines, -} from "../../../../agents/hermes/config/messaging-config.ts"; import { getChannelTokenKeys, KNOWN_CHANNELS, knownChannelNames } from "../../sandbox/channels"; import { COMMON_CONFIG_PROMPT_HOOK_HANDLER_ID, COMMON_TOKEN_PASTE_HOOK_HANDLER_ID, } from "../hooks/common"; -import { SLACK_VALIDATE_CREDENTIALS_HOOK_HANDLER_ID } from "./slack/hooks"; -import { TELEGRAM_ALLOWLIST_ALIASES_HOOK_ID } from "./telegram/hooks"; import type { ChannelInputSpec, ChannelManifest, ChannelRenderSpec } from "../manifest"; import { BUILT_IN_CHANNEL_MANIFESTS, @@ -26,6 +20,8 @@ import { wechatManifest, whatsappManifest, } from "./index"; +import { SLACK_VALIDATE_CREDENTIALS_HOOK_HANDLER_ID } from "./slack/hooks"; +import { TELEGRAM_ALLOWLIST_ALIASES_HOOK_ID } from "./telegram/hooks"; function findInput(manifest: ChannelManifest, inputId: string): ChannelInputSpec { const input = manifest.inputs.find((entry) => entry.id === inputId); @@ -43,6 +39,21 @@ function renderJson(manifest: ChannelManifest): string { return JSON.stringify(manifest.render); } +function expectEnvRenderLines( + manifest: ChannelManifest, + renderId: string, + lines: readonly string[], +): void { + const render = findRender(manifest, renderId); + expect(render).toMatchObject({ + kind: "env-lines", + agent: "hermes", + target: "~/.hermes/.env", + }); + if (render.kind !== "env-lines") throw new Error(`${manifest.id}.${renderId} is not env-lines`); + expect(render.lines).toEqual(lines); +} + function policyPresetNames(manifest: ChannelManifest): string[] { return (manifest.policyPresets ?? []).map((preset) => typeof preset === "string" ? preset : preset.name, @@ -206,14 +217,6 @@ describe("built-in channel manifests", () => { const botToken = findInput(telegramManifest, "botToken"); const allowedIds = findInput(telegramManifest, "allowedIds"); const requireMention = findInput(telegramManifest, "requireMention"); - const hermesLines = buildMessagingEnvLines( - new Set(["telegram"]), - { telegram: ["123456789"] }, - {}, - {}, - {}, - ); - expect(getChannelTokenKeys(KNOWN_CHANNELS.telegram)).toEqual(["TELEGRAM_BOT_TOKEN"]); expect(botToken.envKey).toBe("TELEGRAM_BOT_TOKEN"); expect(allowedIds.envKey).toBe("TELEGRAM_ALLOWED_IDS"); @@ -228,14 +231,16 @@ describe("built-in channel manifests", () => { placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", }, ]); - expect(hermesLines).toContain( - "TELEGRAM_BOT_TOKEN=openshell:resolve:env:TELEGRAM_BOT_TOKEN", - ); - expect(hermesLines).toContain("TELEGRAM_ALLOWED_USERS=123456789"); - expect(renderJson(telegramManifest)).toContain("channels.telegram.accounts.default"); + expectEnvRenderLines(telegramManifest, "telegram-hermes-env", [ + "TELEGRAM_BOT_TOKEN={{credential.telegramBotToken.placeholder}}", + "TELEGRAM_ALLOWED_USERS={{allowedIds.telegram.csv}}", + ]); + expect(renderJson(telegramManifest)).toContain('"path":"channels.telegram"'); + expect(renderJson(telegramManifest)).toContain('"accounts"'); expect(renderJson(telegramManifest)).toContain("groupPolicy"); expect(renderJson(telegramManifest)).toContain("channels.telegram.groups"); expect(renderJson(telegramManifest)).toContain("telegramConfig.requireMention"); + expect(renderJson(telegramManifest)).toContain("platforms.telegram"); expectTokenPasteEnrollHook(telegramManifest, ["botToken"]); expect(telegramManifest.hooks).toContainEqual({ id: "telegram-allowlist-aliases", @@ -257,19 +262,6 @@ describe("built-in channel manifests", () => { const serverId = findInput(discordManifest, "serverId"); const requireMention = findInput(discordManifest, "requireMention"); const userId = findInput(discordManifest, "userId"); - const hermesLines = buildMessagingEnvLines( - new Set(["discord"]), - {}, - { - "1491590992753590594": { - requireMention: false, - users: ["1005536447329222676"], - }, - }, - {}, - {}, - ); - expect(getChannelTokenKeys(KNOWN_CHANNELS.discord)).toEqual(["DISCORD_BOT_TOKEN"]); expect(botToken.envKey).toBe("DISCORD_BOT_TOKEN"); expect(serverId.envKey).toBe("DISCORD_SERVER_ID"); @@ -285,20 +277,17 @@ describe("built-in channel manifests", () => { placeholder: "openshell:resolve:env:DISCORD_BOT_TOKEN", }, ]); - expect(buildDiscordConfig({ "1491590992753590594": { requireMention: false } })).toEqual({ - require_mention: false, - free_response_channels: "", - allowed_channels: "", - auto_thread: true, - reactions: true, - channel_prompts: {}, - }); - expect(hermesLines).toContain( - "DISCORD_BOT_TOKEN=openshell:resolve:env:DISCORD_BOT_TOKEN", - ); - expect(hermesLines).toContain("NEMOCLAW_DISCORD_GUILD_IDS=1491590992753590594"); - expect(hermesLines).toContain("DISCORD_ALLOWED_USERS=1005536447329222676"); - expect(renderJson(discordManifest)).toContain("channels.discord.accounts.default"); + expect(renderJson(discordManifest)).toContain("\"path\":\"discord\""); + expect(renderJson(discordManifest)).toContain("\"require_mention\""); + expect(renderJson(discordManifest)).toContain("\"path\":\"platforms.discord\""); + expectEnvRenderLines(discordManifest, "discord-hermes-env", [ + "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}}", + ]); + expect(renderJson(discordManifest)).toContain('"path":"channels.discord"'); + expect(renderJson(discordManifest)).toContain('"accounts"'); expect(renderJson(discordManifest)).toContain("channels.discord"); expect(renderJson(discordManifest)).toContain("discord.guilds"); expect(renderJson(discordManifest)).toContain("require_mention"); @@ -311,14 +300,6 @@ describe("built-in channel manifests", () => { const appToken = findInput(slackManifest, "appToken"); const allowedUsers = findInput(slackManifest, "allowedUsers"); const allowedChannels = findInput(slackManifest, "allowedChannels"); - const hermesLines = buildMessagingEnvLines( - new Set(["slack"]), - { slack: ["U0123456789"] }, - {}, - {}, - {}, - ); - expect(getChannelTokenKeys(KNOWN_CHANNELS.slack)).toEqual([ "SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", @@ -350,14 +331,14 @@ describe("built-in channel manifests", () => { placeholder: "xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN", }, ]); - expect(hermesLines).toContain( - "SLACK_BOT_TOKEN=xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN", - ); - expect(hermesLines).toContain( - "SLACK_APP_TOKEN=xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN", - ); - expect(hermesLines).toContain("SLACK_ALLOWED_USERS=U0123456789"); - expect(renderJson(slackManifest)).toContain("channels.slack.accounts.default"); + expectEnvRenderLines(slackManifest, "slack-hermes-env", [ + "SLACK_BOT_TOKEN={{credential.slackBotToken.placeholder}}", + "SLACK_APP_TOKEN={{credential.slackAppToken.placeholder}}", + "SLACK_ALLOWED_USERS={{allowedIds.slack.csv}}", + "SLACK_ALLOWED_CHANNELS={{slackConfig.allowedChannels.csv}}", + ]); + expect(renderJson(slackManifest)).toContain('"path":"channels.slack"'); + expect(renderJson(slackManifest)).toContain('"accounts"'); expect(renderJson(slackManifest)).toContain("allowedIds.slack.channels"); expectTokenPasteEnrollHook(slackManifest, ["botToken", "appToken"]); expectConfigPromptEnrollHook(slackManifest, ["allowedUsers", "allowedChannels"]); @@ -386,18 +367,6 @@ describe("built-in channel manifests", () => { const baseUrl = findInput(wechatManifest, "baseUrl"); const userId = findInput(wechatManifest, "userId"); const allowedIds = findInput(wechatManifest, "allowedIds"); - const hermesLines = buildMessagingEnvLines( - new Set(["wechat"]), - { wechat: ["bot_other_friend"] }, - {}, - { - accountId: "test_account_42", - baseUrl: "https://ilinkai.wechat.com", - userId: "operator_self_id", - }, - {}, - ); - expect(getChannelTokenKeys(KNOWN_CHANNELS.wechat)).toEqual(["WECHAT_BOT_TOKEN"]); expect(wechatManifest.auth.mode).toBe("host-qr"); expect(botToken.envKey).toBe("WECHAT_BOT_TOKEN"); @@ -437,10 +406,13 @@ describe("built-in channel manifests", () => { env: "WECHAT_ALLOWED_IDS", }, ]); - expect(hermesLines).toContain("WEIXIN_TOKEN=openshell:resolve:env:WECHAT_BOT_TOKEN"); - expect(hermesLines).toContain("WEIXIN_ACCOUNT_ID=test_account_42"); - expect(hermesLines).toContain("WEIXIN_BASE_URL=https://ilinkai.wechat.com"); - expect(hermesLines).toContain("WEIXIN_ALLOWED_USERS=operator_self_id,bot_other_friend"); + expectEnvRenderLines(wechatManifest, "wechat-hermes-env", [ + "WEIXIN_TOKEN={{credential.wechatBotToken.placeholder}}", + "WEIXIN_ACCOUNT_ID={{wechatConfig.accountId}}", + "WEIXIN_BASE_URL={{wechatConfig.baseUrl}}", + "WEIXIN_ALLOWED_USERS={{allowedIds.wechat.csv}}", + ]); + expect(renderJson(wechatManifest)).toContain("platforms.weixin"); expect(renderJson(wechatManifest)).toContain("WEIXIN_TOKEN"); expect(renderJson(wechatManifest)).toContain("credential.wechatBotToken.placeholder"); expect(wechatManifest.hooks.map((hook) => hook.handler)).toEqual([ @@ -471,14 +443,20 @@ describe("built-in channel manifests", () => { }); }); - it("declares WhatsApp as in-sandbox QR with no host-side token bindings", () => { - const openclawRender = findRender(whatsappManifest, "whatsapp-openclaw-account"); + it("declares WhatsApp as in-sandbox QR with optional allowlist config", () => { + const openclawRender = findRender(whatsappManifest, "whatsapp-openclaw-channel"); const hermesRender = findRender(whatsappManifest, "whatsapp-hermes-env"); - const hermesLines = buildMessagingEnvLines(new Set(["whatsapp"]), {}, {}, {}, {}); expect(getChannelTokenKeys(KNOWN_CHANNELS.whatsapp)).toEqual([]); expect(whatsappManifest.auth.mode).toBe("in-sandbox-qr"); - expect(whatsappManifest.inputs).toEqual([]); + expect(whatsappManifest.inputs).toEqual([ + expect.objectContaining({ + id: "allowedIds", + kind: "config", + envKey: "WHATSAPP_ALLOWED_IDS", + statePath: "allowedIds.whatsapp", + }), + ]); expect(whatsappManifest.credentials).toEqual([]); expect(whatsappManifest.policyPresets).toEqual(["whatsapp"]); expect(openclawRender).toMatchObject({ @@ -486,14 +464,19 @@ describe("built-in channel manifests", () => { agent: "openclaw", target: "openclaw.json", }); - expect(JSON.stringify(openclawRender)).toContain("channels.whatsapp.accounts.default"); + expect(JSON.stringify(openclawRender)).toContain('"path":"channels.whatsapp"'); + expect(JSON.stringify(openclawRender)).toContain('"accounts"'); expect(hermesRender).toMatchObject({ kind: "env-lines", agent: "hermes", target: "~/.hermes/.env", }); - expect(hermesLines).toContain("WHATSAPP_ENABLED=true"); - expect(hermesLines).toContain("WHATSAPP_MODE=bot"); + expectEnvRenderLines(whatsappManifest, "whatsapp-hermes-env", [ + "WHATSAPP_ENABLED=true", + "WHATSAPP_MODE=bot", + "WHATSAPP_ALLOWED_USERS={{allowedIds.whatsapp.csv}}", + ]); + expect(renderJson(whatsappManifest)).toContain("platforms.whatsapp"); expect(renderJson(whatsappManifest)).not.toContain("WHATSAPP_BOT_TOKEN"); expect(renderJson(whatsappManifest)).not.toContain("openshell:resolve:env:WHATSAPP"); }); diff --git a/src/lib/messaging/channels/slack/manifest.ts b/src/lib/messaging/channels/slack/manifest.ts index f0564c69aa..340de84f28 100644 --- a/src/lib/messaging/channels/slack/manifest.ts +++ b/src/lib/messaging/channels/slack/manifest.ts @@ -83,23 +83,40 @@ export const slackManifest = { policyPresets: ["slack"], render: [ { - id: "slack-openclaw-account", + id: "slack-openclaw-channel", kind: "json-fragment", agent: "openclaw", target: "openclaw.json", fragment: { - path: "channels.slack.accounts.default", + path: "channels.slack", value: { - botToken: "{{credential.slackBotToken.placeholder}}", - appToken: "{{credential.slackAppToken.placeholder}}", enabled: true, - healthMonitor: { - enabled: false, + accounts: { + default: { + botToken: "{{credential.slackBotToken.placeholder}}", + appToken: "{{credential.slackAppToken.placeholder}}", + enabled: true, + healthMonitor: { + enabled: false, + }, + dmPolicy: "{{allowedIds.slack.dmPolicy}}", + allowFrom: "{{allowedIds.slack.values}}", + groupPolicy: "{{allowedIds.slack.groupPolicy}}", + channels: "{{allowedIds.slack.channels}}", + }, }, - dmPolicy: "{{allowedIds.slack.dmPolicy}}", - allowFrom: "{{allowedIds.slack.values}}", - groupPolicy: "{{allowedIds.slack.groupPolicy}}", - channels: "{{allowedIds.slack.channels}}", + }, + }, + }, + { + id: "slack-openclaw-plugin", + kind: "json-fragment", + agent: "openclaw", + target: "openclaw.json", + fragment: { + path: "plugins.entries.slack", + value: { + enabled: true, }, }, }, @@ -112,8 +129,21 @@ export const slackManifest = { "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-hermes-platform", + kind: "json-fragment", + agent: "hermes", + target: "~/.hermes/config.yaml", + fragment: { + path: "platforms.slack", + value: { + enabled: true, + }, + }, + }, ], state: { persist: { @@ -132,6 +162,25 @@ export const slackManifest = { ], }, hooks: [ + { + id: "slack-openclaw-package-install", + phase: "agent-install", + handler: "common.staticOutputs", + agents: ["openclaw"], + outputs: [ + { + id: "openclawPluginPackage", + kind: "package-install", + required: true, + value: { + manager: "openclaw-plugin", + spec: "npm:@openclaw/slack@{{openclaw.version}}", + pin: true, + }, + }, + ], + onFailure: "abort", + }, { id: "slack-token-paste", phase: "enroll", diff --git a/src/lib/messaging/channels/telegram/manifest.ts b/src/lib/messaging/channels/telegram/manifest.ts index d0af4167bd..4447d70705 100644 --- a/src/lib/messaging/channels/telegram/manifest.ts +++ b/src/lib/messaging/channels/telegram/manifest.ts @@ -64,22 +64,27 @@ export const telegramManifest = { policyPresets: [{ name: "telegram", policyKeys: ["telegram_bot"] }], render: [ { - id: "telegram-openclaw-account", + id: "telegram-openclaw-channel", kind: "json-fragment", agent: "openclaw", target: "openclaw.json", fragment: { - path: "channels.telegram.accounts.default", + path: "channels.telegram", value: { - botToken: "{{credential.telegramBotToken.placeholder}}", enabled: true, - healthMonitor: { - enabled: false, + accounts: { + default: { + botToken: "{{credential.telegramBotToken.placeholder}}", + enabled: true, + healthMonitor: { + enabled: false, + }, + proxy: "{{proxyUrl}}", + groupPolicy: "open", + dmPolicy: "{{allowedIds.telegram.dmPolicy}}", + allowFrom: "{{allowedIds.telegram.values}}", + }, }, - proxy: "{{proxyUrl}}", - groupPolicy: "open", - dmPolicy: "{{allowedIds.telegram.dmPolicy}}", - allowFrom: "{{allowedIds.telegram.values}}", }, }, }, @@ -88,6 +93,7 @@ export const telegramManifest = { kind: "json-fragment", agent: "openclaw", target: "openclaw.json", + when: "{{telegramConfig.requireMention}}", fragment: { path: "channels.telegram.groups", value: { @@ -97,6 +103,18 @@ export const telegramManifest = { }, }, }, + { + id: "telegram-openclaw-plugin", + kind: "json-fragment", + agent: "openclaw", + target: "openclaw.json", + fragment: { + path: "plugins.entries.telegram", + value: { + enabled: true, + }, + }, + }, { id: "telegram-hermes-env", kind: "env-lines", @@ -119,6 +137,18 @@ export const telegramManifest = { }, }, }, + { + id: "telegram-hermes-platform", + kind: "json-fragment", + agent: "hermes", + target: "~/.hermes/config.yaml", + fragment: { + path: "platforms.telegram", + value: { + enabled: true, + }, + }, + }, ], state: { persist: { diff --git a/src/lib/messaging/channels/wechat/manifest.ts b/src/lib/messaging/channels/wechat/manifest.ts index 7c87b38820..de466bea7b 100644 --- a/src/lib/messaging/channels/wechat/manifest.ts +++ b/src/lib/messaging/channels/wechat/manifest.ts @@ -70,6 +70,18 @@ export const wechatManifest = { ], policyPresets: [{ name: "wechat", policyKeys: ["wechat_bridge"] }], render: [ + { + id: "wechat-openclaw-plugin", + kind: "json-fragment", + agent: "openclaw", + target: "openclaw.json", + fragment: { + path: "plugins.entries.openclaw-weixin", + value: { + enabled: true, + }, + }, + }, { id: "wechat-hermes-env", kind: "env-lines", @@ -82,6 +94,18 @@ export const wechatManifest = { "WEIXIN_ALLOWED_USERS={{allowedIds.wechat.csv}}", ], }, + { + id: "wechat-hermes-platform", + kind: "json-fragment", + agent: "hermes", + target: "~/.hermes/config.yaml", + fragment: { + path: "platforms.weixin", + value: { + enabled: true, + }, + }, + }, ], state: { persist: { diff --git a/src/lib/messaging/channels/whatsapp/manifest.ts b/src/lib/messaging/channels/whatsapp/manifest.ts index c6f931c0df..d82dcf6373 100644 --- a/src/lib/messaging/channels/whatsapp/manifest.ts +++ b/src/lib/messaging/channels/whatsapp/manifest.ts @@ -17,33 +17,104 @@ export const whatsappManifest = { auth: { mode: "in-sandbox-qr", }, - inputs: [], + inputs: [ + { + id: "allowedIds", + kind: "config", + required: false, + envKey: "WHATSAPP_ALLOWED_IDS", + statePath: "allowedIds.whatsapp", + }, + ], credentials: [], policyPresets: ["whatsapp"], render: [ { - id: "whatsapp-openclaw-account", + id: "whatsapp-openclaw-channel", kind: "json-fragment", agent: "openclaw", target: "openclaw.json", fragment: { - path: "channels.whatsapp.accounts.default", + path: "channels.whatsapp", value: { enabled: true, - healthMonitor: { - enabled: false, + accounts: { + default: { + enabled: true, + healthMonitor: { + enabled: false, + }, + }, }, }, }, }, + { + id: "whatsapp-openclaw-plugin", + kind: "json-fragment", + agent: "openclaw", + target: "openclaw.json", + fragment: { + path: "plugins.entries.whatsapp", + value: { + enabled: true, + }, + }, + }, { id: "whatsapp-hermes-env", kind: "env-lines", agent: "hermes", target: "~/.hermes/.env", - lines: ["WHATSAPP_ENABLED=true", "WHATSAPP_MODE=bot"], + lines: [ + "WHATSAPP_ENABLED=true", + "WHATSAPP_MODE=bot", + "WHATSAPP_ALLOWED_USERS={{allowedIds.whatsapp.csv}}", + ], + }, + { + id: "whatsapp-hermes-platform", + kind: "json-fragment", + agent: "hermes", + target: "~/.hermes/config.yaml", + fragment: { + path: "platforms.whatsapp", + value: { + enabled: true, + }, + }, + }, + ], + state: { + persist: { + allowedIds: ["allowedIds"], + }, + rebuildHydration: [ + { + statePath: "allowedIds.whatsapp", + env: "WHATSAPP_ALLOWED_IDS", + }, + ], + }, + hooks: [ + { + id: "whatsapp-openclaw-package-install", + phase: "agent-install", + handler: "common.staticOutputs", + agents: ["openclaw"], + outputs: [ + { + id: "openclawPluginPackage", + kind: "package-install", + required: true, + value: { + manager: "openclaw-plugin", + spec: "npm:@openclaw/whatsapp@{{openclaw.version}}", + pin: true, + }, + }, + ], + onFailure: "abort", }, ], - state: {}, - hooks: [], } as const satisfies ChannelManifest; diff --git a/src/lib/messaging/compiler/engines/agent-render-engine.ts b/src/lib/messaging/compiler/engines/agent-render-engine.ts index 5e58126811..be0dc7f860 100644 --- a/src/lib/messaging/compiler/engines/agent-render-engine.ts +++ b/src/lib/messaging/compiler/engines/agent-render-engine.ts @@ -1,53 +1,124 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { MessagingHookRegistry, runMessagingHook } from "../../hooks"; +import { COMMON_STATIC_OUTPUTS_HOOK_HANDLER_ID } from "../../hooks/common/static-outputs"; import type { + ChannelHookSpec, ChannelManifest, + ChannelRenderSpec, + MessagingSerializableValue, SandboxMessagingAgentRenderPlan, SandboxMessagingEnvLinesRenderPlan, + SandboxMessagingInputReference, SandboxMessagingJsonRenderPlan, } from "../../manifest"; import type { ManifestCompilerContext } from "../types"; import { collectTemplateReferencesInLines, collectTemplateReferencesInValue, + isTruthyRenderTemplate, resolveCredentialTemplatesInLines, resolveCredentialTemplatesInValue, + resolveRenderTemplatesInLines, + resolveRenderTemplatesInValue, } from "./template"; -export function planAgentRender( +export async function planAgentRender( manifest: ChannelManifest, context: ManifestCompilerContext, -): SandboxMessagingAgentRenderPlan[] { - return manifest.render - .filter((render) => render.agent === context.agent) - .map((render) => { - if (render.kind === "json-fragment") { - const value = resolveCredentialTemplatesInValue( - render.fragment.value, - manifest.credentials, - ); - return { - channelId: manifest.id, - renderId: render.id, - kind: "json-fragment", - agent: render.agent, - target: render.target, - path: render.fragment.path, - value, - templateRefs: collectTemplateReferencesInValue(value), - } satisfies SandboxMessagingJsonRenderPlan; - } - - const lines = resolveCredentialTemplatesInLines(render.lines, manifest.credentials); - return { + inputs: readonly SandboxMessagingInputReference[] = [], + hooks = new MessagingHookRegistry(), +): Promise { + const plans: SandboxMessagingAgentRenderPlan[] = []; + const templateContext = { inputs, env: process.env }; + + for (const [index, render] of manifest.render.entries()) { + if (render.agent !== context.agent) continue; + if (!isTruthyRenderTemplate(render.when, templateContext)) continue; + + const hook = renderHookForManifestEntry(manifest.id, render, index); + const result = await runMessagingHook(hook, hooks, { channelId: manifest.id }); + const hookOutput = result.outputs.render?.value; + if (!isChannelRenderSpec(hookOutput)) { + throw new Error(`Messaging render hook '${hook.id}' did not return a render spec.`); + } + + if (hookOutput.kind === "json-fragment") { + const credentialResolved = resolveCredentialTemplatesInValue( + hookOutput.fragment.value, + manifest.credentials, + ); + const value = resolveRenderTemplatesInValue(credentialResolved, templateContext); + if (value === undefined) continue; + plans.push({ channelId: manifest.id, - renderId: render.id, - kind: "env-lines", - agent: render.agent, - target: render.target, - lines, - templateRefs: collectTemplateReferencesInLines(lines), - } satisfies SandboxMessagingEnvLinesRenderPlan; - }); + renderId: hookOutput.id, + hookId: result.hookId, + handler: result.handlerId, + kind: "json-fragment", + agent: hookOutput.agent, + target: hookOutput.target, + path: hookOutput.fragment.path, + value, + templateRefs: collectTemplateReferencesInValue(value), + } satisfies SandboxMessagingJsonRenderPlan); + continue; + } + + const credentialResolved = resolveCredentialTemplatesInLines( + hookOutput.lines, + manifest.credentials, + ); + const lines = resolveRenderTemplatesInLines(credentialResolved, templateContext); + if (lines.length === 0) continue; + plans.push({ + channelId: manifest.id, + renderId: hookOutput.id, + hookId: result.hookId, + handler: result.handlerId, + kind: "env-lines", + agent: hookOutput.agent, + target: hookOutput.target, + lines, + templateRefs: collectTemplateReferencesInLines(lines), + } satisfies SandboxMessagingEnvLinesRenderPlan); + } + + return plans; +} + +function renderHookForManifestEntry( + channelId: string, + render: ChannelRenderSpec, + index: number, +): ChannelHookSpec { + return { + id: render.id ?? `${channelId}-render-${index}`, + phase: "render", + handler: COMMON_STATIC_OUTPUTS_HOOK_HANDLER_ID, + agents: [render.agent], + outputs: [ + { + id: "render", + kind: "agent-render", + required: true, + value: render as unknown as MessagingSerializableValue, + }, + ], + }; +} + +function isChannelRenderSpec(value: unknown): value is ChannelRenderSpec { + if (!isObject(value)) return false; + if (value.kind !== "json-fragment" && value.kind !== "env-lines") return false; + if (typeof value.agent !== "string" || typeof value.target !== "string") return false; + if (value.kind === "json-fragment") { + return isObject(value.fragment) && typeof value.fragment.path === "string"; + } + return Array.isArray(value.lines) && value.lines.every((line) => typeof line === "string"); +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); } diff --git a/src/lib/messaging/compiler/engines/build-step-engine.ts b/src/lib/messaging/compiler/engines/build-step-engine.ts index 29ec2dcb69..0f2848daee 100644 --- a/src/lib/messaging/compiler/engines/build-step-engine.ts +++ b/src/lib/messaging/compiler/engines/build-step-engine.ts @@ -1,34 +1,96 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import type { MessagingHookOutputMap } from "../../hooks"; +import { MessagingHookRegistry, runMessagingHook } from "../../hooks"; import type { ChannelHookOutputSpec, ChannelManifest, MessagingAgentId, + MessagingSerializableValue, SandboxMessagingBuildStepPlan, + SandboxMessagingChannelPlan, + SandboxMessagingCredentialBindingPlan, } from "../../manifest"; -export function planBuildSteps( +export async function planBuildSteps( manifest: ChannelManifest, agent: MessagingAgentId, -): SandboxMessagingBuildStepPlan[] { - return manifest.hooks.flatMap((hook) => { - if (hook.agents && !hook.agents.includes(agent)) return []; - return (hook.outputs ?? []) - .filter(isBuildStepOutput) - .map((output) => ({ + channel: SandboxMessagingChannelPlan | undefined, + credentialBindings: readonly SandboxMessagingCredentialBindingPlan[], + hooks: MessagingHookRegistry, +): Promise { + const steps: SandboxMessagingBuildStepPlan[] = []; + for (const hook of manifest.hooks) { + if (hook.agents && !hook.agents.includes(agent)) continue; + const buildOutputs = (hook.outputs ?? []).filter(isBuildStepOutput); + if (buildOutputs.length === 0) continue; + + let hookOutputs: MessagingHookOutputMap = {}; + if (channel?.active) { + const result = await runMessagingHook(hook, hooks, { + channelId: manifest.id, + inputs: selectHookInputs( + buildHookInputMap(channel, credentialBindings), + hook.inputs, + ), + }); + hookOutputs = result.outputs; + } + + for (const output of buildOutputs) { + const value = hookOutputs[output.id]?.value; + steps.push({ channelId: manifest.id, kind: output.kind, hookId: hook.id, handler: hook.handler, outputId: output.id, required: output.required === true, - })); - }); + ...(value !== undefined ? { value } : {}), + }); + } + } + return steps; } function isBuildStepOutput( output: ChannelHookOutputSpec, -): output is ChannelHookOutputSpec & { readonly kind: "build-arg" | "build-file" } { - return output.kind === "build-arg" || output.kind === "build-file"; +): output is ChannelHookOutputSpec & { + readonly kind: "build-arg" | "build-file" | "package-install"; +} { + return ( + output.kind === "build-arg" || + output.kind === "build-file" || + output.kind === "package-install" + ); +} + +function buildHookInputMap( + channel: SandboxMessagingChannelPlan, + credentialBindings: readonly SandboxMessagingCredentialBindingPlan[], +): Record { + const inputs: Record = {}; + for (const input of channel.inputs) { + if (input.value === undefined) continue; + inputs[input.inputId] = input.value; + if (input.statePath) inputs[input.statePath] = input.value; + } + for (const credential of credentialBindings) { + if (credential.channelId !== channel.channelId) continue; + inputs[`credential.${credential.credentialId}.placeholder`] = credential.placeholder; + } + return inputs; +} + +function selectHookInputs( + inputs: Record, + inputKeys: readonly string[] | undefined, +): Record | undefined { + if (!inputKeys || inputKeys.length === 0) return inputs; + return Object.fromEntries( + inputKeys + .filter((inputKey) => Object.hasOwn(inputs, inputKey)) + .map((inputKey) => [inputKey, inputs[inputKey] as MessagingSerializableValue]), + ); } diff --git a/src/lib/messaging/compiler/engines/template.ts b/src/lib/messaging/compiler/engines/template.ts index 1400029c81..7504ea49ec 100644 --- a/src/lib/messaging/compiler/engines/template.ts +++ b/src/lib/messaging/compiler/engines/template.ts @@ -5,11 +5,28 @@ import type { ChannelCredentialSpec, MessagingSerializableValue, MessagingTemplateString, + SandboxMessagingInputReference, } from "../../manifest"; const CREDENTIAL_PLACEHOLDER_PATTERN = /\{\{\s*credential\.([A-Za-z0-9_-]+)\.placeholder\s*\}\}/g; +const EXACT_TEMPLATE_PATTERN = /^\{\{\s*([^}]+?)\s*\}\}$/; const TEMPLATE_REFERENCE_PATTERN = /\{\{\s*([^}]+?)\s*\}\}/g; +const DEFAULT_PROXY_HOST = "10.200.0.1"; +const DEFAULT_PROXY_PORT = "3128"; + +type RenderTemplateValue = MessagingSerializableValue | undefined; + +type DiscordGuildConfig = { + readonly enabled: true; + readonly requireMention?: boolean; + readonly users?: readonly string[]; +}; + +export interface RenderTemplateContext { + readonly inputs: readonly SandboxMessagingInputReference[]; + readonly env?: Record; +} export function resolveSandboxNameTemplate( value: MessagingTemplateString, @@ -44,6 +61,51 @@ export function resolveCredentialTemplatesInLines( return lines.map((line) => resolveCredentialTemplatesInString(line, credentials)); } +export function resolveRenderTemplatesInValue( + value: MessagingSerializableValue, + context: RenderTemplateContext, +): RenderTemplateValue { + if (typeof value === "string") return resolveRenderTemplatesInString(value, context); + if (Array.isArray(value)) { + if (value.length === 0) return value; + const resolved = value + .map((entry) => resolveRenderTemplatesInValue(entry, context)) + .filter((entry): entry is MessagingSerializableValue => entry !== undefined); + return resolved.length > 0 ? resolved : undefined; + } + if (value && typeof value === "object") { + const sourceEntries = Object.entries(value); + if (sourceEntries.length === 0) return value; + const entries = sourceEntries + .map(([key, entry]) => [key, resolveRenderTemplatesInValue(entry, context)] as const) + .filter((entry): entry is readonly [string, MessagingSerializableValue] => entry[1] !== undefined); + return entries.length > 0 ? Object.fromEntries(entries) : undefined; + } + return value; +} + +export function resolveRenderTemplatesInLines( + lines: readonly MessagingTemplateString[], + context: RenderTemplateContext, +): MessagingTemplateString[] { + return lines + .map((line) => resolveRenderTemplatesInString(line, context)) + .filter((line): line is string => typeof line === "string" && line.length > 0); +} + +export function isTruthyRenderTemplate( + value: MessagingTemplateString | undefined, + context: RenderTemplateContext, +): boolean { + if (!value) return true; + const resolved = resolveRenderTemplatesInString(value, context); + if (resolved === undefined || resolved === null || resolved === false) return false; + if (Array.isArray(resolved)) return resolved.length > 0; + if (typeof resolved === "object") return Object.keys(resolved).length > 0; + if (typeof resolved === "string") return resolved.trim().length > 0; + return true; +} + export function collectTemplateReferencesInValue( value: MessagingSerializableValue, ): string[] { @@ -75,6 +137,190 @@ function resolveCredentialTemplatesInString( }); } +function resolveRenderTemplatesInString( + value: MessagingTemplateString, + context: RenderTemplateContext, +): RenderTemplateValue { + const exact = value.match(EXACT_TEMPLATE_PATTERN); + if (exact?.[1]) return resolveTemplateReference(exact[1].trim(), context); + + let omitted = false; + const resolved = value.replace(TEMPLATE_REFERENCE_PATTERN, (match, reference: string) => { + const replacement = resolveTemplateReference(reference.trim(), context); + if (replacement === undefined || replacement === null) { + omitted = true; + return ""; + } + if (Array.isArray(replacement)) return replacement.map(String).join(","); + if (typeof replacement === "object") return JSON.stringify(replacement); + return String(replacement); + }); + return omitted ? undefined : resolved; +} + +function resolveTemplateReference( + reference: string, + context: RenderTemplateContext, +): RenderTemplateValue { + if (reference === "proxyUrl") return proxyUrl(context.env); + if (reference === "discordProxyUrl") return undefined; + if (reference === "discord.guilds") return nonEmptyObject(discordGuilds(context)); + if (reference === "discord.hasGuilds") return Object.keys(discordGuilds(context)).length > 0; + if (reference === "discord.guildIds.csv") return nonEmptyCsv(Object.keys(discordGuilds(context))); + if (reference === "discord.allowedUsers.values") return nonEmptyArray(discordAllowedUsers(context)); + if (reference === "discord.allowedUsers.csv") return nonEmptyCsv(discordAllowedUsers(context)); + if (reference === "discord.allowedUsers.dmPolicy") { + return discordAllowedUsers(context).length > 0 ? "allowlist" : undefined; + } + if (reference === "discord.allowAllUsers") { + return Object.keys(discordGuilds(context)).length > 0 && discordAllowedUsers(context).length === 0 + ? true + : undefined; + } + if (reference === "discord.requireMention") return discordRequireMention(context); + + const allowedIds = reference.match(/^allowedIds\.([A-Za-z0-9_-]+)\.(values|csv|dmPolicy|groupPolicy|channels)$/); + if (allowedIds?.[1] && allowedIds[2]) { + return resolveAllowedIdsTemplate(allowedIds[1], allowedIds[2], context); + } + + if (reference === "telegramConfig.requireMention") { + return parseBoolean(stateValue(context, "telegramConfig.requireMention")); + } + + const wechatConfig = reference.match(/^wechatConfig\.(accountId|baseUrl|userId)$/); + if (wechatConfig?.[1]) return nonEmptyString(stateValue(context, `wechatConfig.${wechatConfig[1]}`)); + + if (reference === "slackConfig.allowedChannels.csv") return nonEmptyCsv(slackAllowedChannels(context)); + + return `{{${reference}}}`; +} + +function resolveAllowedIdsTemplate( + channel: string, + selector: string, + context: RenderTemplateContext, +): RenderTemplateValue { + const ids = allowedIds(context, channel); + if (selector === "values") return nonEmptyArray(ids); + if (selector === "csv") return nonEmptyCsv(ids); + if (selector === "dmPolicy") return ids.length > 0 ? "allowlist" : undefined; + if (selector === "groupPolicy") { + return ids.length > 0 || (channel === "slack" && slackAllowedChannels(context).length > 0) + ? "allowlist" + : undefined; + } + if (selector === "channels" && channel === "slack") return slackChannelConfig(context, ids); + return undefined; +} + +function proxyUrl(env: RenderTemplateContext["env"]): string { + const host = nonEmptyString(env?.NEMOCLAW_PROXY_HOST) ?? DEFAULT_PROXY_HOST; + const port = nonEmptyString(env?.NEMOCLAW_PROXY_PORT) ?? DEFAULT_PROXY_PORT; + return `http://${host}:${port}`; +} + +function slackChannelConfig( + context: RenderTemplateContext, + users: readonly string[], +): Record | undefined { + const allowedChannels = slackAllowedChannels(context); + const entry: Record = { + enabled: true, + requireMention: true, + ...(users.length > 0 ? { users: [...users] } : {}), + }; + if (allowedChannels.length > 0) { + return Object.fromEntries(allowedChannels.map((channelId) => [channelId, { ...entry }])); + } + return users.length > 0 ? { "*": entry } : undefined; +} + +function discordGuilds(context: RenderTemplateContext): Record { + const serverIds = parseList(stateValue(context, "discordGuilds.serverId")); + if (serverIds.length === 0) return {}; + const users = parseList(stateValue(context, "discordGuilds.userIds")); + const requireMention = parseBoolean(stateValue(context, "discordGuilds.requireMention")) ?? true; + return Object.fromEntries( + serverIds.map((serverId) => [ + serverId, + { + enabled: true, + requireMention, + ...(users.length > 0 ? { users } : {}), + }, + ]), + ); +} + +function discordAllowedUsers(context: RenderTemplateContext): string[] { + const users = new Set(allowedIds(context, "discord")); + for (const guild of Object.values(discordGuilds(context))) { + for (const user of guild.users ?? []) users.add(String(user)); + } + return [...users]; +} + +function discordRequireMention(context: RenderTemplateContext): boolean { + for (const guild of Object.values(discordGuilds(context))) { + if (typeof guild.requireMention === "boolean") return guild.requireMention; + } + return true; +} + +function allowedIds(context: RenderTemplateContext, channel: string): string[] { + const ids = parseList(stateValue(context, `allowedIds.${channel}`)); + if (channel !== "wechat") return ids; + const userId = nonEmptyString(stateValue(context, "wechatConfig.userId")); + return userId && !ids.includes(userId) ? [userId, ...ids] : ids; +} + +function slackAllowedChannels(context: RenderTemplateContext): string[] { + return parseList(stateValue(context, "slackConfig.allowedChannels")); +} + +function stateValue(context: RenderTemplateContext, path: string): MessagingSerializableValue | undefined { + const stateInput = context.inputs.find((input) => input.statePath === path); + if (stateInput?.value !== undefined) return stateInput.value; + const inputId = path.split(".").at(-1); + return context.inputs.find((input) => input.inputId === inputId)?.value; +} + +function parseList(value: MessagingSerializableValue | undefined): string[] { + if (Array.isArray(value)) return unique(value.map(String).map(cleanString).filter(Boolean)); + const text = cleanString(value); + if (!text) return []; + return unique(text.split(",").map(cleanString).filter(Boolean)); +} + +function parseBoolean(value: MessagingSerializableValue | undefined): boolean | undefined { + if (typeof value === "boolean") return value; + const text = cleanString(value)?.toLowerCase(); + if (text === "1" || text === "true" || text === "yes" || text === "on") return true; + if (text === "0" || text === "false" || text === "no" || text === "off") return false; + return undefined; +} + +function nonEmptyString(value: unknown): string | undefined { + return cleanString(value) || undefined; +} + +function cleanString(value: unknown): string { + return String(value ?? "").replace(/\r/g, "").trim(); +} + +function nonEmptyArray(values: readonly string[]): string[] | undefined { + return values.length > 0 ? [...values] : undefined; +} + +function nonEmptyCsv(values: readonly string[]): string | undefined { + return values.length > 0 ? values.join(",") : undefined; +} + +function nonEmptyObject>(value: T): T | undefined { + return Object.keys(value).length > 0 ? value : undefined; +} + function collectTemplateReferencesInString(value: MessagingTemplateString): string[] { return unique( [...value.matchAll(TEMPLATE_REFERENCE_PATTERN)] @@ -83,6 +329,6 @@ function collectTemplateReferencesInString(value: MessagingTemplateString): stri ); } -function unique(values: readonly string[]): string[] { +function unique(values: readonly T[]): T[] { return [...new Set(values)]; } diff --git a/src/lib/messaging/compiler/manifest-compiler.test.ts b/src/lib/messaging/compiler/manifest-compiler.test.ts index 82b5bf8c23..cea1406b40 100644 --- a/src/lib/messaging/compiler/manifest-compiler.test.ts +++ b/src/lib/messaging/compiler/manifest-compiler.test.ts @@ -183,18 +183,35 @@ describe("ManifestCompiler", () => { source: "manifest", }, ]); - expect(plan.agentRender.map((render) => `${render.channelId}:${render.kind}`)).toEqual([ - "telegram:json-fragment", - "telegram:json-fragment", - "discord:json-fragment", - "discord:json-fragment", - "slack:json-fragment", - "whatsapp:json-fragment", + expect(plan.agentRender.map((render) => `${render.channelId}:${render.renderId}`)).toEqual([ + "telegram:telegram-openclaw-channel", + "telegram:telegram-openclaw-groups", + "telegram:telegram-openclaw-plugin", + "discord:discord-openclaw-channel", + "discord:discord-openclaw-plugin", + "wechat:wechat-openclaw-plugin", + "slack:slack-openclaw-channel", + "slack:slack-openclaw-plugin", + "whatsapp:whatsapp-openclaw-channel", + "whatsapp:whatsapp-openclaw-plugin", ]); + expect(plan.agentRender.every((render) => render.handler === "common.staticOutputs")).toBe( + true, + ); expect(JSON.stringify(plan.agentRender)).toContain( "openshell:resolve:env:TELEGRAM_BOT_TOKEN", ); - expect(plan.buildSteps).toEqual([ + expect( + plan.buildSteps.map(({ value: _value, ...step }) => step), + ).toEqual([ + { + channelId: "discord", + kind: "package-install", + hookId: "discord-openclaw-package-install", + handler: "common.staticOutputs", + outputId: "openclawPluginPackage", + required: true, + }, { channelId: "wechat", kind: "build-file", @@ -219,7 +236,36 @@ describe("ManifestCompiler", () => { outputId: "openclawConfigPatch", required: true, }, + { + channelId: "slack", + kind: "package-install", + hookId: "slack-openclaw-package-install", + handler: "common.staticOutputs", + outputId: "openclawPluginPackage", + required: true, + }, + { + channelId: "whatsapp", + kind: "package-install", + hookId: "whatsapp-openclaw-package-install", + handler: "common.staticOutputs", + outputId: "openclawPluginPackage", + required: true, + }, ]); + expect(plan.buildSteps).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "package-install", + value: { + manager: "openclaw-plugin", + spec: "npm:@openclaw/discord@{{openclaw.version}}", + pin: true, + }, + }), + ]), + ); + expect(plan.buildSteps.every((step) => step.value !== undefined)).toBe(true); expect(plan.stateUpdates).toContainEqual({ channelId: "wechat", kind: "rebuild-hydration", @@ -237,7 +283,7 @@ describe("ManifestCompiler", () => { plan.agentRender.find( (render) => render.channelId === "telegram" && render.kind === "json-fragment", )?.templateRefs, - ).toEqual(expect.arrayContaining(["proxyUrl", "allowedIds.telegram.values"])); + ).toEqual([]); }); it("compiles Hermes render and manifest-owned WeChat policy intent", async () => { @@ -273,9 +319,13 @@ describe("ManifestCompiler", () => { "telegram:~/.hermes/config.yaml", "discord:~/.hermes/.env", "discord:~/.hermes/config.yaml", + "discord:~/.hermes/config.yaml", "wechat:~/.hermes/.env", + "wechat:~/.hermes/config.yaml", "slack:~/.hermes/.env", + "slack:~/.hermes/config.yaml", "whatsapp:~/.hermes/.env", + "whatsapp:~/.hermes/config.yaml", ]); expect(JSON.stringify(plan.agentRender)).toContain( "WEIXIN_TOKEN=openshell:resolve:env:WECHAT_BOT_TOKEN", @@ -316,7 +366,7 @@ describe("ManifestCompiler", () => { }); expect(plan.disabledChannels).toEqual(["wechat"]); expect(plan.networkPolicy.entries.map((entry) => entry.channelId)).toEqual(["wechat"]); - expect(plan.agentRender.map((render) => render.channelId)).toEqual(["wechat"]); + expect(plan.agentRender.map((render) => render.channelId)).toEqual(["wechat", "wechat"]); expect(plan.healthChecks.map((entry) => entry.channelId)).toEqual(["wechat"]); }); diff --git a/src/lib/messaging/compiler/manifest-compiler.ts b/src/lib/messaging/compiler/manifest-compiler.ts index 30a5a5d30a..27ae761b6c 100644 --- a/src/lib/messaging/compiler/manifest-compiler.ts +++ b/src/lib/messaging/compiler/manifest-compiler.ts @@ -1,12 +1,17 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { resolveMessagingChannelConfigEnvValue } from "../../messaging-channel-config"; import type { MessagingHookInputMap, MessagingHookOutputMap, MessagingHookRunResult, } from "../hooks"; import { MessagingHookRegistry, runMessagingHook } from "../hooks"; +import { + COMMON_STATIC_OUTPUTS_HOOK_HANDLER_ID, + createStaticOutputsHook, +} from "../hooks/common/static-outputs"; import type { ChannelHookOutputSpec, ChannelHookSpec, @@ -21,7 +26,6 @@ import type { SandboxMessagingInputReference, SandboxMessagingPlan, } from "../manifest"; -import { resolveMessagingChannelConfigEnvValue } from "../../messaging-channel-config"; import { planAgentRender } from "./engines/agent-render-engine"; import { planBuildSteps } from "./engines/build-step-engine"; import { planCredentialBindings } from "./engines/credential-binding-engine"; @@ -31,10 +35,14 @@ import { planStateUpdates } from "./engines/state-update-engine"; import type { ManifestCompilerContext } from "./types"; export class ManifestCompiler { + private readonly hooks: MessagingHookRegistry; + constructor( private readonly registry: ChannelManifestRegistry, - private readonly hooks = new MessagingHookRegistry(), - ) {} + hooks = new MessagingHookRegistry(), + ) { + this.hooks = ensureCommonCompilerHooks(hooks); + } async compile(context: ManifestCompilerContext): Promise { const manifests = this.resolveManifests(requestedChannelIds(context), context); @@ -52,12 +60,34 @@ export class ManifestCompiler { planCredentialBindings(manifest, context, inputRegistry.get(manifest.id) ?? []), ); const networkPolicy = planNetworkPolicy(manifests, context); - const agentRender = manifests.flatMap((manifest) => - planAgentRender(manifest, context), - ); - const buildSteps = manifests.flatMap((manifest) => - planBuildSteps(manifest, context.agent), + const agentRender = ( + await Promise.all( + manifests.map((manifest) => + planAgentRender( + manifest, + context, + inputRegistry.get(manifest.id) ?? [], + this.hooks, + ), + ), + ) + ).flat(); + const channelRegistry = new Map( + channels.map((channel) => [channel.channelId, channel] as const), ); + const buildSteps = ( + await Promise.all( + manifests.map((manifest) => + planBuildSteps( + manifest, + context.agent, + channelRegistry.get(manifest.id), + credentialBindings, + this.hooks, + ), + ), + ) + ).flat(); const stateUpdates = manifests.flatMap((manifest) => planStateUpdates(manifest)); const healthChecks = manifests.flatMap((manifest) => planHealthChecks(manifest)); @@ -142,6 +172,13 @@ export class ManifestCompiler { } } +function ensureCommonCompilerHooks(hooks: MessagingHookRegistry): MessagingHookRegistry { + if (!hooks.get(COMMON_STATIC_OUTPUTS_HOOK_HANDLER_ID)) { + hooks.register(COMMON_STATIC_OUTPUTS_HOOK_HANDLER_ID, createStaticOutputsHook()); + } + return hooks; +} + function isHookForAgent(hook: ChannelHookSpec, agent: ManifestCompilerContext["agent"]): boolean { return !hook.agents || hook.agents.includes(agent); } @@ -301,7 +338,9 @@ function readInputEnvValue(input: ChannelInputSpec): MessagingSerializableValue } const value = process.env[input.envKey]; const normalized = value?.replace(/\r/g, "").trim(); - return normalized && normalized.length > 0 ? normalized : undefined; + if (!normalized || normalized.length === 0) return undefined; + if (input.validValues && !input.validValues.includes(normalized)) return undefined; + return normalized; } function readInputStatePath(input: ChannelInputSpec): MessagingStatePath | undefined { diff --git a/src/lib/messaging/compiler/workflow-planner.test.ts b/src/lib/messaging/compiler/workflow-planner.test.ts index 9a07b5f307..cb2a64b86c 100644 --- a/src/lib/messaging/compiler/workflow-planner.test.ts +++ b/src/lib/messaging/compiler/workflow-planner.test.ts @@ -259,6 +259,25 @@ describe("MessagingWorkflowPlanner", () => { id: "slack.validateCredentials", handler: () => ({}), }, + { + id: "wechat.seedOpenClawAccount", + handler: () => ({ + outputs: { + openclawWeixinAccountsIndex: { + kind: "build-file", + value: { path: "openclaw-weixin/accounts.json", content: [] }, + }, + openclawWeixinAccountFile: { + kind: "build-file", + value: { path: "openclaw-weixin/accounts/cached-wechat-account.json", content: {} }, + }, + openclawConfigPatch: { + kind: "build-file", + value: { path: "openclaw.json", merge: {} }, + }, + }, + }), + }, ]); await withEnv( diff --git a/src/lib/messaging/hooks/common/index.ts b/src/lib/messaging/hooks/common/index.ts index 90c1b92f0b..2f09c0f845 100644 --- a/src/lib/messaging/hooks/common/index.ts +++ b/src/lib/messaging/hooks/common/index.ts @@ -1,16 +1,19 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { getCredential, prompt, saveCredential } from "../../../credentials/store"; +import type { MessagingHookRegistration } from "../types"; import { - createConfigPromptHookRegistration, type ConfigPromptHookOptions, + createConfigPromptHookRegistration, } from "./config-prompt"; +import { + createStaticOutputsHookRegistration, +} from "./static-outputs"; import { createTokenPasteHookRegistration, type TokenPasteHookOptions, } from "./token-paste"; -import { getCredential, prompt, saveCredential } from "../../../credentials/store"; -import type { MessagingHookRegistration } from "../types"; export interface CommonHookOptions extends TokenPasteHookOptions { readonly tokenPaste?: TokenPasteHookOptions; @@ -33,6 +36,7 @@ export function createCommonHookRegistrations( }; return [ + createStaticOutputsHookRegistration(), createTokenPasteHookRegistration(tokenPasteOptions), createConfigPromptHookRegistration(configPromptOptions), ] as const; @@ -84,4 +88,5 @@ function logMessage(message: string): void { } export * from "./config-prompt"; +export * from "./static-outputs"; export * from "./token-paste"; diff --git a/src/lib/messaging/hooks/common/static-outputs.ts b/src/lib/messaging/hooks/common/static-outputs.ts new file mode 100644 index 0000000000..2f3070d55d --- /dev/null +++ b/src/lib/messaging/hooks/common/static-outputs.ts @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { + MessagingHookHandler, + MessagingHookOutputMap, + MessagingHookRegistration, +} from "../types"; + +export const COMMON_STATIC_OUTPUTS_HOOK_HANDLER_ID = "common.staticOutputs"; + +export function createStaticOutputsHook(): MessagingHookHandler { + return (context) => { + const outputs: Record = {}; + for (const output of context.outputDeclarations ?? []) { + if (output.value === undefined) { + if (output.required) { + throw new Error( + `Static output hook '${context.hookId}' missing required value '${output.id}'`, + ); + } + continue; + } + outputs[output.id] = { + kind: output.kind, + value: output.value, + }; + } + return { outputs }; + }; +} + +export function createStaticOutputsHookRegistration(): MessagingHookRegistration { + return { + id: COMMON_STATIC_OUTPUTS_HOOK_HANDLER_ID, + handler: createStaticOutputsHook(), + }; +} diff --git a/src/lib/messaging/hooks/common/token-paste.test.ts b/src/lib/messaging/hooks/common/token-paste.test.ts index 67ec979214..05d967d4cf 100644 --- a/src/lib/messaging/hooks/common/token-paste.test.ts +++ b/src/lib/messaging/hooks/common/token-paste.test.ts @@ -4,24 +4,37 @@ import { describe, expect, it } from "vitest"; import { discordManifest, slackManifest, telegramManifest } from "../../channels"; +import type { ChannelManifest } from "../../manifest"; import { runMessagingHook } from "../hook-runner"; import { MessagingHookRegistry } from "../registry"; import { COMMON_CONFIG_PROMPT_HOOK_HANDLER_ID, + COMMON_STATIC_OUTPUTS_HOOK_HANDLER_ID, COMMON_TOKEN_PASTE_HOOK_HANDLER_ID, COMMON_HOOK_REGISTRATIONS, createTokenPasteHook, } from "./index"; +function findHookByHandler(manifest: ChannelManifest, handler: string) { + return manifest.hooks.find((hook) => hook.handler === handler); +} + describe("common token-paste hook implementation", () => { it("uses the shared handler id declared by token-paste channel manifests", () => { expect(COMMON_HOOK_REGISTRATIONS.map((registration) => registration.id)).toEqual([ + COMMON_STATIC_OUTPUTS_HOOK_HANDLER_ID, COMMON_TOKEN_PASTE_HOOK_HANDLER_ID, COMMON_CONFIG_PROMPT_HOOK_HANDLER_ID, ]); - expect(telegramManifest.hooks[0]?.handler).toBe(COMMON_TOKEN_PASTE_HOOK_HANDLER_ID); - expect(discordManifest.hooks[0]?.handler).toBe(COMMON_TOKEN_PASTE_HOOK_HANDLER_ID); - expect(slackManifest.hooks[0]?.handler).toBe(COMMON_TOKEN_PASTE_HOOK_HANDLER_ID); + expect(findHookByHandler(telegramManifest, COMMON_TOKEN_PASTE_HOOK_HANDLER_ID)?.handler).toBe( + COMMON_TOKEN_PASTE_HOOK_HANDLER_ID, + ); + expect(findHookByHandler(discordManifest, COMMON_TOKEN_PASTE_HOOK_HANDLER_ID)?.handler).toBe( + COMMON_TOKEN_PASTE_HOOK_HANDLER_ID, + ); + expect(findHookByHandler(slackManifest, COMMON_TOKEN_PASTE_HOOK_HANDLER_ID)?.handler).toBe( + COMMON_TOKEN_PASTE_HOOK_HANDLER_ID, + ); }); it("requires an injected prompt when no env or credential value is available", async () => { @@ -91,7 +104,7 @@ describe("common token-paste hook implementation", () => { }), }, ]); - const hook = slackManifest.hooks[0]; + const hook = findHookByHandler(slackManifest, COMMON_TOKEN_PASTE_HOOK_HANDLER_ID); if (!hook) throw new Error("missing Slack token-paste hook"); diff --git a/src/lib/messaging/hooks/hook-runner.test.ts b/src/lib/messaging/hooks/hook-runner.test.ts index afd70080e1..2067f8559c 100644 --- a/src/lib/messaging/hooks/hook-runner.test.ts +++ b/src/lib/messaging/hooks/hook-runner.test.ts @@ -33,6 +33,7 @@ describe("MessagingHookRegistry", () => { const registry = createBuiltInMessagingHookRegistry(); expect(registry.listIds()).toEqual([ + "common.staticOutputs", "common.tokenPaste", "common.configPrompt", "slack.validateCredentials", @@ -44,6 +45,43 @@ describe("MessagingHookRegistry", () => { ]); }); + it("returns declared static outputs for manifest-owned build and render hooks", async () => { + const registry = createBuiltInMessagingHookRegistry(); + const hook = { + id: "discord-openclaw-package-install", + phase: "agent-install", + handler: "common.staticOutputs", + outputs: [ + { + id: "openclawPluginPackage", + kind: "package-install", + required: true, + value: { + manager: "openclaw-plugin", + spec: "npm:@openclaw/discord@{{openclaw.version}}", + pin: true, + }, + }, + ], + } as const satisfies ChannelHookSpec; + + await expect(runMessagingHook(hook, registry, { channelId: "discord" })).resolves.toEqual({ + hookId: "discord-openclaw-package-install", + handlerId: "common.staticOutputs", + phase: "agent-install", + outputs: { + openclawPluginPackage: { + kind: "package-install", + value: { + manager: "openclaw-plugin", + spec: "npm:@openclaw/discord@{{openclaw.version}}", + pin: true, + }, + }, + }, + }); + }); + it("registers handlers by stable handler id", async () => { const registry = new MessagingHookRegistry([ { diff --git a/src/lib/messaging/manifest/types.ts b/src/lib/messaging/manifest/types.ts index 5774a81957..2f6a81f954 100644 --- a/src/lib/messaging/manifest/types.ts +++ b/src/lib/messaging/manifest/types.ts @@ -116,6 +116,7 @@ interface ChannelRenderBaseSpec { readonly id?: string; readonly agent: MessagingAgentId; readonly target: string; + readonly when?: MessagingTemplateString; } /** JSON fragment a compiler can merge into an agent config file. */ @@ -152,6 +153,8 @@ export interface ChannelRebuildHydrationSpec { export type ChannelHookPhase = | "enroll" | "reachability-check" + | "agent-install" + | "render" | "apply" | "post-agent-install" | "health-check" @@ -175,8 +178,15 @@ export interface ChannelHookSpec { /** Output shape a hook promises, without embedding hook implementation details. */ export interface ChannelHookOutputSpec { readonly id: string; - readonly kind: "secret" | "config" | "build-arg" | "build-file"; + readonly kind: + | "secret" + | "config" + | "build-arg" + | "build-file" + | "package-install" + | "agent-render"; readonly required?: boolean; + readonly value?: MessagingSerializableValue; } /** Serializable compiled plan for all selected messaging channels. */ @@ -267,6 +277,8 @@ export type SandboxMessagingRenderFragmentPlan = SandboxMessagingAgentRenderPlan interface SandboxMessagingAgentRenderBasePlan { readonly channelId: MessagingChannelId; readonly renderId?: string; + readonly hookId?: string; + readonly handler?: string; readonly agent: MessagingAgentId; readonly target: string; } @@ -291,7 +303,8 @@ export interface SandboxMessagingEnvLinesRenderPlan /** Build-time input the applier may pass into sandbox create/rebuild. */ export type SandboxMessagingBuildStepPlan = | SandboxMessagingBuildArgStepPlan - | SandboxMessagingBuildFileStepPlan; + | SandboxMessagingBuildFileStepPlan + | SandboxMessagingPackageInstallStepPlan; /** Compatibility alias for older phase-1 tests and callers. */ export type SandboxMessagingBuildInputPlan = SandboxMessagingBuildStepPlan; @@ -300,20 +313,33 @@ export type SandboxMessagingBuildInputPlan = SandboxMessagingBuildStepPlan; export interface SandboxMessagingBuildArgStepPlan { readonly channelId: MessagingChannelId; readonly kind: "build-arg"; - readonly hookId: string; - readonly handler: string; + readonly hookId?: string; + readonly handler?: string; readonly outputId: string; readonly required: boolean; + readonly value?: MessagingSerializableValue; } /** File planned for the sandbox build context, optionally sourced from a hook. */ export interface SandboxMessagingBuildFileStepPlan { readonly channelId: MessagingChannelId; readonly kind: "build-file"; - readonly hookId: string; - readonly handler: string; + readonly hookId?: string; + readonly handler?: string; readonly outputId: string; readonly required: boolean; + readonly value?: MessagingSerializableValue; +} + +/** Agent package install planned for the sandbox image build. */ +export interface SandboxMessagingPackageInstallStepPlan { + readonly channelId: MessagingChannelId; + readonly kind: "package-install"; + readonly hookId?: string; + readonly handler?: string; + readonly outputId: string; + readonly required: boolean; + readonly value?: MessagingSerializableValue; } /** Hook reference carried into a compiled plan. */ diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index b31d0f12f2..32cb2e0a5d 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -86,7 +86,13 @@ const { hasWechatConfigDrift, toSessionWechatConfig, } = require("./onboard/wechat-config") as typeof import("./onboard/wechat-config"); -const { setupMessagingChannels: setupMessagingChannelsImpl, readMessagingPlanFromEnv, writePlanToEnv, getRegistrySandboxMessagingPlan, MessagingHostStateApplier } = require("./onboard/messaging-channel-setup") as typeof import("./onboard/messaging-channel-setup"); +const { + setupMessagingChannels: setupMessagingChannelsImpl, + readMessagingPlanFromEnv, + writePlanToEnv, + getRegistrySandboxMessagingPlan, + MessagingHostStateApplier, +} = require("./onboard/messaging-channel-setup") as typeof import("./onboard/messaging-channel-setup"); const { clearAgentScopedResumeState, }: typeof import("./onboard/agent-resume-state") = require("./onboard/agent-resume-state"); @@ -370,7 +376,6 @@ const { getRecordedMessagingChannelsForResume: getRecordedMessagingChannelsForResumeFromState, }: typeof import("./onboard/messaging-credentials") = require("./onboard/messaging-credentials"); const { - collectMessagingBuildConfig, computeTelegramRequireMention, getStoredMessagingChannelConfig, messagingChannelConfigsEqual, @@ -846,7 +851,12 @@ function upsertProvider( return result; } -type MessagingTokenDef = { name: string; envKey: string; token: string | null; providerType?: string }; +type MessagingTokenDef = { + name: string; + envKey: string; + token: string | null; + providerType?: string; +}; type EndpointValidationResult = | { ok: true; api: string | null; retry?: undefined } @@ -2903,27 +2913,7 @@ async function createSandbox( } if (braveWebSearchEnabled) messagingTokenDefs.push({ name: `${sandboxName}-brave-search`, envKey: webSearch.BRAVE_API_KEY_ENV, token: braveApiKey, providerType: braveProviderProfile.BRAVE_PROVIDER_PROFILE_ID }); const extraPlaceholderKeys: string[] = require("./onboard/extra-placeholder-keys").registerExtraPlaceholderProviders(sandboxName, messagingTokenDefs); - const previousProviderCredentialHashes = - registry.getSandbox(sandboxName)?.providerCredentialHashes ?? {}; const hasMessagingTokens = messagingTokenDefs.some(({ token }) => !!token); - const reusableMessagingProviders: string[] = []; - const reusableMessagingChannels: string[] = []; - const reusableMessagingEnvKeys = new Set(); - if (enabledChannels != null) { - for (const { name, envKey, token } of messagingTokenDefs) { - if (token) continue; - const channel = - envKey === "SLACK_APP_TOKEN" ? "slack" : getMessagingChannelForEnvKey(envKey); - if (!channel || !enabledChannels.includes(channel)) continue; - if (!providerExistsInGateway(name)) continue; - reusableMessagingProviders.push(name); - reusableMessagingEnvKeys.add(envKey); - if (!reusableMessagingChannels.includes(channel)) { - reusableMessagingChannels.push(channel); - } - } - } - const existingRegistryEntryBeforePrune = registry.getSandbox(sandboxName); // Reconcile local registry state with the live OpenShell gateway state. @@ -3333,7 +3323,6 @@ async function createSandbox( } return []; }), - ...reusableMessagingChannels, ...qrSelectedChannels, ]), ]; @@ -3376,12 +3365,7 @@ async function createSandbox( // attached providers are detached and safe to delete+create. That's // required for the legacy Brave generic→brave type migration since // `openshell provider update` cannot change `--type` (#3626). - const messagingProviders = [ - ...new Set([ - ...upsertMessagingProviders(messagingTokenDefs, { replaceExisting: true }), - ...reusableMessagingProviders, - ]), - ]; + const messagingProviders = upsertMessagingProviders(messagingTokenDefs, { replaceExisting: true }); for (const p of messagingProviders) { createArgs.push("--provider", p); } @@ -3392,19 +3376,11 @@ async function createSandbox( console.log(` Creating sandbox '${sandboxName}' (this takes a few minutes on first run)...`); const messagingChannelConfig = readMessagingChannelConfigFromEnv(); - const enabledTokenEnvKeys = new Set(messagingTokenDefs.map(({ envKey }) => envKey)); - const activeChannelNames = new Set(activeMessagingChannels); - const { messagingAllowedIds, discordGuilds, slackConfig } = collectMessagingBuildConfig({ - channels: MESSAGING_CHANNELS, - activeChannelNames, - enabledTokenEnvKeys, - discordSnowflakeRe: onboardProviders.DISCORD_SNOWFLAKE_RE, - }); // Telegram mention-only mode — parity with Discord's requireMention. // Off by default so existing sandboxes behave the same; opt-in via // TELEGRAM_REQUIRE_MENTION=1 or the interactive prompt. See #1737. const telegramConfig: { requireMention?: boolean } = {}; - if (enabledTokenEnvKeys.has("TELEGRAM_BOT_TOKEN")) { + if (activeMessagingChannels.includes("telegram")) { const telegramRequireMention = computeTelegramRequireMention(); if (telegramRequireMention !== null) { telegramConfig.requireMention = telegramRequireMention; @@ -3468,18 +3444,12 @@ async function createSandbox( provider, preferredInferenceApi, webSearchConfig, - activeMessagingChannels, - messagingAllowedIds, - discordGuilds, resolved ? resolved.ref : null, - telegramConfig, - wechatConfig as Record, // Docker-on-Colima uses normal container ownership; keep the old VM chmod // compatibility path disabled unless a future VM-specific flow opts in. false, sandboxInferenceBaseUrlOverride, hermesToolGateways, - slackConfig, ); // Only pass non-sensitive env vars to the sandbox. Credentials flow through // OpenShell providers — the gateway injects them as placeholders and the L7 @@ -3714,12 +3684,6 @@ async function createSandbox( providerCredentialHashes[envKey] = hash; } } - for (const envKey of reusableMessagingEnvKeys) { - const previousHash = previousProviderCredentialHashes[envKey]; - if (typeof previousHash === "string" && previousHash) { - providerCredentialHashes[envKey] = previousHash; - } - } // openshell tags images with seconds; buildId is ms. Parse actual tag from output. Fixes #2672. const resolvedImageTag = resolveSandboxImageTagFromCreateOutput(createResult.output, buildId); diff --git a/src/lib/onboard/dockerfile-patch.test.ts b/src/lib/onboard/dockerfile-patch.test.ts index 85e3057668..cd8893ff37 100644 --- a/src/lib/onboard/dockerfile-patch.test.ts +++ b/src/lib/onboard/dockerfile-patch.test.ts @@ -18,6 +18,7 @@ import { const tmpRoots: string[] = []; beforeEach(() => { + delete process.env.NEMOCLAW_MESSAGING_PLAN_B64; delete process.env.NEMOCLAW_OPENCLAW_OTEL; delete process.env.NEMOCLAW_OPENCLAW_OTEL_ENDPOINT; delete process.env.NEMOCLAW_OPENCLAW_OTEL_SERVICE_NAME; @@ -32,10 +33,48 @@ function dockerfileWith(content: string): string { return file; } +type TestMessagingPlan = Record; + +function buildMessagingPlan(overrides: TestMessagingPlan = {}): TestMessagingPlan { + return { + schemaVersion: 1, + sandboxName: "my-assistant", + agent: "openclaw", + workflow: "onboard", + channels: [], + disabledChannels: [], + credentialBindings: [], + networkPolicy: { presets: [], entries: [] }, + agentRender: [], + buildSteps: [], + stateUpdates: [], + healthChecks: [], + ...overrides, + }; +} + +function setMessagingPlanEnv(overrides: TestMessagingPlan = {}): TestMessagingPlan { + const plan = buildMessagingPlan(overrides); + process.env.NEMOCLAW_MESSAGING_PLAN_B64 = Buffer.from(JSON.stringify(plan), "utf8").toString( + "base64", + ); + return plan; +} + +function readMessagingPlanArg(dockerfile: string): unknown { + const line = dockerfile + .split("\n") + .find((entry) => entry.startsWith("ARG NEMOCLAW_MESSAGING_PLAN_B64=")); + assert.ok(line, "expected messaging plan build arg"); + const prefix = "ARG NEMOCLAW_MESSAGING_PLAN_B64="; + return JSON.parse(Buffer.from(line.slice(prefix.length), "base64").toString("utf8")); +} + afterEach(() => { for (const dir of tmpRoots.splice(0)) { fs.rmSync(dir, { recursive: true, force: true }); } + delete process.env.NEMOCLAW_MESSAGING_PLAN_B64; delete process.env.NEMOCLAW_PROXY_HOST; delete process.env.NEMOCLAW_PROXY_PORT; delete process.env.NEMOCLAW_OPENCLAW_OTEL; @@ -93,27 +132,25 @@ describe("dockerfile patch helpers", () => { "compatible-endpoint", null, null, - [], - {}, - {}, null, - {}, - {}, false, null, [], - {}, ), ).toThrow(/Dockerfile is missing ARG NEMOCLAW_OPENCLAW_OTEL_ENDPOINT/); }); - it("patches base image, inference, proxy, and messaging args", () => { + it("patches base image, inference, proxy, and messaging plan args", () => { process.env.NEMOCLAW_PROXY_HOST = "host.docker.internal"; process.env.NEMOCLAW_PROXY_PORT = "3128"; process.env.NEMOCLAW_OPENCLAW_OTEL = "1"; process.env.NEMOCLAW_OPENCLAW_OTEL_ENDPOINT = "http://host.openshell.internal:4318"; process.env.NEMOCLAW_OPENCLAW_OTEL_SERVICE_NAME = "nemoclaw-local"; process.env.NEMOCLAW_OPENCLAW_OTEL_SAMPLE_RATE = "0.5"; + const messagingPlan = setMessagingPlanEnv({ + channels: [{ channelId: "telegram", active: true }], + buildSteps: [{ channelId: "telegram", kind: "build-arg", target: "openclaw" }], + }); const dockerfilePath = dockerfileWith( [ "ARG BASE_IMAGE=ghcr.io/nvidia/nemoclaw/sandbox-base:latest", @@ -134,11 +171,7 @@ describe("dockerfile patch helpers", () => { "ARG NEMOCLAW_OPENCLAW_OTEL_SERVICE_NAME=old", "ARG NEMOCLAW_OPENCLAW_OTEL_SAMPLE_RATE=old", "ARG NEMOCLAW_DISABLE_DEVICE_AUTH=0", - "ARG NEMOCLAW_MESSAGING_CHANNELS_B64=old", - "ARG NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=old", - "ARG NEMOCLAW_DISCORD_GUILDS_B64=old", - "ARG NEMOCLAW_TELEGRAM_CONFIG_B64=old", - "ARG NEMOCLAW_SLACK_CONFIG_B64=old", + "ARG NEMOCLAW_MESSAGING_PLAN_B64=old", ].join("\n"), ); @@ -150,16 +183,10 @@ describe("dockerfile patch helpers", () => { "compatible-endpoint", null, { fetchEnabled: true }, - ["telegram"], - { telegram: ["123"] }, - { discord: ["456"] }, "ghcr.io/nvidia/nemoclaw/sandbox-base@sha256:abc", - { requireMention: true }, - {}, true, null, [], - { allowedChannels: ["C012AB3CD", "C987ZY6XW"] }, ); const patched = fs.readFileSync(dockerfilePath, "utf-8"); @@ -181,15 +208,7 @@ describe("dockerfile patch helpers", () => { expect(patched).toContain("ARG NEMOCLAW_OPENCLAW_OTEL_SERVICE_NAME=nemoclaw-local"); expect(patched).toContain("ARG NEMOCLAW_OPENCLAW_OTEL_SAMPLE_RATE=0.5"); expect(patched).toContain("ARG NEMOCLAW_DISABLE_DEVICE_AUTH=1"); - expect(patched).not.toContain("ARG NEMOCLAW_MESSAGING_CHANNELS_B64=old"); - expect(patched).not.toContain("ARG NEMOCLAW_TELEGRAM_CONFIG_B64=old"); - const slackLine = patched - .split("\n") - .find((line) => line.startsWith("ARG NEMOCLAW_SLACK_CONFIG_B64=")); - assert.ok(slackLine, "expected slack config build arg"); - assert.deepEqual(JSON.parse(Buffer.from(slackLine.split("=")[1], "base64").toString("utf8")), { - allowedChannels: ["C012AB3CD", "C987ZY6XW"], - }); + assert.deepEqual(readMessagingPlanArg(patched), messagingPlan); }); it("uses the shared sandbox inference mapping", () => { @@ -248,12 +267,7 @@ describe("dockerfile patch helpers", () => { "ollama-local", null, null, - [], - {}, - {}, null, - {}, - {}, false, "http://127.0.0.1:11434/v1", ); @@ -291,9 +305,6 @@ describe("dockerfile patch helpers", () => { "compatible-endpoint", "openai-responses\nRUN touch /tmp/api-pwn", null, - [], - {}, - {}, "ghcr.io/nvidia/nemoclaw/sandbox-base@sha256:abc\nRUN touch /tmp/base-pwn", ); @@ -370,12 +381,7 @@ describe("dockerfile patch helpers", () => { "openai-api", null, null, - [], - {}, - {}, null, - {}, - {}, true, ); const patched = fs.readFileSync(dockerfilePath, "utf8"); @@ -385,167 +391,17 @@ describe("dockerfile patch helpers", () => { } }); - it("patches the staged Dockerfile with Discord guild config for server workspaces", () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-dockerfile-discord-")); - const dockerfilePath = path.join(tmpDir, "Dockerfile"); - fs.writeFileSync( - dockerfilePath, - [ - "ARG NEMOCLAW_MODEL=nvidia/nemotron-3-super-120b-a12b", - "ARG NEMOCLAW_PROVIDER_KEY=nvidia", - "ARG NEMOCLAW_PRIMARY_MODEL_REF=nvidia/nemotron-3-super-120b-a12b", - "ARG CHAT_UI_URL=http://127.0.0.1:18789", - "ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30=", - "ARG NEMOCLAW_WEB_SEARCH_ENABLED=0", - "ARG NEMOCLAW_MESSAGING_CHANNELS_B64=W10=", - "ARG NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=e30=", - "ARG NEMOCLAW_DISCORD_GUILDS_B64=e30=", - "ARG NEMOCLAW_BUILD_ID=default", - ].join("\n"), - ); - - try { - patchStagedDockerfile( - dockerfilePath, - "gpt-5.4", - "http://127.0.0.1:19999", - "build-discord-guild", - "openai-api", - null, - null, - ["discord"], - {}, - { - "1491590992753590594": { - requireMention: true, - users: ["1005536447329222676"], - }, - }, - ); - const patched = fs.readFileSync(dockerfilePath, "utf8"); - assert.match(patched, /^ARG NEMOCLAW_MESSAGING_CHANNELS_B64=/m); - const guildLine = patched - .split("\n") - .find((line) => line.startsWith("ARG NEMOCLAW_DISCORD_GUILDS_B64=")); - assert.ok(guildLine, "expected discord guild build arg"); - const encoded = guildLine.split("=")[1]; - const decoded = JSON.parse(Buffer.from(encoded, "base64").toString("utf8")); - assert.deepEqual(decoded, { - "1491590992753590594": { - requireMention: true, - users: ["1005536447329222676"], - }, - }); - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } - }); - - it("patches the staged Dockerfile with Discord guild config that allows all server members", () => { - const tmpDir = fs.mkdtempSync( - path.join(os.tmpdir(), "nemoclaw-onboard-dockerfile-discord-open-"), - ); - const dockerfilePath = path.join(tmpDir, "Dockerfile"); - fs.writeFileSync( - dockerfilePath, - [ - "ARG NEMOCLAW_MODEL=nvidia/nemotron-3-super-120b-a12b", - "ARG NEMOCLAW_PROVIDER_KEY=nvidia", - "ARG NEMOCLAW_PRIMARY_MODEL_REF=nvidia/nemotron-3-super-120b-a12b", - "ARG CHAT_UI_URL=http://127.0.0.1:18789", - "ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30=", - "ARG NEMOCLAW_WEB_SEARCH_ENABLED=0", - "ARG NEMOCLAW_MESSAGING_CHANNELS_B64=W10=", - "ARG NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=e30=", - "ARG NEMOCLAW_DISCORD_GUILDS_B64=e30=", - "ARG NEMOCLAW_BUILD_ID=default", - ].join("\n"), - ); - - try { - patchStagedDockerfile( - dockerfilePath, - "gpt-5.4", - "http://127.0.0.1:19999", - "build-discord-open", - "openai-api", - null, - null, - ["discord"], - {}, - { - "1491590992753590594": { - requireMention: false, - }, - }, - ); - const patched = fs.readFileSync(dockerfilePath, "utf8"); - const guildLine = patched - .split("\n") - .find((line) => line.startsWith("ARG NEMOCLAW_DISCORD_GUILDS_B64=")); - assert.ok(guildLine, "expected discord guild build arg"); - const encoded = guildLine.split("=")[1]; - const decoded = JSON.parse(Buffer.from(encoded, "base64").toString("utf8")); - assert.deepEqual(decoded, { - "1491590992753590594": { - requireMention: false, - }, - }); - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } - }); - - it("#1737: patches the staged Dockerfile with Telegram mention-only config", () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-dockerfile-tg-mention-")); - const dockerfilePath = path.join(tmpDir, "Dockerfile"); - fs.writeFileSync( - dockerfilePath, - [ - "ARG NEMOCLAW_MODEL=nvidia/nemotron-3-super-120b-a12b", - "ARG NEMOCLAW_PROVIDER_KEY=nvidia", - "ARG NEMOCLAW_PRIMARY_MODEL_REF=nvidia/nemotron-3-super-120b-a12b", - "ARG CHAT_UI_URL=http://127.0.0.1:18789", - "ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30=", - "ARG NEMOCLAW_WEB_SEARCH_ENABLED=0", - "ARG NEMOCLAW_MESSAGING_CHANNELS_B64=W10=", - "ARG NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=e30=", - "ARG NEMOCLAW_DISCORD_GUILDS_B64=e30=", - "ARG NEMOCLAW_TELEGRAM_CONFIG_B64=e30=", - "ARG NEMOCLAW_BUILD_ID=default", - ].join("\n"), - ); - - try { - patchStagedDockerfile( - dockerfilePath, - "gpt-5.4", - "http://127.0.0.1:19999", - "build-tg-mention", - "openai-api", - null, - null, - ["telegram"], - {}, - {}, - null, - { requireMention: true }, - ); - const patched = fs.readFileSync(dockerfilePath, "utf8"); - const line = patched - .split("\n") - .find((l) => l.startsWith("ARG NEMOCLAW_TELEGRAM_CONFIG_B64=")); - assert.ok(line, "expected telegram config build arg"); - const encoded = line.split("=")[1]; - const decoded = JSON.parse(Buffer.from(encoded, "base64").toString("utf8")); - assert.deepEqual(decoded, { requireMention: true }); - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } - }); - - it("#1737: patches the staged Dockerfile with Telegram open-group config when requireMention=false", () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-dockerfile-tg-open-")); + it("patches the staged Dockerfile with the manifest messaging plan", () => { + const messagingPlan = setMessagingPlanEnv({ + channels: [ + { channelId: "discord", active: true }, + { channelId: "telegram", active: true }, + ], + agentRender: [ + { channelId: "discord", target: "openclaw.json", path: ["channels", "discord"] }, + ], + }); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-dockerfile-plan-")); const dockerfilePath = path.join(tmpDir, "Dockerfile"); fs.writeFileSync( dockerfilePath, @@ -556,10 +412,7 @@ describe("dockerfile patch helpers", () => { "ARG CHAT_UI_URL=http://127.0.0.1:18789", "ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30=", "ARG NEMOCLAW_WEB_SEARCH_ENABLED=0", - "ARG NEMOCLAW_MESSAGING_CHANNELS_B64=W10=", - "ARG NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=e30=", - "ARG NEMOCLAW_DISCORD_GUILDS_B64=e30=", - "ARG NEMOCLAW_TELEGRAM_CONFIG_B64=e30=", + "ARG NEMOCLAW_MESSAGING_PLAN_B64=old", "ARG NEMOCLAW_BUILD_ID=default", ].join("\n"), ); @@ -569,38 +422,24 @@ describe("dockerfile patch helpers", () => { dockerfilePath, "gpt-5.4", "http://127.0.0.1:19999", - "build-tg-open", + "build-manifest-plan", "openai-api", null, null, - ["telegram"], - {}, - {}, - null, - { requireMention: false }, ); const patched = fs.readFileSync(dockerfilePath, "utf8"); - const line = patched - .split("\n") - .find((l) => l.startsWith("ARG NEMOCLAW_TELEGRAM_CONFIG_B64=")); - assert.ok(line, "expected telegram config build arg"); - const encoded = line.split("=")[1]; - const decoded = JSON.parse(Buffer.from(encoded, "base64").toString("utf8")); - assert.deepEqual(decoded, { requireMention: false }); + assert.deepEqual(readMessagingPlanArg(patched), messagingPlan); + assert.doesNotMatch(patched, /NEMOCLAW_MESSAGING_CHANNELS_B64/); + assert.doesNotMatch(patched, /NEMOCLAW_DISCORD_GUILDS_B64/); + assert.doesNotMatch(patched, /NEMOCLAW_TELEGRAM_CONFIG_B64/); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } }); - it("#1737: preserves default Telegram group-open behavior when telegramConfig is empty", () => { - // Backward compatibility guard: the ARG default stays at e30= ({} base64) - // and patchStagedDockerfile does not rewrite it when no config is passed. - // The Dockerfile Python generator reads empty config as requireMention=false - // which maps to groupPolicy=open (matches pre-#1737 behavior). - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-dockerfile-tg-empty-")); - const dockerfilePath = path.join(tmpDir, "Dockerfile"); - fs.writeFileSync( - dockerfilePath, + it("fails when a messaging plan exists but the staged Dockerfile has no manifest ARG", () => { + setMessagingPlanEnv({ channels: [{ channelId: "telegram", active: true }] }); + const dockerfilePath = dockerfileWith( [ "ARG NEMOCLAW_MODEL=nvidia/nemotron-3-super-120b-a12b", "ARG NEMOCLAW_PROVIDER_KEY=nvidia", @@ -608,34 +447,19 @@ describe("dockerfile patch helpers", () => { "ARG CHAT_UI_URL=http://127.0.0.1:18789", "ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30=", "ARG NEMOCLAW_WEB_SEARCH_ENABLED=0", - "ARG NEMOCLAW_MESSAGING_CHANNELS_B64=W10=", - "ARG NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=e30=", - "ARG NEMOCLAW_DISCORD_GUILDS_B64=e30=", - "ARG NEMOCLAW_TELEGRAM_CONFIG_B64=e30=", "ARG NEMOCLAW_BUILD_ID=default", ].join("\n"), ); - try { + expect(() => patchStagedDockerfile( dockerfilePath, "gpt-5.4", "http://127.0.0.1:19999", - "build-tg-default", + "build-missing-plan-arg", "openai-api", - null, - null, - ["telegram"], - {}, - {}, - null, - {}, - ); - const patched = fs.readFileSync(dockerfilePath, "utf8"); - assert.match(patched, /^ARG NEMOCLAW_TELEGRAM_CONFIG_B64=e30=$/m); - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } + ), + ).toThrow(/missing ARG NEMOCLAW_MESSAGING_PLAN_B64/); }); it("patchStagedDockerfile rewrites ARG BASE_IMAGE when baseImageRef is provided", () => { @@ -666,9 +490,6 @@ describe("dockerfile patch helpers", () => { "openai-api", null, null, - [], - {}, - {}, fakeRef, ); const patched = fs.readFileSync(dockerfilePath, "utf8"); @@ -709,9 +530,6 @@ describe("dockerfile patch helpers", () => { "openai-api", null, null, - [], - {}, - {}, null, ); const patched = fs.readFileSync(dockerfilePath, "utf8"); @@ -752,9 +570,6 @@ describe("dockerfile patch helpers", () => { "openai-api", null, null, - [], - {}, - {}, fakeRef, ); const patched = fs.readFileSync(dockerfilePath, "utf8"); @@ -802,9 +617,6 @@ describe("dockerfile patch helpers", () => { "openai-api", null, null, - [], - {}, - {}, correctRef, ); const patched = fs.readFileSync(dockerfilePath, "utf8"); @@ -852,9 +664,6 @@ describe("dockerfile patch helpers", () => { "openai-api", null, null, - [], - {}, - {}, sandboxRef, ); const patched = fs.readFileSync(dockerfilePath, "utf8"); diff --git a/src/lib/onboard/dockerfile-patch.ts b/src/lib/onboard/dockerfile-patch.ts index 27f4fcb0e9..cb57023e5f 100644 --- a/src/lib/onboard/dockerfile-patch.ts +++ b/src/lib/onboard/dockerfile-patch.ts @@ -5,12 +5,12 @@ import fs from "node:fs"; import { getSandboxInferenceConfig } from "../inference/config"; import type { WebSearchConfig } from "../inference/web-search"; +import { MessagingSetupApplier } from "../messaging"; const SANDBOX_BASE_IMAGE = "ghcr.io/nvidia/nemoclaw/sandbox-base"; const PROXY_HOST_RE = /^[A-Za-z0-9._-]+$/; const POSITIVE_INT_RE = /^[1-9][0-9]*$/; -type LooseObject = Record; export function encodeDockerJsonArg(value: unknown): string { return Buffer.from(JSON.stringify(value ?? {}), "utf8").toString("base64"); @@ -42,16 +42,10 @@ export function patchStagedDockerfile( provider: string | null = null, preferredInferenceApi: string | null = null, webSearchConfig: WebSearchConfig | null = null, - messagingChannels: string[] = [], - messagingAllowedIds: LooseObject = {}, - discordGuilds: LooseObject = {}, baseImageRef: string | null = null, - telegramConfig: LooseObject = {}, - wechatConfig: LooseObject = {}, darwinVmCompat = false, inferenceBaseUrlOverride: string | null = null, hermesToolGateways: string[] = [], - slackConfig: LooseObject = {}, ): void { const sanitizedModel = sanitizeDockerArg(model); const sandboxInference = getSandboxInferenceConfig( @@ -219,40 +213,15 @@ export function patchStagedDockerfile( /^ARG NEMOCLAW_DISABLE_DEVICE_AUTH=.*$/m, `ARG NEMOCLAW_DISABLE_DEVICE_AUTH=${sanitizeDockerArg("1")}`, ); - if (messagingChannels.length > 0) { - dockerfile = dockerfile.replace( - /^ARG NEMOCLAW_MESSAGING_CHANNELS_B64=.*$/m, - `ARG NEMOCLAW_MESSAGING_CHANNELS_B64=${encodeSanitizedDockerJsonArg(messagingChannels)}`, - ); - } - if (Object.keys(messagingAllowedIds).length > 0) { - dockerfile = dockerfile.replace( - /^ARG NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=.*$/m, - `ARG NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=${encodeSanitizedDockerJsonArg(messagingAllowedIds)}`, - ); - } - if (Object.keys(discordGuilds).length > 0) { - dockerfile = dockerfile.replace( - /^ARG NEMOCLAW_DISCORD_GUILDS_B64=.*$/m, - `ARG NEMOCLAW_DISCORD_GUILDS_B64=${encodeSanitizedDockerJsonArg(discordGuilds)}`, - ); - } - if (telegramConfig && Object.keys(telegramConfig).length > 0) { - dockerfile = dockerfile.replace( - /^ARG NEMOCLAW_TELEGRAM_CONFIG_B64=.*$/m, - `ARG NEMOCLAW_TELEGRAM_CONFIG_B64=${encodeSanitizedDockerJsonArg(telegramConfig)}`, - ); - } - if (wechatConfig && Object.keys(wechatConfig).length > 0) { - dockerfile = dockerfile.replace( - /^ARG NEMOCLAW_WECHAT_CONFIG_B64=.*$/m, - `ARG NEMOCLAW_WECHAT_CONFIG_B64=${encodeSanitizedDockerJsonArg(wechatConfig)}`, - ); - } - if (slackConfig && Object.keys(slackConfig).length > 0) { + const messagingPlan = MessagingSetupApplier.readPlanFromEnv(); + if (messagingPlan) { + const messagingPlanArgPattern = /^ARG NEMOCLAW_MESSAGING_PLAN_B64=.*$/m; + if (!messagingPlanArgPattern.test(dockerfile)) { + throw new Error("Dockerfile is missing ARG NEMOCLAW_MESSAGING_PLAN_B64; cannot apply messaging plan."); + } dockerfile = dockerfile.replace( - /^ARG NEMOCLAW_SLACK_CONFIG_B64=.*$/m, - `ARG NEMOCLAW_SLACK_CONFIG_B64=${encodeSanitizedDockerJsonArg(slackConfig)}`, + messagingPlanArgPattern, + `ARG NEMOCLAW_MESSAGING_PLAN_B64=${sanitizeDockerArg(MessagingSetupApplier.encodePlan(messagingPlan))}`, ); } if (hermesToolGateways.length > 0) { diff --git a/src/lib/onboard/messaging-config.test.ts b/src/lib/onboard/messaging-config.test.ts deleted file mode 100644 index c38b882281..0000000000 --- a/src/lib/onboard/messaging-config.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { describe, expect, it, vi } from "vitest"; - -import { collectMessagingBuildConfig, parseMessagingConfigList } from "./messaging-config"; - -const DISCORD_SNOWFLAKE_RE = /^[0-9]{17,19}$/; - -describe("onboard messaging config", () => { - it("parses comma-separated config without preserving line breaks", () => { - expect(parseMessagingConfigList(" U01\nBAD , C01\rBAD , , U02 ")).toEqual([ - "U01BAD", - "C01BAD", - "U02", - ]); - }); - - it("collects active channel allowlists and Slack channel config", () => { - expect( - collectMessagingBuildConfig({ - channels: [ - { name: "telegram", userIdEnvKey: "TELEGRAM_ALLOWED_IDS" }, - { name: "slack", userIdEnvKey: "SLACK_ALLOWED_USERS" }, - { name: "wechat", userIdEnvKey: "WECHAT_ALLOWED_IDS" }, - ], - activeChannelNames: new Set(["slack", "telegram"]), - enabledTokenEnvKeys: new Set(), - env: { - TELEGRAM_ALLOWED_IDS: "123,456", - SLACK_ALLOWED_USERS: "U01ABC2DEF3", - SLACK_ALLOWED_CHANNELS: "C012AB3CD\n,C987ZY6XW", - WECHAT_ALLOWED_IDS: "wxid-unused", - }, - discordSnowflakeRe: DISCORD_SNOWFLAKE_RE, - }), - ).toEqual({ - messagingAllowedIds: { - telegram: ["123", "456"], - slack: ["U01ABC2DEF3"], - }, - discordGuilds: {}, - slackConfig: { - allowedChannels: ["C012AB3CD", "C987ZY6XW"], - }, - }); - }); - - it("collects Discord guild config and warns on malformed IDs", () => { - const warn = vi.fn(); - - expect( - collectMessagingBuildConfig({ - channels: [], - activeChannelNames: new Set(), - enabledTokenEnvKeys: new Set(["DISCORD_BOT_TOKEN"]), - env: { - DISCORD_SERVER_IDS: "1491590992753590594,bad-server", - DISCORD_ALLOWED_IDS: "1491590992753590595,bad-user", - DISCORD_REQUIRE_MENTION: "0", - }, - discordSnowflakeRe: DISCORD_SNOWFLAKE_RE, - warn, - }), - ).toEqual({ - messagingAllowedIds: {}, - discordGuilds: { - "1491590992753590594": { - requireMention: false, - users: ["1491590992753590595", "bad-user"], - }, - "bad-server": { - requireMention: false, - users: ["1491590992753590595", "bad-user"], - }, - }, - slackConfig: {}, - }); - expect(warn).toHaveBeenCalledTimes(2); - }); -}); diff --git a/src/lib/onboard/messaging-config.ts b/src/lib/onboard/messaging-config.ts index c18fba5cb1..ff9b03f6d0 100644 --- a/src/lib/onboard/messaging-config.ts +++ b/src/lib/onboard/messaging-config.ts @@ -4,39 +4,15 @@ import { type MessagingChannelConfig, mergeMessagingChannelConfigs, - resolveMessagingChannelConfigEnvValue, sanitizeMessagingChannelConfig, } from "../messaging-channel-config"; import type { Session } from "../state/onboard-session"; import * as onboardSession from "../state/onboard-session"; import * as registry from "../state/registry"; -type EnvLike = Record; - -type MessagingBuildChannel = { - name: string; - userIdEnvKey?: string; -}; - -export type MessagingBuildConfig = { - messagingAllowedIds: Record; - discordGuilds: Record; - slackConfig: Record; -}; - -export type CollectMessagingBuildConfigOptions = { - channels: MessagingBuildChannel[]; - activeChannelNames: ReadonlySet; - enabledTokenEnvKeys: ReadonlySet; - env?: EnvLike; - discordSnowflakeRe: RegExp; - warn?: (message: string) => void; -}; - // Read TELEGRAM_REQUIRE_MENTION (set either by the interactive mention prompt // or by the user's shell) and map it to a boolean, or null when the env var -// is unset / invalid. Used at build time to bake groupPolicy into -// openclaw.json and at resume time to detect drift against the recorded +// is unset / invalid. Used at resume time to detect drift against the recorded // session state. See #1737 and the CodeRabbit follow-up on #2417. export function computeTelegramRequireMention(): boolean | null { const raw = process.env.TELEGRAM_REQUIRE_MENTION; @@ -45,63 +21,6 @@ export function computeTelegramRequireMention(): boolean | null { return null; } -export function parseMessagingConfigList(value: unknown): string[] { - return String(value ?? "") - .split(",") - .map((s) => s.replace(/[\r\n]/g, "").trim()) - .filter(Boolean); -} - -export function collectMessagingBuildConfig({ - channels, - activeChannelNames, - enabledTokenEnvKeys, - env = process.env, - discordSnowflakeRe, - warn = console.warn, -}: CollectMessagingBuildConfigOptions): MessagingBuildConfig { - const messagingAllowedIds: Record = {}; - for (const ch of channels) { - if (activeChannelNames.has(ch.name) && ch.userIdEnvKey) { - const resolved = resolveMessagingChannelConfigEnvValue(ch.userIdEnvKey, env); - if (!resolved.value) continue; - const ids = parseMessagingConfigList(resolved.value); - if (ids.length > 0) messagingAllowedIds[ch.name] = ids; - } - } - - const slackConfig: Record = {}; - if (activeChannelNames.has("slack") && env.SLACK_ALLOWED_CHANNELS) { - const allowedChannels = parseMessagingConfigList(env.SLACK_ALLOWED_CHANNELS); - if (allowedChannels.length > 0) slackConfig.allowedChannels = allowedChannels; - } - - const discordGuilds: Record = {}; - if (enabledTokenEnvKeys.has("DISCORD_BOT_TOKEN")) { - const serverIds = parseMessagingConfigList(env.DISCORD_SERVER_IDS || env.DISCORD_SERVER_ID); - const userIds = parseMessagingConfigList(env.DISCORD_ALLOWED_IDS || env.DISCORD_USER_ID); - for (const serverId of serverIds) { - if (!discordSnowflakeRe.test(serverId)) { - warn(" Warning: configured Discord server ID does not look like a snowflake."); - } - } - for (const userId of userIds) { - if (!discordSnowflakeRe.test(userId)) { - warn(" Warning: configured Discord user ID does not look like a snowflake."); - } - } - const requireMention = env.DISCORD_REQUIRE_MENTION !== "0"; - for (const serverId of serverIds) { - discordGuilds[serverId] = { - requireMention, - ...(userIds.length > 0 ? { users: userIds } : {}), - }; - } - } - - return { messagingAllowedIds, discordGuilds, slackConfig }; -} - export function getStoredMessagingChannelConfig( sandboxName: string | null, session: Session | null, diff --git a/src/lib/onboard/wechat-config.ts b/src/lib/onboard/wechat-config.ts index 70f603eee1..07f045abf9 100644 --- a/src/lib/onboard/wechat-config.ts +++ b/src/lib/onboard/wechat-config.ts @@ -18,8 +18,8 @@ export interface WechatConfigSnapshot { * host-qr handler because the bot token is already cached. * * Non-secret — the bot token lives in the OpenShell provider, not here. - * The metadata is what `patchStagedDockerfile` serializes into - * `NEMOCLAW_WECHAT_CONFIG_B64` so `seed-wechat-accounts.py` can write + * The metadata is serialized into the messaging manifest plan so the + * `wechat.seedOpenClawAccount` post-agent-install hook can write * `/openclaw-weixin/accounts/.json` at image-build time. */ export function gatherWechatConfig(session: Session | null): WechatConfigSnapshot { diff --git a/src/lib/sandbox/build-context.ts b/src/lib/sandbox/build-context.ts index 16f6f99ba0..8569068e79 100644 --- a/src/lib/sandbox/build-context.ts +++ b/src/lib/sandbox/build-context.ts @@ -142,15 +142,8 @@ function stageOptimizedSandboxBuildContext( path.join(stagedScriptsDir, "generate-openclaw-config.mts"), ); fs.copyFileSync( - path.join(rootDir, "scripts", "openclaw-build-messaging-plugins.py"), - path.join(stagedScriptsDir, "openclaw-build-messaging-plugins.py"), - ); - // WeChat-account seed for the @tencent-weixin/openclaw-weixin plugin — - // runs at image build time when WeChat is enabled to skip the upstream - // plugin's in-sandbox QR login. - fs.copyFileSync( - path.join(rootDir, "scripts", "seed-wechat-accounts.py"), - path.join(stagedScriptsDir, "seed-wechat-accounts.py"), + path.join(rootDir, "scripts", "run-openclaw-build-hooks.mts"), + path.join(stagedScriptsDir, "run-openclaw-build-hooks.mts"), ); fs.copyFileSync( path.join(rootDir, "scripts", "patch-openclaw-tool-catalog.js"), diff --git a/test/e2e/docs/parity-inventory.generated.json b/test/e2e/docs/parity-inventory.generated.json index f42dff2ee9..1f481405b8 100644 --- a/test/e2e/docs/parity-inventory.generated.json +++ b/test/e2e/docs/parity-inventory.generated.json @@ -8802,7 +8802,7 @@ { "script": "test/e2e/test-messaging-providers.sh", "line": 1212, - "text": "M-W9: Real WeChat token spliced into accounts/${WECHAT_ACCOUNT}.json — seed-wechat-accounts.py placeholder regression", + "text": "M-W9: Real WeChat token spliced into accounts/${WECHAT_ACCOUNT}.json — manifest seed placeholder regression", "polarity": "fail", "normalized_id": "m.w9.real.wechat.token.spliced.into.accounts.wechat.account.json.seed.wechat.accounts.py.placeholder.regression", "mapping_status": "deferred" diff --git a/test/e2e/test-messaging-providers.sh b/test/e2e/test-messaging-providers.sh index 54d86866f9..3b4515dd72 100755 --- a/test/e2e/test-messaging-providers.sh +++ b/test/e2e/test-messaging-providers.sh @@ -59,7 +59,7 @@ # SLACK_BOT_TOKEN_REVOKED — optional: revoked xoxb- token to test auth pre-validation (#2340) # SLACK_APP_TOKEN_REVOKED — optional: paired xapp- token for the revoked bot token # WECHAT_BOT_TOKEN — defaults to fake token; presence skips host-side QR login -# WECHAT_ACCOUNT_ID — defaults to fake iLink account ID (seed-wechat-accounts.py key) +# WECHAT_ACCOUNT_ID — defaults to fake iLink account ID (manifest hook account key) # WECHAT_BASE_URL — defaults to fake iLink baseUrl (per-account API host) # WECHAT_USER_ID — defaults to fake operator wechat user ID (seeds DM allowlist) # WECHAT_ALLOWED_IDS — optional: comma-separated DM allowlist for wechat @@ -2081,7 +2081,7 @@ print(','.join(bad)) # after OpenClaw config rewrites (plugins.entries alone is not enough), # and a floating spec (e.g. "@latest") would silently bypass the # installer-trust pinning enforced in Dockerfile.base and - # scripts/seed-wechat-accounts.py (WECHAT_PLUGIN_SPEC=@2.4.3). + # wechat.seedOpenClawAccount manifest hook (WECHAT_PLUGIN_SPEC=@2.4.3). wechat_plugins_json=$(sandbox_exec "python3 -c \" import json cfg = json.load(open('/sandbox/.openclaw/openclaw.json')) @@ -2119,10 +2119,10 @@ sys.exit(0 if ok else 1) fi # M-W8: WeChat channel registered under channels.openclaw-weixin with the - # configured accountId enabled. Written by seed-wechat-accounts.py during - # image build using NEMOCLAW_WECHAT_CONFIG_B64. Absence here means - # NEMOCLAW_WECHAT_CONFIG_B64 was empty or seed-wechat-accounts.py was - # skipped — both regressions on the non-interactive QR-skip path. + # configured accountId enabled. Written by the manifest post-agent-install + # hook during image build. Absence here means WeChat metadata was empty or + # the manifest build-file output was skipped — both regressions on the + # non-interactive QR-skip path. wechat_enabled=$(echo "$channel_json" | python3 -c " import json, sys d = json.load(sys.stdin) @@ -2138,16 +2138,16 @@ print(account.get('enabled', False)) fi # M-W9: Per-account credential file holds the WECHAT_BOT_TOKEN placeholder, -# not the real token. seed-wechat-accounts.py writes +# not the real token. The manifest post-agent-install hook writes # /openclaw-weixin/accounts/.json with # token = "openshell:resolve:env:WECHAT_BOT_TOKEN". A real-token hit # would mean someone bypassed the placeholder constant. wechat_account_json=$(sandbox_exec "cat /sandbox/.openclaw/openclaw-weixin/accounts/${WECHAT_ACCOUNT}.json 2>/dev/null || true" 2>/dev/null || true) if [ -z "$wechat_account_json" ] || echo "$wechat_account_json" | grep -qi "no such file"; then - fail "M-W9: WeChat per-account credential file not found (seed-wechat-accounts.py may have been skipped)" + fail "M-W9: WeChat per-account credential file not found (manifest post-agent-install hook may have been skipped)" else if echo "$wechat_account_json" | grep -qF "$WECHAT_TOKEN"; then - fail "M-W9: Real WeChat token spliced into accounts/${WECHAT_ACCOUNT}.json — seed-wechat-accounts.py placeholder regression" + fail "M-W9: Real WeChat token spliced into accounts/${WECHAT_ACCOUNT}.json — manifest seed placeholder regression" elif echo "$wechat_account_json" | grep -qF "openshell:resolve:env:WECHAT_BOT_TOKEN"; then pass "M-W9: WeChat per-account credential file uses the L7-resolved placeholder" else @@ -2156,7 +2156,7 @@ else fi # M-W10: Accounts index lists the configured accountId. Written by -# seed-wechat-accounts.py before the per-account file; the upstream plugin's +# the manifest post-agent-install hook before the per-account file; the upstream plugin's # auth/accounts.ts boots accounts that appear in this index. wechat_index_json=$(sandbox_exec "cat /sandbox/.openclaw/openclaw-weixin/accounts.json 2>/dev/null || true" 2>/dev/null || true) if [ -z "$wechat_index_json" ] || echo "$wechat_index_json" | grep -qi "no such file"; then diff --git a/test/generate-hermes-config.test.ts b/test/generate-hermes-config.test.ts index 14704e044c..f2eda0ba7f 100644 --- a/test/generate-hermes-config.test.ts +++ b/test/generate-hermes-config.test.ts @@ -8,6 +8,7 @@ import path from "node:path"; import YAML from "yaml"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { HERMES_PROXY_API_KEY_PLACEHOLDER } from "../src/lib/hermes-proxy-api-key"; +import { withLegacyMessagingPlanEnv } from "./messaging-plan-test-helper"; const SCRIPT_PATH = path.join(import.meta.dirname, "..", "agents", "hermes", "generate-config.ts"); const CONFIG_MODULE_DIR = path.join(import.meta.dirname, "..", "agents", "hermes", "config"); @@ -71,18 +72,22 @@ function runConfigScriptRaw( opts: { cwd?: string; scriptPath?: string } = {}, ) { fs.mkdirSync(path.join(tmpDir, ".hermes"), { recursive: true }); + const env = withLegacyMessagingPlanEnv( + { + PATH: process.env.PATH || "/usr/bin:/bin", + ...BASE_ENV, + ...envOverrides, + HOME: tmpDir, + }, + "hermes", + ); return spawnSync( process.execPath, ["--experimental-strip-types", opts.scriptPath || SCRIPT_PATH], { encoding: "utf-8", cwd: opts.cwd, - env: { - PATH: process.env.PATH || "/usr/bin:/bin", - ...BASE_ENV, - ...envOverrides, - HOME: tmpDir, - }, + env, timeout: 10_000, }, ); @@ -372,7 +377,8 @@ describe("agents/hermes/generate-config.ts", () => { reactions: true, channel_prompts: {}, }); - expect(config.platforms.discord).toBeUndefined(); + expect(config.platforms.discord).toEqual({ enabled: true }); + expectRemotePlatformToolsets(config.platform_toolsets.discord); expect(JSON.stringify(config)).not.toContain("DISCORD_BOT_TOKEN"); expect(envFile).toContain("DISCORD_BOT_TOKEN=openshell:resolve:env:DISCORD_BOT_TOKEN\n"); expect(envFile).not.toContain("DISCORD_PROXY="); @@ -436,8 +442,10 @@ describe("agents/hermes/generate-config.ts", () => { }); expect(config.telegram).toEqual({ require_mention: true }); - expect(config.platforms.telegram).toBeUndefined(); + expect(config.platforms.telegram).toEqual({ enabled: true }); expect(config.platforms.slack).toEqual({ enabled: true }); + expectRemotePlatformToolsets(config.platform_toolsets.telegram); + expectRemotePlatformToolsets(config.platform_toolsets.slack); expect(envFile).toContain("TELEGRAM_BOT_TOKEN=openshell:resolve:env:TELEGRAM_BOT_TOKEN\n"); expect(envFile).toContain("TELEGRAM_ALLOWED_USERS=123456789\n"); expect(envFile).toContain( @@ -494,6 +502,8 @@ describe("agents/hermes/generate-config.ts", () => { // env vars and writes its own state under ~/.hermes/weixin/. expect(config.wechat).toBeUndefined(); expect(config.platforms.wechat).toBeUndefined(); + expect(config.platforms.weixin).toEqual({ enabled: true }); + expectRemotePlatformToolsets(config.platform_toolsets.weixin); // The bot token placeholder references the OpenShell credential slot // (WECHAT_BOT_TOKEN), NOT a fresh WEIXIN_TOKEN slot — that's the L7 @@ -508,13 +518,14 @@ describe("agents/hermes/generate-config.ts", () => { expect(envFile).toContain("WEIXIN_ALLOWED_USERS=operator_self_id,bot_other_friend\n"); }); - it("enables Hermes WhatsApp without provider tokens or generic platform blocks", () => { + it("enables Hermes WhatsApp without provider tokens", () => { const { config, envFile } = runConfigScript({ NEMOCLAW_MESSAGING_CHANNELS_B64: encodeJson(["whatsapp"]), }); expect(config.whatsapp).toBeUndefined(); - expect(config.platforms.whatsapp).toBeUndefined(); + expect(config.platforms.whatsapp).toEqual({ enabled: true }); + expectRemotePlatformToolsets(config.platform_toolsets.whatsapp); expect(envFile).toContain("WHATSAPP_ENABLED=true\n"); expect(envFile).toContain("WHATSAPP_MODE=bot\n"); expect(envFile).not.toContain("WHATSAPP_BOT_TOKEN="); @@ -532,8 +543,8 @@ describe("agents/hermes/generate-config.ts", () => { expect(envFile).toContain("WHATSAPP_ALLOWED_USERS=15551234567,15557654321\n"); }); - it("fails fast when WeChat is enabled without captured account metadata", () => { - const result = runConfigScriptRaw({ + it("omits WeChat env when captured account metadata is incomplete", () => { + const { config, envFile } = runConfigScript({ NEMOCLAW_MESSAGING_CHANNELS_B64: encodeJson(["wechat"]), NEMOCLAW_WECHAT_CONFIG_B64: encodeJson({ baseUrl: "https://ilinkai.wechat.com", @@ -541,9 +552,9 @@ describe("agents/hermes/generate-config.ts", () => { }), }); - expect(result.status).not.toBe(0); - expect(result.stderr).toContain("wechat is enabled but wechatConfig.accountId is missing"); - expect(fs.existsSync(path.join(tmpDir, ".hermes", ".env"))).toBe(false); + expect(config.platform_toolsets.weixin).toBeUndefined(); + expect(envFile).not.toContain("WEIXIN_TOKEN="); + expect(envFile).not.toContain("WEIXIN_ACCOUNT_ID="); }); it("omits Telegram behavior config when requireMention is not boolean", () => { @@ -553,7 +564,8 @@ describe("agents/hermes/generate-config.ts", () => { }); expect(config.telegram).toBeUndefined(); - expect(config.platforms.telegram).toBeUndefined(); + expect(config.platforms.telegram).toEqual({ enabled: true }); + expectRemotePlatformToolsets(config.platform_toolsets.telegram); expect(envFile).toContain("TELEGRAM_BOT_TOKEN=openshell:resolve:env:TELEGRAM_BOT_TOKEN\n"); }); diff --git a/test/generate-openclaw-config.test.ts b/test/generate-openclaw-config.test.ts index 31a47b17e1..e54875e030 100644 --- a/test/generate-openclaw-config.test.ts +++ b/test/generate-openclaw-config.test.ts @@ -13,6 +13,7 @@ import path from "node:path"; import { spawnSync } from "node:child_process"; import { buildConfig, main } from "../scripts/generate-openclaw-config.mts"; +import { withLegacyMessagingPlanEnv } from "./messaging-plan-test-helper"; const SCRIPT_PATH = path.join(import.meta.dirname, "..", "scripts", "generate-openclaw-config.mts"); const SCRIPT_ARGS = ["--experimental-strip-types", SCRIPT_PATH]; @@ -37,12 +38,13 @@ const BASE_ENV: Record = { let tmpDir: string; function buildTestEnv(envOverrides: Record = {}): Record { - return { + const env = { PATH: process.env.PATH || "/usr/bin:/bin", ...BASE_ENV, ...envOverrides, HOME: tmpDir, }; + return withLegacyMessagingPlanEnv(env, "openclaw"); } function runConfigScriptRaw(envOverrides: Record = {}) { @@ -148,15 +150,8 @@ function wechatExtensionPath(stateDir = path.join(tmpDir, ".openclaw")) { return path.join(fs.realpathSync(stateDir), "extensions", "openclaw-weixin"); } -function wechatNpmPackagePath(stateDir = path.join(tmpDir, ".openclaw")) { - return path.join( - fs.realpathSync(stateDir), - "npm", - "node_modules", - "@tencent-weixin", - "openclaw-weixin", - ); -} + +const SANDBOX_WECHAT_PLUGIN_INSTALL_PATH = "/sandbox/.openclaw/extensions/openclaw-weixin"; function writeRegistryManifest( blueprintDir: string, @@ -454,7 +449,7 @@ describe("generate-openclaw-config.mts: config generation", () => { expect(config.channels.discord.guilds).toEqual(guilds); }); - it("does not seed channels.openclaw-weixin before the base plugin install registry exists", () => { + it("seeds channels.openclaw-weixin from manifest build-file outputs", () => { const channels = Buffer.from(JSON.stringify(["wechat"])).toString("base64"); const wechatConfig = Buffer.from( JSON.stringify({ accountId: "primary", baseUrl: "https://example", userId: "u1" }), @@ -463,13 +458,21 @@ describe("generate-openclaw-config.mts: config generation", () => { NEMOCLAW_MESSAGING_CHANNELS_B64: channels, NEMOCLAW_WECHAT_CONFIG_B64: wechatConfig, }); - expect(config.channels?.["openclaw-weixin"]).toBeUndefined(); + expect(config.plugins?.installs?.["openclaw-weixin"]).toEqual({ + source: "npm", + spec: "@tencent-weixin/openclaw-weixin@2.4.3", + installPath: SANDBOX_WECHAT_PLUGIN_INSTALL_PATH, + }); + expect(config.plugins?.load?.paths).toEqual([SANDBOX_WECHAT_PLUGIN_INSTALL_PATH]); + expect(config.channels?.["openclaw-weixin"]?.accounts?.primary).toEqual({ + enabled: true, + }); // The "wechat" alias is the NemoClaw channel name, not an OpenClaw // channel id — must never appear under channels. expect(config.channels?.wechat).toBeUndefined(); }); - it("detects installed WeChat metadata in nested extension directories", () => { + it("ignores installed WeChat metadata in nested extension directories", () => { const pluginDir = path.join(tmpDir, ".openclaw", "extensions", "vendor", "openclaw-weixin"); fs.mkdirSync(pluginDir, { recursive: true }); fs.mkdirSync(path.join(tmpDir, ".openclaw", "extensions", "node_modules"), { recursive: true }); @@ -492,7 +495,7 @@ describe("generate-openclaw-config.mts: config generation", () => { }); }); - it("seeds channels.openclaw-weixin when the base plugin install registry exists", () => { + it("uses canonical sandbox WeChat install metadata when the base plugin install registry exists", () => { const configPath = path.join(tmpDir, ".openclaw", "openclaw.json"); const installEntry = { source: "npm", @@ -515,9 +518,9 @@ describe("generate-openclaw-config.mts: config generation", () => { expect(config.plugins?.installs?.["openclaw-weixin"]).toEqual({ ...installEntry, - installPath: wechatExtensionPath(), + installPath: SANDBOX_WECHAT_PLUGIN_INSTALL_PATH, }); - expect(config.plugins?.load?.paths).toEqual([wechatExtensionPath()]); + expect(config.plugins?.load?.paths).toEqual([SANDBOX_WECHAT_PLUGIN_INSTALL_PATH]); expect(config.channels?.["openclaw-weixin"]?.accounts?.primary).toEqual({ enabled: true, }); @@ -538,7 +541,7 @@ describe("generate-openclaw-config.mts: config generation", () => { }); }); - it("seeds channels.openclaw-weixin and restores install registry when installed WeChat plugin metadata exists", () => { + it("uses canonical sandbox WeChat install metadata when host plugin metadata exists", () => { writeWeChatPluginMetadata({ id: "openclaw-weixin", channels: ["openclaw-weixin"], @@ -557,16 +560,16 @@ describe("generate-openclaw-config.mts: config generation", () => { expect(config.plugins?.installs?.["openclaw-weixin"]).toEqual({ source: "npm", spec: "@tencent-weixin/openclaw-weixin@2.4.3", - installPath: wechatExtensionPath(), + installPath: SANDBOX_WECHAT_PLUGIN_INSTALL_PATH, }); - expect(config.plugins?.load?.paths).toEqual([wechatExtensionPath()]); + expect(config.plugins?.load?.paths).toEqual([SANDBOX_WECHAT_PLUGIN_INSTALL_PATH]); expect(config.channels?.["openclaw-weixin"]?.accounts?.primary).toEqual({ enabled: true, }); expect(config.channels?.wechat).toBeUndefined(); }); - it("uses the npm package path when installed WeChat package metadata exists without an extension dir", () => { + it("ignores npm package metadata when manifest build-file output seeds WeChat", () => { writeWeChatNpmPackageMetadata({ name: "@tencent-weixin/openclaw-weixin", openclaw: { channels: ["vendor-weixin"] }, @@ -584,12 +587,10 @@ describe("generate-openclaw-config.mts: config generation", () => { expect(config.plugins?.installs?.["openclaw-weixin"]).toEqual({ source: "npm", spec: "@tencent-weixin/openclaw-weixin@2.4.3", - installPath: wechatNpmPackagePath(), - }); - expect(config.plugins?.load?.paths).toEqual([wechatNpmPackagePath()]); - expect(config.channels?.["vendor-weixin"]?.accounts?.primary).toEqual({ - enabled: true, + installPath: SANDBOX_WECHAT_PLUGIN_INSTALL_PATH, }); + expect(config.plugins?.load?.paths).toEqual([SANDBOX_WECHAT_PLUGIN_INSTALL_PATH]); + expect(config.channels?.["vendor-weixin"]).toBeUndefined(); expect(config.channels?.["openclaw-weixin"]?.accounts?.primary).toEqual({ enabled: true, }); @@ -597,7 +598,7 @@ describe("generate-openclaw-config.mts: config generation", () => { expect(fs.existsSync(wechatExtensionPath())).toBe(false); }); - it("uses the npm package path when installed WeChat plugin metadata exists without an extension dir", () => { + it("ignores npm plugin metadata when manifest build-file output seeds WeChat", () => { writeWeChatNpmPluginMetadata({ id: "openclaw-weixin", channelConfigs: { "vendor-weixin": {} }, @@ -615,12 +616,10 @@ describe("generate-openclaw-config.mts: config generation", () => { expect(config.plugins?.installs?.["openclaw-weixin"]).toEqual({ source: "npm", spec: "@tencent-weixin/openclaw-weixin@2.4.3", - installPath: wechatNpmPackagePath(), - }); - expect(config.plugins?.load?.paths).toEqual([wechatNpmPackagePath()]); - expect(config.channels?.["vendor-weixin"]?.accounts?.primary).toEqual({ - enabled: true, + installPath: SANDBOX_WECHAT_PLUGIN_INSTALL_PATH, }); + expect(config.plugins?.load?.paths).toEqual([SANDBOX_WECHAT_PLUGIN_INSTALL_PATH]); + expect(config.channels?.["vendor-weixin"]).toBeUndefined(); expect(config.channels?.["openclaw-weixin"]?.accounts?.primary).toEqual({ enabled: true, }); diff --git a/test/messaging-plan-test-helper.ts b/test/messaging-plan-test-helper.ts new file mode 100644 index 0000000000..0acbfacad4 --- /dev/null +++ b/test/messaging-plan-test-helper.ts @@ -0,0 +1,250 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const REPO_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."); +const PLAN_BUILDER = String.raw` +import { + MessagingSetupApplier, + MessagingWorkflowPlanner, + createBuiltInChannelManifestRegistry, + createBuiltInMessagingHookRegistry, +} from "./src/lib/messaging/index.ts"; + +const agent = process.env.NEMOCLAW_TEST_MESSAGING_PLAN_AGENT; +const channels = JSON.parse(process.env.NEMOCLAW_TEST_MESSAGING_PLAN_CHANNELS_JSON || "[]"); +const credentialAvailability = JSON.parse( + process.env.NEMOCLAW_TEST_MESSAGING_CREDENTIAL_AVAILABILITY_JSON || "{}", +); + +async function main() { + const planner = new MessagingWorkflowPlanner( + createBuiltInChannelManifestRegistry(), + createBuiltInMessagingHookRegistry({ + wechat: { + seedOpenClawAccount: { + now: () => "2026-01-01T00:00:00.000Z", + }, + }, + }), + ); + const plan = await planner.buildPlan({ + sandboxName: "test-sandbox", + agent, + workflow: "rebuild", + isInteractive: false, + configuredChannels: channels, + credentialAvailability, + }); + process.stdout.write(MessagingSetupApplier.encodePlan(plan)); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.stack || error.message : String(error)); + process.exit(1); +}); +`; + +export type MessagingPlanAgent = "openclaw" | "hermes"; + +export function encodeJson(value: unknown): string { + return Buffer.from(JSON.stringify(value)).toString("base64"); +} + +export function withLegacyMessagingPlanEnv( + env: Record, + agent: MessagingPlanAgent, +): Record { + if (env.NEMOCLAW_MESSAGING_PLAN_B64) return env; + const channels = decodeJsonEnv(env, "NEMOCLAW_MESSAGING_CHANNELS_B64", []); + if (!Array.isArray(channels) || channels.length === 0) return env; + + const normalizedEnv = { + ...env, + ...legacyMessagingConfigEnv(env), + }; + return { + ...env, + NEMOCLAW_MESSAGING_PLAN_B64: buildMessagingPlanB64(normalizedEnv, agent, channels), + }; +} + +export function buildMessagingPlanB64( + env: Record, + agent: MessagingPlanAgent, + channels: readonly string[], +): string { + const result = spawnSync( + "npx", + ["tsx", "-e", PLAN_BUILDER], + { + cwd: REPO_ROOT, + encoding: "utf-8", + env: { + PATH: process.env.PATH || "/usr/bin:/bin", + ...env, + NEMOCLAW_TEST_MESSAGING_PLAN_AGENT: agent, + NEMOCLAW_TEST_MESSAGING_PLAN_CHANNELS_JSON: JSON.stringify([...new Set(channels)]), + NEMOCLAW_TEST_MESSAGING_CREDENTIAL_AVAILABILITY_JSON: JSON.stringify( + credentialAvailability(), + ), + }, + timeout: 10_000, + }, + ); + if (result.status !== 0) { + throw new Error( + `Failed to build ${agent} messaging test plan (exit ${result.status}):\nstdout: ${result.stdout}\nstderr: ${result.stderr}`, + ); + } + return result.stdout.trim(); +} + +function legacyMessagingConfigEnv(env: Record): Record { + const next: Record = {}; + const allowedIds = decodeJsonEnv>( + env, + "NEMOCLAW_MESSAGING_ALLOWED_IDS_B64", + {}, + ); + assignCsv(next, "TELEGRAM_ALLOWED_IDS", allowedIds.telegram); + assignCsv(next, "SLACK_ALLOWED_USERS", allowedIds.slack); + assignCsv(next, "WECHAT_ALLOWED_IDS", allowedIds.wechat); + assignCsv(next, "WHATSAPP_ALLOWED_IDS", allowedIds.whatsapp); + + const telegramConfig = decodeJsonEnv>( + env, + "NEMOCLAW_TELEGRAM_CONFIG_B64", + {}, + ); + assignMentionMode(next, "TELEGRAM_REQUIRE_MENTION", telegramConfig.requireMention); + + const discordGuilds = decodeJsonEnv>( + env, + "NEMOCLAW_DISCORD_GUILDS_B64", + {}, + ); + assignDiscordConfig(next, allowedIds.discord, discordGuilds); + + const wechatConfig = decodeJsonEnv>( + env, + "NEMOCLAW_WECHAT_CONFIG_B64", + {}, + ); + assignString(next, "WECHAT_ACCOUNT_ID", wechatConfig.accountId); + assignString(next, "WECHAT_BASE_URL", wechatConfig.baseUrl); + assignString(next, "WECHAT_USER_ID", wechatConfig.userId); + + const slackConfig = decodeJsonEnv>( + env, + "NEMOCLAW_SLACK_CONFIG_B64", + {}, + ); + assignCsv(next, "SLACK_ALLOWED_CHANNELS", slackConfig.allowedChannels); + + return next; +} + +function assignDiscordConfig( + target: Record, + allowedUsers: unknown, + guilds: Record, +): void { + const guildIds = Object.keys(guilds).filter((guildId) => guildId.trim().length > 0); + assignCsv(target, "DISCORD_SERVER_ID", guildIds); + + const users = uniqueStrings([ + ...stringList(allowedUsers), + ...Object.values(guilds).flatMap((entry) => + isRecord(entry) ? stringList(entry.users) : [], + ), + ]); + assignCsv(target, "DISCORD_USER_ID", users); + + for (const guildId of guildIds) { + const guild = guilds[guildId]; + if (!isRecord(guild)) continue; + if (typeof guild.requireMention === "boolean" || typeof guild.requireMention === "string") { + assignMentionMode(target, "DISCORD_REQUIRE_MENTION", guild.requireMention); + return; + } + } +} + +function assignMentionMode( + target: Record, + key: string, + value: unknown, +): void { + if (typeof value === "boolean") { + target[key] = value ? "1" : "0"; + return; + } + assignString(target, key, value); +} + +function assignString(target: Record, key: string, value: unknown): void { + if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") { + return; + } + const normalized = String(value).replace(/\r/g, "").trim(); + if (normalized) target[key] = normalized; +} + +function assignCsv(target: Record, key: string, value: unknown): void { + const values = stringList(value); + if (values.length > 0) target[key] = values.join(","); +} + +function stringList(value: unknown): string[] { + if (Array.isArray(value)) { + return uniqueStrings(value.map((entry) => String(entry).trim()).filter(Boolean)); + } + if (typeof value === "string") { + return uniqueStrings(value.split(",").map((entry) => entry.trim()).filter(Boolean)); + } + if (typeof value === "number" || typeof value === "boolean") { + return [String(value)]; + } + return []; +} + +function uniqueStrings(values: readonly string[]): string[] { + return [...new Set(values)]; +} + +function decodeJsonEnv(env: Record, name: string, fallback: T): T { + const encoded = env[name]; + if (!encoded) return fallback; + return JSON.parse(Buffer.from(encoded, "base64").toString("utf-8")) as T; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function credentialAvailability(): Record { + const keys = [ + "botToken", + "appToken", + "telegram.botToken", + "discord.botToken", + "wechat.botToken", + "slack.botToken", + "slack.appToken", + "telegramBotToken", + "discordBotToken", + "wechatBotToken", + "slackBotToken", + "slackAppToken", + "TELEGRAM_BOT_TOKEN", + "DISCORD_BOT_TOKEN", + "WECHAT_BOT_TOKEN", + "SLACK_BOT_TOKEN", + "SLACK_APP_TOKEN", + ]; + return Object.fromEntries(keys.map((key) => [key, true])); +} diff --git a/test/onboard-messaging.test.ts b/test/onboard-messaging.test.ts index 687d874e14..555ec6aef5 100644 --- a/test/onboard-messaging.test.ts +++ b/test/onboard-messaging.test.ts @@ -6,6 +6,7 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { createRequire } from "node:module"; import { pathToFileURL } from "node:url"; import { describe, it } from "vitest"; @@ -16,8 +17,67 @@ type CommandEntry = { env?: Record; policyContent?: string; policyReadError?: string; + dockerfileContent?: string; + dockerfileReadError?: string; }; +type MessagingPlanChannel = { + channelId?: unknown; + active?: unknown; +}; + +type MessagingPlan = { + channels?: MessagingPlanChannel[]; +}; + +function readMessagingPlanFromDockerfile(dockerfileContent: string | undefined): MessagingPlan { + assert.ok(dockerfileContent, "expected Dockerfile content"); + const line = dockerfileContent + .split("\n") + .find((entry) => entry.startsWith("ARG NEMOCLAW_MESSAGING_PLAN_B64=")); + assert.ok(line, "expected messaging plan build arg in Dockerfile"); + const prefix = "ARG NEMOCLAW_MESSAGING_PLAN_B64="; + return JSON.parse(Buffer.from(line.slice(prefix.length), "base64").toString("utf8")); +} + +function activeChannelsFromDockerfile(dockerfileContent: string | undefined): string[] { + const plan = readMessagingPlanFromDockerfile(dockerfileContent); + return (plan.channels ?? []) + .filter((channel) => channel.active === true && typeof channel.channelId === "string") + .map((channel) => String(channel.channelId)) + .sort(); +} + +function encodeTestMessagingPlan( + channels: ReadonlyArray<{ readonly channelId: string; readonly active: boolean }>, +): string { + const plan = { + schemaVersion: 1, + sandboxName: "my-assistant", + agent: "openclaw", + workflow: "onboard", + channels: channels.map(({ channelId, active }) => ({ + channelId, + displayName: channelId, + authMode: "none", + active, + selected: true, + configured: true, + disabled: !active, + inputs: [], + hooks: [], + })), + disabledChannels: channels.filter((channel) => !channel.active).map((channel) => channel.channelId), + credentialBindings: [], + networkPolicy: { presets: [], entries: [] }, + agentRender: [], + buildSteps: [], + stateUpdates: [], + healthChecks: [], + }; + return Buffer.from(JSON.stringify(plan), "utf8").toString("base64"); +} + function parseStdoutJson(stdout: string): T { const line = stdout.trim().split("\n").pop(); assert.ok(line, `expected JSON payload in stdout:\n${stdout}`); @@ -25,6 +85,8 @@ function parseStdoutJson(stdout: string): T { } const repoRoot = path.join(import.meta.dirname, ".."); +const requireForTest = createRequire(import.meta.url); +const yamlModulePath = requireForTest.resolve("yaml"); const onboardScriptMocksPath = JSON.stringify( path.join(repoRoot, "test", "helpers", "onboard-script-mocks.cjs"), ); @@ -303,12 +365,12 @@ const { createSandbox, setupMessagingChannels } = require(${onboardPath}); const credentialsPath = JSON.stringify( path.join(repoRoot, "dist", "lib", "credentials", "store.js"), ); - const yamlPath = JSON.stringify(path.join(repoRoot, "node_modules", "yaml")); + const yamlPath = JSON.stringify(yamlModulePath); const customDockerfileArg = JSON.stringify(customDockerfilePath); fs.mkdirSync(fakeBin, { recursive: true }); fs.mkdirSync(customBuildDir, { recursive: true }); - fs.writeFileSync(customDockerfilePath, "FROM scratch\n"); + fs.writeFileSync(customDockerfilePath, "FROM scratch\nARG NEMOCLAW_MESSAGING_PLAN_B64=\n"); fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { mode: 0o755, }); @@ -481,7 +543,7 @@ const { createSandbox } = require(${onboardPath}); ); it( - "reuses existing messaging providers during non-interactive recreate when tokens are not in the host env", + "does not reuse existing messaging providers during non-interactive recreate when tokens are not in the host env", { timeout: 60_000 }, async () => { const repoRoot = path.join(import.meta.dirname, ".."); @@ -495,6 +557,10 @@ const { createSandbox } = require(${onboardPath}); const registryPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "state", "registry.js")); const preflightPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "onboard", "preflight.js")); const credentialsPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "credentials", "store.js")); + const messagingPlanB64 = encodeTestMessagingPlan([ + { channelId: "discord", active: false }, + { channelId: "slack", active: false }, + ]); fs.mkdirSync(fakeBin, { recursive: true }); fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { @@ -603,6 +669,7 @@ const { createSandbox } = require(${onboardPath}); HOME: tmpDir, PATH: `${fakeBin}:${process.env.PATH || ""}`, NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_MESSAGING_PLAN_B64: messagingPlanB64, DISCORD_BOT_TOKEN: "", SLACK_BOT_TOKEN: "", SLACK_APP_TOKEN: "", @@ -634,22 +701,13 @@ const { createSandbox } = require(${onboardPath}); ); assert.ok(createCommand, "expected sandbox create command"); assert.equal(createCommand.dockerfileReadError, undefined); - assert.match(createCommand.command, /--provider my-assistant-discord-bridge/); - assert.match(createCommand.command, /--provider my-assistant-slack-bridge/); - assert.match(createCommand.command, /--provider my-assistant-slack-app/); - - const channelsLine = createCommand.dockerfileContent - ?.split("\n") - .find((line: string) => line.startsWith("ARG NEMOCLAW_MESSAGING_CHANNELS_B64=")); - assert.ok(channelsLine, "expected messaging build arg in Dockerfile"); - const channels = JSON.parse(Buffer.from(channelsLine.split("=")[1], "base64").toString()); - assert.deepEqual(channels, ["discord", "slack"]); + assert.doesNotMatch(createCommand.command, /--provider my-assistant-discord-bridge/); + assert.doesNotMatch(createCommand.command, /--provider my-assistant-slack-bridge/); + assert.doesNotMatch(createCommand.command, /--provider my-assistant-slack-app/); + + assert.deepEqual(activeChannelsFromDockerfile(createCommand.dockerfileContent), []); assert.deepEqual(payload.registerCalls[0]?.messagingChannels, ["discord", "slack"]); - assert.deepEqual(payload.registerCalls[0]?.providerCredentialHashes, { - DISCORD_BOT_TOKEN: "hash-discord", - SLACK_BOT_TOKEN: "hash-slack-bot", - SLACK_APP_TOKEN: "hash-slack-app", - }); + assert.equal(payload.registerCalls[0]?.providerCredentialHashes, undefined); }, ); @@ -668,6 +726,7 @@ const { createSandbox } = require(${onboardPath}); const registryPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "state", "registry.js")); const preflightPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "onboard", "preflight.js")); const credentialsPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "credentials", "store.js")); + const messagingPlanB64 = encodeTestMessagingPlan([{ channelId: "telegram", active: false }]); fs.mkdirSync(fakeBin, { recursive: true }); fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { @@ -767,6 +826,7 @@ const { createSandbox } = require(${onboardPath}); HOME: tmpDir, PATH: `${fakeBin}:${process.env.PATH || ""}`, NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_MESSAGING_PLAN_B64: messagingPlanB64, TELEGRAM_BOT_TOKEN: "", }, }); @@ -787,14 +847,11 @@ const { createSandbox } = require(${onboardPath}); assert.ok(createCommand, "expected sandbox create command"); assert.equal(createCommand.dockerfileReadError, undefined); - const channelsLine = createCommand.dockerfileContent - ?.split("\n") - .find((line: string) => line.startsWith("ARG NEMOCLAW_MESSAGING_CHANNELS_B64=")); - assert.ok(channelsLine, "expected messaging build arg in Dockerfile"); - const bakedChannels = JSON.parse( - Buffer.from(channelsLine.split("=")[1], "base64").toString(), + assert.deepEqual( + activeChannelsFromDockerfile(createCommand.dockerfileContent), + [], + "disabled channel must not be active in the image plan", ); - assert.deepEqual(bakedChannels, [], "disabled channel must not be baked into the image"); assert.doesNotMatch( createCommand.command, /--provider my-assistant-telegram-bridge/, @@ -836,6 +893,7 @@ const { createSandbox } = require(${onboardPath}); const credentialsPath = JSON.stringify( path.join(repoRoot, "dist", "lib", "credentials", "store.js"), ); + const messagingPlanB64 = encodeTestMessagingPlan([{ channelId: "whatsapp", active: true }]); fs.mkdirSync(fakeBin, { recursive: true }); fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { @@ -932,6 +990,7 @@ const { createSandbox } = require(${onboardPath}); HOME: tmpDir, PATH: `${fakeBin}:${process.env.PATH || ""}`, NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_MESSAGING_PLAN_B64: messagingPlanB64, }, }); @@ -961,14 +1020,9 @@ const { createSandbox } = require(${onboardPath}); assert.equal(createCommand.dockerfileReadError, undefined); assert.doesNotMatch(createCommand.command, /--provider \S+-bridge\b/); - const channelsLine = createCommand.dockerfileContent - ?.split("\n") - .find((line: string) => line.startsWith("ARG NEMOCLAW_MESSAGING_CHANNELS_B64=")); - assert.ok(channelsLine, "expected messaging build arg in Dockerfile"); - const channels = JSON.parse( - Buffer.from(channelsLine.split("=")[1], "base64").toString(), - ); - assert.deepEqual(channels, ["whatsapp"]); + assert.deepEqual(activeChannelsFromDockerfile(createCommand.dockerfileContent), [ + "whatsapp", + ]); assert.deepEqual(payload.registerCalls[0]?.messagingChannels, ["whatsapp"]); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); @@ -998,6 +1052,7 @@ const { createSandbox } = require(${onboardPath}); const credentialsPath = JSON.stringify( path.join(repoRoot, "dist", "lib", "credentials", "store.js"), ); + const messagingPlanB64 = encodeTestMessagingPlan([{ channelId: "whatsapp", active: false }]); fs.mkdirSync(fakeBin, { recursive: true }); fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { @@ -1099,6 +1154,7 @@ const { createSandbox } = require(${onboardPath}); HOME: tmpDir, PATH: `${fakeBin}:${process.env.PATH || ""}`, NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_MESSAGING_PLAN_B64: messagingPlanB64, }, }); @@ -1118,14 +1174,11 @@ const { createSandbox } = require(${onboardPath}); assert.ok(createCommand, "expected sandbox create command"); assert.equal(createCommand.dockerfileReadError, undefined); - const channelsLine = createCommand.dockerfileContent - ?.split("\n") - .find((line: string) => line.startsWith("ARG NEMOCLAW_MESSAGING_CHANNELS_B64=")); - assert.ok(channelsLine, "expected messaging build arg in Dockerfile"); - const channels = JSON.parse( - Buffer.from(channelsLine.split("=")[1], "base64").toString(), + assert.deepEqual( + activeChannelsFromDockerfile(createCommand.dockerfileContent), + [], + "disabled QR channel must not be active in the image plan", ); - assert.deepEqual(channels, [], "disabled QR channel must not be baked into the image"); assert.deepEqual( payload.registerCalls[0]?.messagingChannels, ["whatsapp"], diff --git a/test/onboard.test.ts b/test/onboard.test.ts index bc9af37d66..ae1bed3bd5 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -2715,11 +2715,7 @@ agentOnboard.createAgentSandbox = () => { "ARG NEMOCLAW_INFERENCE_BASE_URL=https://inference.local/v1", "ARG NEMOCLAW_INFERENCE_API=openai-completions", "ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30=", - "ARG NEMOCLAW_MESSAGING_CHANNELS_B64=W10=", - "ARG NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=e30=", - "ARG NEMOCLAW_DISCORD_GUILDS_B64=e30=", - "ARG NEMOCLAW_TELEGRAM_CONFIG_B64=e30=", - "ARG NEMOCLAW_WECHAT_CONFIG_B64=e30=", + "ARG NEMOCLAW_MESSAGING_PLAN_B64=", "ARG NEMOCLAW_HERMES_TOOL_GATEWAY_BROKER=0", "ARG NEMOCLAW_HERMES_TOOL_GATEWAY_PRESETS_B64=W10=", "ARG NEMOCLAW_BUILD_ID=default", @@ -2911,11 +2907,7 @@ buildContext.stageOptimizedSandboxBuildContext = () => { "ARG NEMOCLAW_INFERENCE_BASE_URL=https://inference.local/v1", "ARG NEMOCLAW_INFERENCE_API=openai-completions", "ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30=", - "ARG NEMOCLAW_MESSAGING_CHANNELS_B64=W10=", - "ARG NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=e30=", - "ARG NEMOCLAW_DISCORD_GUILDS_B64=e30=", - "ARG NEMOCLAW_TELEGRAM_CONFIG_B64=e30=", - "ARG NEMOCLAW_WECHAT_CONFIG_B64=e30=", + "ARG NEMOCLAW_MESSAGING_PLAN_B64=", "ARG NEMOCLAW_BUILD_ID=default", "ARG NEMOCLAW_DARWIN_VM_COMPAT=0", "CMD [\"/bin/bash\"]", diff --git a/test/openclaw-build-messaging-plugins.test.ts b/test/run-openclaw-build-hooks.test.ts similarity index 89% rename from test/openclaw-build-messaging-plugins.test.ts rename to test/run-openclaw-build-hooks.test.ts index cf4e74591c..4e29be9e54 100644 --- a/test/openclaw-build-messaging-plugins.test.ts +++ b/test/run-openclaw-build-hooks.test.ts @@ -2,19 +2,20 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 // -// Functional tests for scripts/openclaw-build-messaging-plugins.py. +// Functional tests for scripts/run-openclaw-build-hooks.mts. import { describe, it, expect } from "vitest"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { spawnSync } from "node:child_process"; +import { withLegacyMessagingPlanEnv } from "./messaging-plan-test-helper"; const SCRIPT_PATH = path.join( import.meta.dirname, "..", "scripts", - "openclaw-build-messaging-plugins.py", + "run-openclaw-build-hooks.mts", ); const GENERATOR_PATH = path.join( import.meta.dirname, @@ -44,13 +45,17 @@ function channelsB64(channels: string[]): string { } function runDryRun(envOverrides: Record = {}) { - return spawnSync("python3", [SCRIPT_PATH, "--dry-run"], { - encoding: "utf-8", - stdio: ["pipe", "pipe", "pipe"], - env: { + const env = withLegacyMessagingPlanEnv( + { PATH: process.env.PATH || "/usr/bin:/bin", ...envOverrides, }, + "openclaw", + ); + return spawnSync("node", ["--experimental-strip-types", SCRIPT_PATH, "--dry-run"], { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + env, timeout: 10_000, }); } @@ -61,7 +66,7 @@ function parseDryRun(envOverrides: Record = {}) { return JSON.parse(result.stdout); } -describe("openclaw-build-messaging-plugins.py", () => { +describe("run-openclaw-build-hooks.mts", () => { it("pins selected external messaging plugins to OPENCLAW_VERSION", () => { const payload = parseDryRun({ OPENCLAW_VERSION: "2026.5.22", @@ -138,14 +143,14 @@ describe("openclaw-build-messaging-plugins.py", () => { expect(result.stderr).toContain("OPENCLAW_VERSION is required"); }); - it("fails fast on malformed channel payloads", () => { + it("fails fast on malformed messaging plans", () => { const result = runDryRun({ OPENCLAW_VERSION: "2026.5.22", - NEMOCLAW_MESSAGING_CHANNELS_B64: "not-base64-json", + NEMOCLAW_MESSAGING_PLAN_B64: "not-base64-json", }); expect(result.status).not.toBe(0); - expect(result.stderr).toContain("NEMOCLAW_MESSAGING_CHANNELS_B64"); + expect(result.stderr).toContain("NEMOCLAW_MESSAGING_PLAN_B64"); }); it("runs pinned installs before doctor and limits doctor env injection to the doctor command", () => { @@ -164,10 +169,8 @@ describe("openclaw-build-messaging-plugins.py", () => { ); try { - const result = spawnSync("python3", [SCRIPT_PATH], { - encoding: "utf-8", - stdio: ["pipe", "pipe", "pipe"], - env: { + const planEnv = withLegacyMessagingPlanEnv( + { PATH: `${tmp}:${process.env.PATH || "/usr/bin:/bin"}`, OPENCLAW_TRACE: tracePath, OPENCLAW_VERSION: "2026.5.22", @@ -178,6 +181,12 @@ describe("openclaw-build-messaging-plugins.py", () => { "whatsapp", ]), }, + "openclaw", + ); + const result = spawnSync("node", ["--experimental-strip-types", SCRIPT_PATH], { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + env: planEnv, timeout: 10_000, }); @@ -233,21 +242,25 @@ describe("openclaw-build-messaging-plugins.py", () => { ); try { - const generatorResult = spawnSync("node", ["--experimental-strip-types", GENERATOR_PATH], { - encoding: "utf-8", - stdio: ["pipe", "pipe", "pipe"], - env: { + const generatorEnv = withLegacyMessagingPlanEnv( + { PATH: `${tmp}:${process.env.PATH || "/usr/bin:/bin"}`, HOME: tmp, ...BASE_GENERATOR_ENV, NEMOCLAW_MESSAGING_CHANNELS_B64: discordChannels, NEMOCLAW_OPENCLAW_MANAGED_PROXY: "0", }, + "openclaw", + ); + const generatorResult = spawnSync("node", ["--experimental-strip-types", GENERATOR_PATH], { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + env: generatorEnv, timeout: 10_000, }); expect(generatorResult.status, generatorResult.stderr).toBe(0); - const pluginResult = spawnSync("python3", [SCRIPT_PATH], { + const pluginResult = spawnSync("node", ["--experimental-strip-types", SCRIPT_PATH], { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], env: { @@ -255,7 +268,7 @@ describe("openclaw-build-messaging-plugins.py", () => { HOME: tmp, OPENCLAW_TRACE: tracePath, OPENCLAW_VERSION: "2026.5.22", - NEMOCLAW_MESSAGING_CHANNELS_B64: discordChannels, + NEMOCLAW_MESSAGING_PLAN_B64: generatorEnv.NEMOCLAW_MESSAGING_PLAN_B64, }, timeout: 10_000, }); diff --git a/test/sandbox-build-context.test.ts b/test/sandbox-build-context.test.ts index af8e8b3a0b..ffa9becef4 100644 --- a/test/sandbox-build-context.test.ts +++ b/test/sandbox-build-context.test.ts @@ -76,8 +76,7 @@ describe("sandbox build context staging", () => { writeFixture(path.join("scripts", "lib", "openclaw_device_approval_policy.py")); writeFixture(path.join("scripts", "lib", "clean_runtime_shell_env_shim.py")); writeFixture(path.join("scripts", "generate-openclaw-config.mts")); - writeFixture(path.join("scripts", "openclaw-build-messaging-plugins.py")); - writeFixture(path.join("scripts", "seed-wechat-accounts.py")); + writeFixture(path.join("scripts", "run-openclaw-build-hooks.mts")); writeFixture(path.join("scripts", "patch-openclaw-tool-catalog.js")); writeFixture(path.join("scripts", "patch-openclaw-chat-send.js")); } @@ -250,9 +249,8 @@ describe("sandbox build context staging", () => { true, ); expect( - fs.existsSync(path.join(buildCtx, "scripts", "openclaw-build-messaging-plugins.py")), + fs.existsSync(path.join(buildCtx, "scripts", "run-openclaw-build-hooks.mts")), ).toBe(true); - expect(fs.existsSync(path.join(buildCtx, "scripts", "seed-wechat-accounts.py"))).toBe(true); expect( fs.existsSync(path.join(buildCtx, "scripts", "lib", "openclaw_device_approval_policy.py")), ).toBe(true); diff --git a/test/sandbox-provisioning.test.ts b/test/sandbox-provisioning.test.ts index 0c171563ed..e9200ff710 100644 --- a/test/sandbox-provisioning.test.ts +++ b/test/sandbox-provisioning.test.ts @@ -861,8 +861,7 @@ describe("sandbox provisioning: copied OpenClaw helper permissions (#2861)", () path.join(localLib, "openclaw_device_approval_policy.py"), path.join(localLib, "clean_runtime_shell_env_shim.py"), path.join(localLib, "generate-openclaw-config.mts"), - path.join(localLib, "openclaw-build-messaging-plugins.py"), - path.join(localLib, "seed-wechat-accounts.py"), + path.join(localLib, "run-openclaw-build-hooks.mts"), path.join(localLib, "ws-proxy-fix.js"), pluginFile, nestedPluginFile, @@ -891,8 +890,8 @@ describe("sandbox provisioning: copied OpenClaw helper permissions (#2861)", () const generatorMode = ( fs.statSync(path.join(localLib, "generate-openclaw-config.mts")).mode & 0o777 ).toString(8); - const messagingPluginMode = ( - fs.statSync(path.join(localLib, "openclaw-build-messaging-plugins.py")).mode & 0o777 + const buildHookRunnerMode = ( + fs.statSync(path.join(localLib, "run-openclaw-build-hooks.mts")).mode & 0o777 ).toString(8); const approvalPolicyMode = ( fs.statSync(path.join(localLib, "openclaw_device_approval_policy.py")).mode & 0o777 @@ -902,7 +901,7 @@ describe("sandbox provisioning: copied OpenClaw helper permissions (#2861)", () const nestedPluginDirMode = (fs.statSync(nestedPluginDir).mode & 0o777).toString(8); const nestedPluginMode = (fs.statSync(nestedPluginFile).mode & 0o777).toString(8); expect(generatorMode).toBe("755"); - expect(messagingPluginMode).toBe("755"); + expect(buildHookRunnerMode).toBe("755"); expect(approvalPolicyMode).toBe("644"); expect(pluginDirMode).toBe("755"); expect(pluginMode).toBe("644"); diff --git a/test/seed-wechat-accounts.test.ts b/test/seed-wechat-accounts.test.ts deleted file mode 100644 index 126a849263..0000000000 --- a/test/seed-wechat-accounts.test.ts +++ /dev/null @@ -1,489 +0,0 @@ -// @ts-nocheck -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 -// -// Functional tests for scripts/seed-wechat-accounts.py. -// Runs the actual Python script with controlled env vars + a temp HOME and -// asserts on the on-disk state it leaves behind. Mirrors the spawn-and-read -// pattern from generate-openclaw-config.test.ts. - -import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { spawnSync } from "node:child_process"; - -const SCRIPT_PATH = path.join(import.meta.dirname, "..", "scripts", "seed-wechat-accounts.py"); - -const PLACEHOLDER = "openshell:resolve:env:WECHAT_BOT_TOKEN"; - -let tmpDir: string; - -function configB64(payload: Record): string { - return Buffer.from(JSON.stringify(payload)).toString("base64"); -} - -function channelsB64(channels: string[]): string { - return Buffer.from(JSON.stringify(channels)).toString("base64"); -} - -function runSeed(envOverrides: Record = {}) { - const env: Record = { - PATH: process.env.PATH || "/usr/bin:/bin", - HOME: tmpDir, - // Default to wechat-in-active-channels so existing tests exercise the - // openclaw.json-patching path. Tests that simulate `channels stop wechat` - // override this with `channelsB64([])` (or any list excluding wechat). - NEMOCLAW_MESSAGING_CHANNELS_B64: channelsB64(["wechat"]), - ...envOverrides, - }; - return spawnSync("python3", [SCRIPT_PATH], { - encoding: "utf-8", - stdio: ["pipe", "pipe", "pipe"], - env, - timeout: 10_000, - }); -} - -function writeOpenclawConfig(extra: Record = {}) { - const cfgDir = path.join(tmpDir, ".openclaw"); - fs.mkdirSync(cfgDir, { recursive: true }); - const cfgPath = path.join(cfgDir, "openclaw.json"); - const baseCfg = { gateway: { port: 1 }, channels: {}, ...extra }; - fs.writeFileSync(cfgPath, JSON.stringify(baseCfg, null, 2) + "\n"); - return cfgPath; -} - -function writeWeChatPluginMetadata(manifest: Record) { - const pluginDir = path.join(tmpDir, ".openclaw", "extensions", "openclaw-weixin"); - fs.mkdirSync(pluginDir, { recursive: true }); - fs.writeFileSync(path.join(pluginDir, "openclaw.plugin.json"), JSON.stringify(manifest, null, 2)); -} - -function writeWeChatNpmPackageMetadata(manifest: Record) { - const pluginDir = path.join( - tmpDir, - ".openclaw", - "npm", - "node_modules", - "@tencent-weixin", - "openclaw-weixin", - ); - fs.mkdirSync(pluginDir, { recursive: true }); - fs.writeFileSync(path.join(pluginDir, "package.json"), JSON.stringify(manifest, null, 2)); -} - -function wechatExtensionPath(stateDir = path.join(tmpDir, ".openclaw")) { - return path.join(fs.realpathSync(stateDir), "extensions", "openclaw-weixin"); -} - -function wechatNpmPackagePath(stateDir = path.join(tmpDir, ".openclaw")) { - return path.join( - fs.realpathSync(stateDir), - "npm", - "node_modules", - "@tencent-weixin", - "openclaw-weixin", - ); -} - -function readJson(p: string): any { - return JSON.parse(fs.readFileSync(p, "utf-8")); -} - -beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-seed-wechat-test-")); -}); - -afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); -}); - -describe("seed-wechat-accounts.py: gating", () => { - it("no-ops silently when NEMOCLAW_WECHAT_CONFIG_B64 is unset", () => { - // The script now runs unconditionally from generate-openclaw-config.mts - // on every build, so the "no host-side QR login was performed" path is - // the common case and must stay quiet — no stderr noise, no on-disk - // state under the plugin state dir. - const result = runSeed(); - expect(result.status).toBe(0); - expect(result.stderr).toBe(""); - const pluginDir = path.join(tmpDir, ".openclaw", "openclaw-weixin"); - expect(fs.existsSync(pluginDir)).toBe(false); - }); - - it("no-ops silently when accountId is missing from the config payload", () => { - // baseUrl + userId without accountId would leave the upstream plugin - // unable to pick a filename. Bail without writing — quietly, since this - // is reachable in non-WeChat onboards too. - const result = runSeed({ - NEMOCLAW_WECHAT_CONFIG_B64: configB64({ baseUrl: "https://x", userId: "u" }), - }); - expect(result.status).toBe(0); - expect(result.stderr).toBe(""); - const pluginDir = path.join(tmpDir, ".openclaw", "openclaw-weixin"); - expect(fs.existsSync(pluginDir)).toBe(false); - }); -}); - -describe("seed-wechat-accounts.py: per-account state files", () => { - it("writes accounts.json index and per-account file with placeholder token", () => { - writeOpenclawConfig(); - const result = runSeed({ - NEMOCLAW_WECHAT_CONFIG_B64: configB64({ - accountId: "primary", - baseUrl: "https://ilinkai.wechat.com", - userId: "user-42", - }), - }); - expect(result.status).toBe(0); - - const pluginDir = path.join(tmpDir, ".openclaw", "openclaw-weixin"); - const index = readJson(path.join(pluginDir, "accounts.json")); - expect(index).toEqual(["primary"]); - - const account = readJson(path.join(pluginDir, "accounts", "primary.json")); - expect(account.token).toBe(PLACEHOLDER); - expect(account.baseUrl).toBe("https://ilinkai.wechat.com"); - expect(account.userId).toBe("user-42"); - // savedAt must be a parseable ISO timestamp (the upstream plugin reads it). - expect(Number.isNaN(Date.parse(account.savedAt))).toBe(false); - }); - - it("omits baseUrl and userId when they are absent in the config", () => { - writeOpenclawConfig(); - const result = runSeed({ - NEMOCLAW_WECHAT_CONFIG_B64: configB64({ accountId: "primary" }), - }); - expect(result.status).toBe(0); - - const account = readJson( - path.join(tmpDir, ".openclaw", "openclaw-weixin", "accounts", "primary.json"), - ); - expect(account.token).toBe(PLACEHOLDER); - expect("baseUrl" in account).toBe(false); - expect("userId" in account).toBe(false); - }); - - it("appends to an existing accounts.json instead of overwriting", () => { - // Append-only invariant: a prior seed (or upstream-plugin save) must not - // be clobbered when a second accountId is registered. - writeOpenclawConfig(); - const pluginDir = path.join(tmpDir, ".openclaw", "openclaw-weixin"); - fs.mkdirSync(pluginDir, { recursive: true }); - fs.writeFileSync(path.join(pluginDir, "accounts.json"), JSON.stringify(["old"]) + "\n"); - - const result = runSeed({ - NEMOCLAW_WECHAT_CONFIG_B64: configB64({ accountId: "new-one" }), - }); - expect(result.status).toBe(0); - - const index = readJson(path.join(pluginDir, "accounts.json")); - expect(index).toEqual(["old", "new-one"]); - }); - - it("does not duplicate an accountId already present in the index", () => { - writeOpenclawConfig(); - const pluginDir = path.join(tmpDir, ".openclaw", "openclaw-weixin"); - fs.mkdirSync(pluginDir, { recursive: true }); - fs.writeFileSync(path.join(pluginDir, "accounts.json"), JSON.stringify(["primary"]) + "\n"); - - const result = runSeed({ - NEMOCLAW_WECHAT_CONFIG_B64: configB64({ accountId: "primary" }), - }); - expect(result.status).toBe(0); - - const index = readJson(path.join(pluginDir, "accounts.json")); - expect(index).toEqual(["primary"]); - }); - - it("respects OPENCLAW_STATE_DIR as the state-dir override", () => { - const altState = path.join(tmpDir, "alt-state"); - fs.mkdirSync(altState, { recursive: true }); - fs.writeFileSync( - path.join(altState, "openclaw.json"), - JSON.stringify({ channels: {} }, null, 2) + "\n", - ); - - const result = runSeed({ - NEMOCLAW_WECHAT_CONFIG_B64: configB64({ accountId: "primary" }), - OPENCLAW_STATE_DIR: altState, - }); - expect(result.status).toBe(0); - - expect(fs.existsSync(path.join(altState, "openclaw-weixin", "accounts.json"))).toBe(true); - expect(fs.existsSync(path.join(tmpDir, ".openclaw", "openclaw-weixin"))).toBe(false); - }); -}); - -describe("seed-wechat-accounts.py: openclaw.json patching (channels.openclaw-weixin)", () => { - it("registers channels.openclaw-weixin.accounts..enabled=true", () => { - // Without enabled=true the upstream plugin's auth/accounts.ts treats the - // account as disabled and the bridge no-ops. This is the load-bearing - // bit of the post-install patch. - writeOpenclawConfig(); - const result = runSeed({ - NEMOCLAW_WECHAT_CONFIG_B64: configB64({ accountId: "primary" }), - }); - expect(result.status).toBe(0); - - const cfg = readJson(path.join(tmpDir, ".openclaw", "openclaw.json")); - expect(cfg.channels["openclaw-weixin"].accounts.primary.enabled).toBe(true); - }); - - it("derives the WeChat channel id from installed plugin metadata", () => { - writeOpenclawConfig(); - writeWeChatPluginMetadata({ - id: "openclaw-weixin", - channels: ["vendor-weixin"], - channelConfigs: { "vendor-weixin": {} }, - }); - const result = runSeed({ - NEMOCLAW_WECHAT_CONFIG_B64: configB64({ accountId: "primary" }), - }); - expect(result.status).toBe(0); - - const cfg = readJson(path.join(tmpDir, ".openclaw", "openclaw.json")); - expect(cfg.channels["vendor-weixin"].accounts.primary.enabled).toBe(true); - }); - - it("keeps the legacy openclaw-weixin channel registration for older plugin loads", () => { - writeOpenclawConfig(); - writeWeChatPluginMetadata({ - id: "openclaw-weixin", - channels: ["vendor-weixin"], - channelConfigs: { "vendor-weixin": {} }, - }); - runSeed({ - NEMOCLAW_WECHAT_CONFIG_B64: configB64({ accountId: "primary" }), - }); - - const cfg = readJson(path.join(tmpDir, ".openclaw", "openclaw.json")); - expect(cfg.channels["vendor-weixin"].accounts.primary.enabled).toBe(true); - expect(cfg.channels["openclaw-weixin"].accounts.primary.enabled).toBe(true); - }); - - it("writes a channelConfigUpdatedAt in JS Date.toISOString() shape (ms + 'Z')", () => { - // The upstream plugin compares this string with values it produces via - // Date.toISOString(). A Python isoformat() with offset would diverge. - writeOpenclawConfig(); - runSeed({ - NEMOCLAW_WECHAT_CONFIG_B64: configB64({ accountId: "primary" }), - }); - - const cfg = readJson(path.join(tmpDir, ".openclaw", "openclaw.json")); - const updatedAt = cfg.channels["openclaw-weixin"].channelConfigUpdatedAt; - expect(updatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); - }); - - it("preserves existing unrelated keys in openclaw.json", () => { - // The patch must merge into the existing config — clobbering gateway or - // other channels would break everything else generate-openclaw-config.mts - // wrote moments earlier. - writeOpenclawConfig({ - gateway: { port: 9999, marker: "keep-me" }, - channels: { telegram: { accounts: { default: { enabled: true } } } }, - }); - runSeed({ - NEMOCLAW_WECHAT_CONFIG_B64: configB64({ accountId: "primary" }), - }); - - const cfg = readJson(path.join(tmpDir, ".openclaw", "openclaw.json")); - expect(cfg.gateway).toEqual({ port: 9999, marker: "keep-me" }); - expect(cfg.channels.telegram.accounts.default.enabled).toBe(true); - expect(cfg.channels["openclaw-weixin"].accounts.primary.enabled).toBe(true); - }); - - it("restores plugin registration and channel block after a later OpenClaw config rewrite drops them", () => { - // The Dockerfile invokes this seed script again after OpenClaw doctor and - // plugin installation because those commands can rewrite openclaw.json - // after generate-openclaw-config.mts first runs. Re-running the seed must - // be enough to put the upstream WeChat plugin and channel registration - // back; otherwise the gateway rejects channels.openclaw-weixin as an - // unknown channel id at startup. - writeOpenclawConfig({ - channels: { - telegram: { accounts: { default: { enabled: true } } }, - slack: { accounts: { default: { enabled: true } } }, - }, - plugins: {}, - }); - - const result = runSeed({ - NEMOCLAW_WECHAT_CONFIG_B64: configB64({ - accountId: "primary", - baseUrl: "https://ilinkai.wechat.com", - userId: "wxid-42", - }), - }); - expect(result.status).toBe(0); - - const cfg = readJson(path.join(tmpDir, ".openclaw", "openclaw.json")); - expect(cfg.plugins.installs["openclaw-weixin"]).toEqual({ - source: "npm", - spec: "@tencent-weixin/openclaw-weixin@2.4.3", - installPath: wechatExtensionPath(), - }); - expect(cfg.plugins.load.paths).toEqual([wechatExtensionPath()]); - expect(cfg.plugins.entries["openclaw-weixin"].enabled).toBe(true); - expect(Object.keys(cfg.channels)).toEqual(["telegram", "slack", "openclaw-weixin"]); - expect(cfg.channels["openclaw-weixin"].accounts.primary.enabled).toBe(true); - }); - - it("uses OpenClaw's npm package install path when no legacy extension directory exists", () => { - writeOpenclawConfig({ - plugins: { - installs: { - "openclaw-weixin": { - source: "npm", - spec: "@tencent-weixin/openclaw-weixin@2.4.3", - }, - }, - }, - }); - writeWeChatNpmPackageMetadata({ - name: "@tencent-weixin/openclaw-weixin", - openclaw: { channels: ["vendor-weixin"] }, - }); - - const result = runSeed({ - NEMOCLAW_WECHAT_CONFIG_B64: configB64({ accountId: "primary" }), - }); - expect(result.status).toBe(0); - - const cfg = readJson(path.join(tmpDir, ".openclaw", "openclaw.json")); - expect(cfg.plugins.installs["openclaw-weixin"]).toEqual({ - source: "npm", - spec: "@tencent-weixin/openclaw-weixin@2.4.3", - installPath: wechatNpmPackagePath(), - }); - expect(cfg.plugins.load.paths).toEqual([wechatNpmPackagePath()]); - expect(cfg.channels["vendor-weixin"].accounts.primary.enabled).toBe(true); - expect(cfg.channels["openclaw-weixin"].accounts.primary.enabled).toBe(true); - expect(fs.existsSync(wechatExtensionPath())).toBe(false); - }); - - it("preserves existing plugin load paths and appends the WeChat extension path", () => { - writeOpenclawConfig({ - plugins: { - load: { paths: ["/opt/custom-openclaw-plugin"] }, - installs: { - "openclaw-weixin": { - source: "npm", - spec: "@tencent-weixin/openclaw-weixin@2.4.2", - installPath: "/already/installed/openclaw-weixin", - pinned: true, - }, - }, - }, - }); - - const result = runSeed({ - NEMOCLAW_WECHAT_CONFIG_B64: configB64({ accountId: "primary" }), - }); - expect(result.status).toBe(0); - - const cfg = readJson(path.join(tmpDir, ".openclaw", "openclaw.json")); - expect(cfg.plugins.installs["openclaw-weixin"]).toEqual({ - source: "npm", - spec: "@tencent-weixin/openclaw-weixin@2.4.2", - installPath: "/already/installed/openclaw-weixin", - pinned: true, - }); - expect(cfg.plugins.load.paths).toEqual([ - "/opt/custom-openclaw-plugin", - "/already/installed/openclaw-weixin", - ]); - expect(cfg.channels["openclaw-weixin"].accounts.primary.enabled).toBe(true); - }); - - it("bails (and warns) when openclaw.json is missing — does not invent a config", () => { - // generate-openclaw-config.mts runs first and is responsible for producing - // openclaw.json. If it failed silently, we'd rather print a warning than - // create a half-formed file from this script's narrow vantage point. - const result = runSeed({ - NEMOCLAW_WECHAT_CONFIG_B64: configB64({ accountId: "primary" }), - }); - expect(result.status).toBe(0); - expect(result.stderr).toContain("not found; cannot register channel"); - expect(fs.existsSync(path.join(tmpDir, ".openclaw", "openclaw.json"))).toBe(false); - - // Per-account state files must still have been written (they sit in the - // plugin's own state dir, not openclaw.json). - const pluginDir = path.join(tmpDir, ".openclaw", "openclaw-weixin"); - expect(fs.existsSync(path.join(pluginDir, "accounts.json"))).toBe(true); - }); - - it("survives a corrupted openclaw.json without crashing", () => { - const cfgPath = path.join(tmpDir, ".openclaw", "openclaw.json"); - fs.mkdirSync(path.dirname(cfgPath), { recursive: true }); - fs.writeFileSync(cfgPath, "{not valid json"); - const result = runSeed({ - NEMOCLAW_WECHAT_CONFIG_B64: configB64({ accountId: "primary" }), - }); - expect(result.status).toBe(0); - expect(result.stderr).toContain("could not parse"); - // Original (broken) file is left intact for a human to inspect. - expect(fs.readFileSync(cfgPath, "utf-8")).toBe("{not valid json"); - }); -}); - -describe("seed-wechat-accounts.py: stopped-channel preservation", () => { - // When NEMOCLAW_MESSAGING_CHANNELS_B64 omits wechat (operator ran - // `channels stop wechat` before rebuild) we still want the per-account - // state files on disk so a later `channels start wechat` rebuild can - // revive the bridge without a fresh QR scan. The openclaw.json patch is - // what we suppress — without channels.openclaw-weixin.accounts..enabled - // the upstream plugin treats the account as inactive and the bridge - // no-ops, even though the placeholder token + baseUrl/userId are present - // in the accounts file. - - it("writes account state files but skips openclaw.json patch when wechat is not in active channels", () => { - writeOpenclawConfig({ gateway: { port: 7777 } }); - const result = runSeed({ - NEMOCLAW_MESSAGING_CHANNELS_B64: channelsB64(["telegram"]), - NEMOCLAW_WECHAT_CONFIG_B64: configB64({ - accountId: "primary", - baseUrl: "https://ilinkai.wechat.com", - userId: "wxid-42", - }), - }); - expect(result.status).toBe(0); - expect(result.stderr).toBe(""); - expect(result.stdout).toContain("wechat not in active channels"); - - // Per-account files survive — ready for the next `channels start`. - const account = readJson( - path.join(tmpDir, ".openclaw", "openclaw-weixin", "accounts", "primary.json"), - ); - expect(account.token).toBe(PLACEHOLDER); - expect(account.baseUrl).toBe("https://ilinkai.wechat.com"); - expect(account.userId).toBe("wxid-42"); - const index = readJson(path.join(tmpDir, ".openclaw", "openclaw-weixin", "accounts.json")); - expect(index).toEqual(["primary"]); - - // openclaw.json must not have the channel block, but the unrelated - // gateway key the test seeded earlier must survive untouched. - const cfg = readJson(path.join(tmpDir, ".openclaw", "openclaw.json")); - expect(cfg.channels?.["openclaw-weixin"]).toBeUndefined(); - expect(cfg.gateway).toEqual({ port: 7777 }); - }); - - it("treats an empty channel list as 'wechat stopped'", () => { - // Defensive: a malformed/empty NEMOCLAW_MESSAGING_CHANNELS_B64 must - // not silently re-enable wechat. Account state still gets written for - // recovery, the channel block does not. - writeOpenclawConfig(); - const result = runSeed({ - NEMOCLAW_MESSAGING_CHANNELS_B64: channelsB64([]), - NEMOCLAW_WECHAT_CONFIG_B64: configB64({ accountId: "primary" }), - }); - expect(result.status).toBe(0); - - expect( - fs.existsSync(path.join(tmpDir, ".openclaw", "openclaw-weixin", "accounts", "primary.json")), - ).toBe(true); - const cfg = readJson(path.join(tmpDir, ".openclaw", "openclaw.json")); - expect(cfg.channels?.["openclaw-weixin"]).toBeUndefined(); - }); -}); From 3297aa695cc95f21625a2a75ab4786313f1fc0bd Mon Sep 17 00:00:00 2001 From: San Dang Date: Mon, 8 Jun 2026 19:26:19 +0530 Subject: [PATCH 02/23] refactor(messaging): consolidate build applier --- Dockerfile | 38 +- agents/hermes/Dockerfile | 13 +- agents/hermes/config/manifest-hooks.ts | 202 ---- agents/hermes/generate-config.ts | 11 +- ci/test-file-size-budget.json | 2 +- scripts/generate-openclaw-config.mts | 229 +--- scripts/run-openclaw-build-hooks.mts | 249 ---- .../applier/build/messaging-build-applier.mts | 1025 +++++++++++++++++ src/lib/sandbox/build-context.ts | 24 +- test/fetch-guard-patch-regression.test.ts | 2 +- test/generate-hermes-config.test.ts | 71 +- ...est.ts => messaging-build-applier.test.ts} | 194 +++- ...test.ts => openclaw-config-render.test.ts} | 272 ++--- test/sandbox-build-context.test.ts | 34 +- test/sandbox-provisioning.test.ts | 29 +- test/security-c2-dockerfile-injection.test.ts | 3 +- 16 files changed, 1442 insertions(+), 956 deletions(-) delete mode 100644 agents/hermes/config/manifest-hooks.ts delete mode 100755 scripts/run-openclaw-build-hooks.mts create mode 100755 src/lib/messaging/applier/build/messaging-build-applier.mts rename test/{run-openclaw-build-hooks.test.ts => messaging-build-applier.test.ts} (59%) rename test/{generate-openclaw-config.test.ts => openclaw-config-render.test.ts} (91%) diff --git a/Dockerfile b/Dockerfile index 7fa3037abb..2486de77fd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -409,13 +409,14 @@ COPY scripts/nemoclaw-start.sh /usr/local/bin/nemoclaw-start # needs to read these files to install runtime preloads under /tmp. COPY nemoclaw-blueprint/scripts/*.js /usr/local/lib/nemoclaw/preloads/ COPY scripts/codex-acp-wrapper.sh /usr/local/bin/nemoclaw-codex-acp -COPY scripts/generate-openclaw-config.mts /usr/local/lib/nemoclaw/generate-openclaw-config.mts -COPY scripts/run-openclaw-build-hooks.mts /usr/local/lib/nemoclaw/run-openclaw-build-hooks.mts +COPY scripts/generate-openclaw-config.mts /scripts/generate-openclaw-config.mts +COPY src/lib/messaging/ /src/lib/messaging/ COPY nemoclaw-blueprint/openclaw-plugins/ /usr/local/share/nemoclaw/openclaw-plugins/ RUN chmod 755 /usr/local/bin/nemoclaw-start /usr/local/bin/nemoclaw-codex-acp \ /usr/local/lib/nemoclaw/sandbox-init.sh \ - /usr/local/lib/nemoclaw/generate-openclaw-config.mts \ - /usr/local/lib/nemoclaw/run-openclaw-build-hooks.mts \ + /scripts/generate-openclaw-config.mts \ + /src/lib/messaging/applier/build/messaging-build-applier.mts \ + && chmod -R a+rX /src/lib/messaging \ && chmod 644 /usr/local/lib/nemoclaw/openclaw_device_approval_policy.py \ /usr/local/lib/nemoclaw/clean_runtime_shell_env_shim.py \ && if [ -d /usr/local/lib/nemoclaw/preloads ]; then find /usr/local/lib/nemoclaw/preloads -type f -name '*.js' -exec chmod 644 {} +; fi \ @@ -452,7 +453,7 @@ ARG NEMOCLAW_AGENT_TIMEOUT=600 # change at image build time. Ref: issue #2880 ARG NEMOCLAW_AGENT_HEARTBEAT_EVERY= ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30= -# Base64-encoded manifest hook plan for messaging build inputs and agent +# Base64-encoded messaging build plan for messaging build inputs and agent # rendering. The plan contains placeholders only; secrets are resolved at # runtime via OpenShell providers. ARG NEMOCLAW_MESSAGING_PLAN_B64= @@ -536,18 +537,26 @@ USER sandbox # is opt-in via `shields up` (DAC 444 root:root + chattr +i). # Build args (NEMOCLAW_MODEL, CHAT_UI_URL) customize per deployment. # -# Generate openclaw.json from environment variables. Config generation logic -# lives in scripts/generate-openclaw-config.mts — see that file for the full -# list of env vars and derivation rules. +# Generate base openclaw.json from environment variables. Messaging build +# steps run through src/lib/messaging/applier/build/messaging-build-applier.mts. # # OpenClaw's managed proxy config activates process-wide HTTP_PROXY/HTTPS_PROXY # for child npm processes. During image build the OpenShell gateway is not # available at the runtime sandbox proxy address yet, so defer the final proxy # block until after build-time OpenClaw doctor/plugin commands complete. -RUN NEMOCLAW_OPENCLAW_MANAGED_PROXY=0 node --experimental-strip-types /usr/local/lib/nemoclaw/generate-openclaw-config.mts +RUN NEMOCLAW_OPENCLAW_MANAGED_PROXY=0 node --experimental-strip-types /scripts/generate-openclaw-config.mts +# Install the non-messaging OpenClaw diagnostics plugin when OTEL is enabled. # hadolint ignore=DL3059,DL4006 -RUN node --experimental-strip-types /usr/local/lib/nemoclaw/run-openclaw-build-hooks.mts +RUN set -eu; \ + if [ "$NEMOCLAW_OPENCLAW_OTEL" = "1" ]; then \ + test -n "$OPENCLAW_VERSION"; \ + openclaw plugins install "npm:@openclaw/diagnostics-otel@${OPENCLAW_VERSION}" --pin; \ + openclaw doctor --fix --non-interactive; \ + fi + +# hadolint ignore=DL3059,DL4006 +RUN node --experimental-strip-types /src/lib/messaging/applier/build/messaging-build-applier.mts --agent openclaw --phase agent-install # Lock down npm for the next RUN: the local OpenClaw plugin install must # resolve from /opt/nemoclaw and the staged plugin-runtime-deps tree without @@ -561,8 +570,9 @@ ENV NPM_CONFIG_OFFLINE=true \ # This must fail the image build if registration fails; otherwise the sandbox # can boot with a discoverable plugin manifest but without the /nemoclaw runtime # command registered in the active Gateway. -# WeChat account seed files are written during config generation from -# serialized manifest hook build-file outputs before the sandbox starts. +# Messaging post-agent-install hooks run after the OpenClaw agent and +# NemoClaw plugin are installed; for example, WeChat seed files are written +# from messaging hook build-file outputs before the sandbox starts. # Prune non-runtime metadata from staged bundled plugin dependencies before # this layer is committed; deleting it in a later layer would not reduce the # OCI image imported by k3s. @@ -581,6 +591,10 @@ RUN openclaw plugins install /opt/nemoclaw \ \) -prune -exec rm -rf {} +; \ fi +# Apply messaging render and post-agent-install build-file hooks after agent/plugin installation. +# hadolint ignore=DL3059,DL4006 +RUN node --experimental-strip-types /src/lib/messaging/applier/build/messaging-build-applier.mts --agent openclaw --phase post-agent-install + # Release the offline lock so the runtime sandbox can install MCP servers, # skills, and ad-hoc packages via the OpenShell L7 proxy. ENV NPM_CONFIG_OFFLINE=false diff --git a/agents/hermes/Dockerfile b/agents/hermes/Dockerfile index 1d68f0e614..da3ad8b861 100644 --- a/agents/hermes/Dockerfile +++ b/agents/hermes/Dockerfile @@ -78,8 +78,10 @@ RUN chmod -R a+rX /opt/nemoclaw-hermes-plugin/ COPY agents/hermes/generate-config.ts /opt/nemoclaw-hermes-config/generate-config.ts COPY agents/hermes/config/ /opt/nemoclaw-hermes-config/config/ COPY agents/hermes/host/managed-tool-gateway-matrix.json /opt/nemoclaw-hermes-config/managed-tool-gateway-matrix.json +COPY src/lib/messaging/ /src/lib/messaging/ RUN find /opt/nemoclaw-hermes-config -type d -exec chmod 755 {} + \ - && find /opt/nemoclaw-hermes-config -type f -exec chmod 444 {} + + && find /opt/nemoclaw-hermes-config -type f -exec chmod 444 {} + \ + && chmod -R a+rX /src/lib/messaging # Copy blueprint (shared infrastructure) COPY nemoclaw-blueprint/ /opt/nemoclaw-blueprint/ @@ -130,10 +132,19 @@ RUN mkdir -p /sandbox/.nemoclaw/blueprints/0.1.0 \ # code injection via build-arg interpolation (same concern as OpenClaw C-2). RUN node --experimental-strip-types /opt/nemoclaw-hermes-config/generate-config.ts +# Apply messaging agent-install hooks before Hermes plugin installation. +# hadolint ignore=DL3059 +RUN node --experimental-strip-types /src/lib/messaging/applier/build/messaging-build-applier.mts --agent hermes --phase agent-install + # Install NemoClaw plugin into Hermes +# hadolint ignore=DL3059 RUN mkdir -p /sandbox/.hermes/plugins/nemoclaw \ && cp -r /opt/nemoclaw-hermes-plugin/* /sandbox/.hermes/plugins/nemoclaw/ +# Apply messaging render and post-agent-install build-file hooks after agent/plugin installation. +# hadolint ignore=DL3059 +RUN node --experimental-strip-types /src/lib/messaging/applier/build/messaging-build-applier.mts --agent hermes --phase post-agent-install + # Write the default SOUL.md (agent identity) for the sandboxed agent. # This is the stock Hermes default soul (hermes_cli/default_soul.py, # DEFAULT_SOUL_MD) shipped verbatim. The OpenShell/NemoClaw environment is diff --git a/agents/hermes/config/manifest-hooks.ts b/agents/hermes/config/manifest-hooks.ts deleted file mode 100644 index 294b3fbaf1..0000000000 --- a/agents/hermes/config/manifest-hooks.ts +++ /dev/null @@ -1,202 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { Buffer } from "node:buffer"; - -type JsonObject = Record; - -type ManifestHookRenderResult = { - readonly appliedHooks: readonly string[]; - readonly appliedTargets: readonly string[]; - readonly unresolvedTemplateRefs: readonly string[]; -}; - -type MessagingRenderEntry = { - readonly channelId: string; - readonly agent: string; - readonly target: string; - readonly kind: "json-fragment" | "env-lines"; - readonly renderId?: string; - readonly hookId?: string; - readonly handler?: string; - readonly path?: string; - readonly value?: unknown; - readonly lines?: readonly string[]; - readonly templateRefs?: readonly string[]; -}; - -type HermesManifestHookPlan = { - readonly schemaVersion: 1; - readonly agent: "hermes"; - readonly channels: readonly { - readonly channelId: string; - readonly active?: boolean; - readonly disabled?: boolean; - }[]; - readonly agentRender: readonly MessagingRenderEntry[]; -}; - -const HERMES_CONFIG_TARGET = "~/.hermes/config.yaml"; -const HERMES_ENV_TARGET = "~/.hermes/.env"; - -export function readHermesManifestHookPlan( - env: NodeJS.ProcessEnv, -): HermesManifestHookPlan | null { - const encoded = env.NEMOCLAW_MESSAGING_PLAN_B64; - if (!encoded || encoded.trim() === "") return null; - - const parsed = JSON.parse(Buffer.from(encoded, "base64").toString("utf-8")) as unknown; - if ( - !isObject(parsed) || - parsed.schemaVersion !== 1 || - parsed.agent !== "hermes" || - !Array.isArray(parsed.channels) || - !Array.isArray(parsed.agentRender) - ) { - throw new Error("NEMOCLAW_MESSAGING_PLAN_B64 must contain a hermes messaging plan"); - } - - return parsed as HermesManifestHookPlan; -} - -export function applyHermesManifestHookRender( - config: JsonObject, - envLines: string[], - plan: HermesManifestHookPlan | null, -): ManifestHookRenderResult { - if (!plan) { - return { appliedHooks: [], appliedTargets: [], unresolvedTemplateRefs: [] }; - } - - const activeChannels = new Set( - plan.channels - .filter((channel) => channel.active === true && channel.disabled !== true) - .map((channel) => channel.channelId), - ); - const appliedHooks: string[] = []; - const appliedTargets: string[] = []; - const unresolvedTemplateRefs: string[] = []; - - for (const render of plan.agentRender) { - if (render.agent !== "hermes" || !activeChannels.has(render.channelId)) continue; - unresolvedTemplateRefs.push(...(render.templateRefs ?? [])); - if (render.kind === "json-fragment") { - applyJsonRender(config, render); - appliedTargets.push(render.target); - if (render.hookId) appliedHooks.push(`${render.channelId}:${render.hookId}`); - continue; - } - applyEnvRender(envLines, render); - appliedTargets.push(render.target); - if (render.hookId) appliedHooks.push(`${render.channelId}:${render.hookId}`); - } - - return { - appliedHooks: uniqueStrings(appliedHooks), - appliedTargets: uniqueStrings(appliedTargets), - unresolvedTemplateRefs: uniqueStrings(unresolvedTemplateRefs), - }; -} - -function applyJsonRender(config: JsonObject, render: MessagingRenderEntry): void { - if (render.target !== HERMES_CONFIG_TARGET) { - throw new Error(`Hermes manifest hook render target is not supported: ${render.target}`); - } - if (typeof render.path !== "string") { - throw new Error( - `Hermes manifest hook render '${render.renderId ?? render.channelId}' is missing a path.`, - ); - } - setJsonPath(config, render.path, render.value); -} - -function applyEnvRender(envLines: string[], render: MessagingRenderEntry): void { - if (render.target !== HERMES_ENV_TARGET) { - throw new Error(`Hermes manifest hook render target is not supported: ${render.target}`); - } - if (!Array.isArray(render.lines)) { - throw new Error( - `Hermes manifest hook render '${render.renderId ?? render.channelId}' is missing env lines.`, - ); - } - mergeEnvLines(envLines, render.lines); -} - -function setJsonPath(root: JsonObject, path: string, value: unknown): void { - const segments = path.split(".").filter(Boolean); - if (segments.length === 0) throw new Error("Hermes manifest hook render path must not be empty."); - let cursor = root; - for (const segment of segments.slice(0, -1)) { - assertSafeObjectKey(segment); - if (!isObject(cursor[segment])) cursor[segment] = {}; - cursor = cursor[segment] as JsonObject; - } - const finalSegment = segments[segments.length - 1] as string; - assertSafeObjectKey(finalSegment); - if (isObject(cursor[finalSegment]) && isObject(value)) { - mergeObjects(cursor[finalSegment] as JsonObject, value as JsonObject); - return; - } - cursor[finalSegment] = value; -} - -function mergeObjects(target: JsonObject, patch: JsonObject): void { - for (const [key, value] of Object.entries(patch)) { - assertSafeObjectKey(key); - const existing = target[key]; - if (isObject(existing) && isObject(value)) { - mergeObjects(existing as JsonObject, value as JsonObject); - } else if (Array.isArray(existing) && Array.isArray(value)) { - target[key] = [...new Set([...existing, ...value])]; - } else { - target[key] = value; - } - } -} - -function mergeEnvLines(existingLines: string[], desiredLines: readonly string[]): void { - const desired = new Map(); - const rawDesiredLines: string[] = []; - for (const line of desiredLines) { - const key = readEnvLineKey(line); - if (key) { - desired.set(key, line); - } else { - rawDesiredLines.push(line); - } - } - - const written = new Set(); - for (const [index, line] of existingLines.entries()) { - const key = readEnvLineKey(line); - if (!key || !desired.has(key)) continue; - existingLines[index] = desired.get(key) as string; - written.add(key); - } - - for (const [key, line] of desired) { - if (!written.has(key)) existingLines.push(line); - } - existingLines.push(...rawDesiredLines); -} - -function readEnvLineKey(line: string): string | null { - const index = line.indexOf("="); - if (index <= 0) return null; - const key = line.slice(0, index).trim(); - return key.length > 0 ? key : null; -} - -function assertSafeObjectKey(key: string): void { - if (key === "__proto__" || key === "prototype" || key === "constructor") { - throw new Error(`Hermes manifest hook render rejected unsafe object key '${key}'.`); - } -} - -function isObject(value: unknown): value is JsonObject { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function uniqueStrings(values: readonly string[]): string[] { - return [...new Set(values)]; -} diff --git a/agents/hermes/generate-config.ts b/agents/hermes/generate-config.ts index e8577a1d70..cc20d8e2ed 100644 --- a/agents/hermes/generate-config.ts +++ b/agents/hermes/generate-config.ts @@ -5,19 +5,15 @@ // // Called at Docker image build time. Reads NEMOCLAW_* env vars and writes: // ~/.hermes/config.yaml — Hermes configuration (immutable at runtime) -// ~/.hermes/.env — Messaging token placeholders (immutable at runtime) +// ~/.hermes/.env — Base environment placeholders (immutable at runtime) // // Sets what's required for Hermes to run inside OpenShell: // - Model and inference endpoint (custom provider pointing at inference.local) // - API server on internal port (socat forwards to public port) -// - Messaging platform tokens (if configured during onboard) +// - Base environment entries used by Hermes inside OpenShell // - Agent defaults (terminal, memory, skills, display) import { readHermesBuildSettings } from "./config/build-env.ts"; -import { - applyHermesManifestHookRender, - readHermesManifestHookPlan, -} from "./config/manifest-hooks.ts"; import { buildHermesEnvLines } from "./config/hermes-env.ts"; import { buildHermesConfig, finalizeHermesPlatformToolsets } from "./config/hermes-config.ts"; import { discoverModelSpecificSetups } from "./config/model-specific-setup.ts"; @@ -25,8 +21,6 @@ import { writeHermesConfigFiles } from "./config/write-config.ts"; function main(): void { const settings = readHermesBuildSettings(process.env); - const messagingPlan = readHermesManifestHookPlan(process.env); - discoverModelSpecificSetups( "hermes", { @@ -43,7 +37,6 @@ function main(): void { const config = buildHermesConfig(settings); const envLines = buildHermesEnvLines(settings); - applyHermesManifestHookRender(config, envLines, messagingPlan); finalizeHermesPlatformToolsets(config, settings); const written = writeHermesConfigFiles(config, envLines); diff --git a/ci/test-file-size-budget.json b/ci/test-file-size-budget.json index 50dba967c4..e5458c88b9 100644 --- a/ci/test-file-size-budget.json +++ b/ci/test-file-size-budget.json @@ -6,7 +6,7 @@ "src/lib/inference/nim.test.ts": 2079, "src/lib/onboard/preflight.test.ts": 1905, "test/channels-add-preset.test.ts": 1915, - "test/generate-openclaw-config.test.ts": 2106, + "test/openclaw-config-render.test.ts": 2005, "test/install-preflight.test.ts": 4397, "test/nemoclaw-start.test.ts": 5300, "test/onboard-messaging.test.ts": 2122, diff --git a/scripts/generate-openclaw-config.mts b/scripts/generate-openclaw-config.mts index 8525893854..4709a288b3 100755 --- a/scripts/generate-openclaw-config.mts +++ b/scripts/generate-openclaw-config.mts @@ -14,7 +14,7 @@ // NEMOCLAW_INFERENCE_INPUTS, NEMOCLAW_CONTEXT_WINDOW, // NEMOCLAW_MAX_TOKENS, NEMOCLAW_REASONING, // NEMOCLAW_AGENT_TIMEOUT, NEMOCLAW_AGENT_HEARTBEAT_EVERY, -// NEMOCLAW_INFERENCE_COMPAT_B64, NEMOCLAW_MESSAGING_PLAN_B64, +// NEMOCLAW_INFERENCE_COMPAT_B64, // NEMOCLAW_DISABLE_DEVICE_AUTH, // NEMOCLAW_EXTRA_AGENTS_JSON_B64, // NEMOCLAW_PROXY_HOST, NEMOCLAW_PROXY_PORT, @@ -37,223 +37,6 @@ import { fileURLToPath, pathToFileURL } from "node:url"; type Env = Record; type JsonObject = Record; -type MessagingPlan = { - readonly schemaVersion: 1; - readonly agent: string; - readonly channels: readonly MessagingPlanChannel[]; - readonly agentRender: readonly MessagingRenderEntry[]; - readonly buildSteps: readonly MessagingBuildStep[]; -}; - -type MessagingPlanChannel = { - readonly channelId: string; - readonly active?: boolean; - readonly disabled?: boolean; -}; - -type MessagingRenderEntry = { - readonly channelId: string; - readonly agent: string; - readonly target: string; - readonly kind: "json-fragment" | "env-lines"; - readonly path?: string; - readonly value?: unknown; -}; - -type MessagingBuildStep = { - readonly channelId: string; - readonly kind: "build-arg" | "build-file" | "package-install"; - readonly outputId: string; - readonly required?: boolean; - readonly value?: unknown; -}; - -function readMessagingPlanFromEnv(env: Env, agent: string): MessagingPlan | null { - const encoded = env.NEMOCLAW_MESSAGING_PLAN_B64; - if (!encoded || encoded.trim() === "") return null; - let parsed: unknown; - try { - parsed = JSON.parse(Buffer.from(encoded, "base64").toString("utf-8")); - } catch (error) { - throw new Error( - `NEMOCLAW_MESSAGING_PLAN_B64 must be a base64-encoded messaging plan: ${error instanceof Error ? error.message : String(error)}`, - ); - } - if ( - !isObject(parsed) || - parsed.schemaVersion !== 1 || - parsed.agent !== agent || - !Array.isArray(parsed.channels) || - !Array.isArray(parsed.agentRender) || - !Array.isArray(parsed.buildSteps) - ) { - throw new Error(`NEMOCLAW_MESSAGING_PLAN_B64 must contain a ${agent} messaging plan`); - } - return parsed as MessagingPlan; -} - -function activeMessagingPlanChannels(plan: MessagingPlan | null): string[] { - if (!plan) return []; - return plan.channels - .filter((channel) => channel.active === true && channel.disabled !== true) - .map((channel) => channel.channelId); -} - -function isPlanChannelActive(plan: MessagingPlan, channelId: string): boolean { - return activeMessagingPlanChannels(plan).includes(channelId); -} - -function applyMessagingAgentRender( - config: JsonObject, - plan: MessagingPlan | null, - target: string, -): void { - if (!plan) return; - for (const render of plan.agentRender) { - if ( - render.kind !== "json-fragment" || - render.target !== target || - typeof render.path !== "string" || - !isPlanChannelActive(plan, render.channelId) - ) { - continue; - } - setMessagingJsonPath(config, render.path, toMessagingJsonValue(render.value)); - } -} - -function applyMessagingBuildFiles(config: JsonObject, plan: MessagingPlan | null): void { - if (!plan) return; - for (const step of plan.buildSteps) { - if (step.kind !== "build-file" || !isPlanChannelActive(plan, step.channelId)) continue; - if (step.value === undefined) { - if (step.required) throw new Error(`Messaging build-file output ${step.outputId} is missing`); - continue; - } - applyMessagingBuildFile(config, toMessagingBuildFile(step.value)); - } -} - -function applyMessagingBuildFile( - config: JsonObject, - file: { readonly path: string; readonly mode?: string; readonly content?: unknown; readonly merge?: unknown }, -): void { - const relativePath = normalizeMessagingBuildFilePath(file.path); - if (relativePath === "openclaw.json") { - if (file.merge !== undefined) mergeJsonObjects(config, toMessagingObject(file.merge)); - if (file.content !== undefined) { - const replacement = toMessagingObject(file.content); - for (const key of Object.keys(config)) delete config[key]; - mergeJsonObjects(config, replacement); - } - return; - } - - const stateRoot = expandUser("~/.openclaw"); - const target = resolve(stateRoot, relativePath); - const normalizedRoot = resolve(stateRoot); - if (target !== normalizedRoot && !target.startsWith(`${normalizedRoot}${sep}`)) { - throw new Error(`Messaging build-file path ${file.path} must stay inside ~/.openclaw`); - } - mkdirSync(dirname(target), { recursive: true }); - const contents = serializeMessagingBuildFileContent(file.content); - writeFileSync(target, contents); - if (file.mode) chmodSync(target, parseMessagingFileMode(file.path, file.mode)); -} - -function setMessagingJsonPath(root: JsonObject, pathValue: string, value: unknown): void { - const segments = pathValue.split(".").filter(Boolean); - if (segments.length === 0) throw new Error("Messaging render path must not be empty"); - let cursor = root; - for (const segment of segments.slice(0, -1)) { - assertSafeMessagingObjectKey(segment, "Messaging render path"); - if (!isObject(cursor[segment])) cursor[segment] = {}; - cursor = cursor[segment] as JsonObject; - } - const finalSegment = segments[segments.length - 1] as string; - assertSafeMessagingObjectKey(finalSegment, "Messaging render path"); - if (isObject(cursor[finalSegment]) && isObject(value)) { - mergeJsonObjects(cursor[finalSegment] as JsonObject, value as JsonObject); - return; - } - cursor[finalSegment] = value; -} - -function mergeJsonObjects(target: JsonObject, patch: JsonObject): void { - for (const [key, value] of Object.entries(patch)) { - assertSafeMessagingObjectKey(key, "Messaging object merge"); - const existing = target[key]; - if (isObject(existing) && isObject(value)) { - mergeJsonObjects(existing as JsonObject, value as JsonObject); - } else if (Array.isArray(existing) && Array.isArray(value)) { - target[key] = unique([...existing, ...value]); - } else { - target[key] = value; - } - } -} - -function toMessagingJsonValue(value: unknown): unknown { - if (value === undefined) throw new Error("Messaging render value is missing"); - return value; -} - -function toMessagingObject(value: unknown): JsonObject { - if (!isObject(value)) throw new Error("Messaging build-file merge/content must be an object"); - return value; -} - -function toMessagingBuildFile(value: unknown): { - readonly path: string; - readonly mode?: string; - readonly content?: unknown; - readonly merge?: unknown; -} { - if (!isObject(value) || typeof value.path !== "string" || value.path.trim().length === 0) { - throw new Error("Messaging build-file output must include a path"); - } - return value as { - readonly path: string; - readonly mode?: string; - readonly content?: unknown; - readonly merge?: unknown; - }; -} - -function normalizeMessagingBuildFilePath(pathValue: string): string { - if (pathValue.startsWith("/") || pathValue.includes("\\") || /[\0-\x1F\x7F]/.test(pathValue)) { - throw new Error(`Messaging build-file path ${pathValue} must be a safe relative path`); - } - const segments = pathValue.split("/"); - if (segments.some((segment) => !segment || segment === "." || segment === "..")) { - throw new Error(`Messaging build-file path ${pathValue} must not traverse directories`); - } - return pathValue; -} - -function serializeMessagingBuildFileContent(value: unknown): string { - if (value === undefined) return ""; - if (typeof value === "string") return value.endsWith("\n") ? value : `${value}\n`; - return `${JSON.stringify(value, null, 2)}\n`; -} - -function parseMessagingFileMode(pathValue: string, mode: string): number { - if (!/^[0-7]{3,4}$/.test(mode) || (mode.length === 4 && mode[0] !== "0")) { - throw new Error(`Messaging build-file ${pathValue} mode must be an octal file mode`); - } - const parsed = Number.parseInt(mode, 8); - if ((parsed & 0o022) !== 0) { - throw new Error(`Messaging build-file ${pathValue} mode must not be group/world writable`); - } - return parsed; -} - -function assertSafeMessagingObjectKey(key: string, context: string): void { - if (key === "__proto__" || key === "prototype" || key === "constructor") { - throw new Error(`${context} rejected unsafe object key ${key}`); - } -} - const KNOWN_MODEL_SETUP_AGENTS = new Set(["openclaw", "hermes"]); const MODEL_SETUP_EFFECT_KEYS: Record> = { openclaw: new Set(["openclawCompat", "openclawPlugins", "openclawTools"]), @@ -1047,7 +830,6 @@ export function buildConfig(env: Env = process.env): JsonObject { inferenceCompat.supportsUsageInStreaming ??= true; } - const messagingPlan = readMessagingPlanFromEnv(env, "openclaw"); const normalizedUrl = normalizeUrlForParse(chatUiUrl); const parsed = parseUrl(normalizedUrl); @@ -1183,7 +965,6 @@ export function buildConfig(env: Env = process.env): JsonObject { }; } - applyMessagingAgentRender(config, messagingPlan, "openclaw.json"); return config; } @@ -1214,17 +995,19 @@ function preserveExistingPluginInstalls(config: JsonObject, configPath: string): Object.assign(currentPlugins.installs, existingInstalls); } -export function main(): void { +export function writeOpenClawConfig(): void { const config = buildConfig(); const configPath = expandUser("~/.openclaw/openclaw.json"); - const messagingPlan = readMessagingPlanFromEnv(process.env, "openclaw"); preserveExistingPluginInstalls(config, configPath); mkdirSync(dirname(configPath), { recursive: true }); - applyMessagingBuildFiles(config, messagingPlan); writeFileSync(configPath, JSON.stringify(config, null, 2)); chmodSync(configPath, 0o600); } +export function main(): void { + writeOpenClawConfig(); +} + function isMainModule(): boolean { return process.argv[1] ? import.meta.url === pathToFileURL(resolve(process.argv[1])).href : false; } diff --git a/scripts/run-openclaw-build-hooks.mts b/scripts/run-openclaw-build-hooks.mts deleted file mode 100755 index 9e7714e684..0000000000 --- a/scripts/run-openclaw-build-hooks.mts +++ /dev/null @@ -1,249 +0,0 @@ -#!/usr/bin/env -S node --experimental-strip-types -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { spawnSync } from "node:child_process"; - -type Env = Record; -type JsonObject = Record; - -type MessagingPlan = { - readonly schemaVersion: 1; - readonly agent: string; - readonly channels: readonly MessagingPlanChannel[]; - readonly credentialBindings: readonly MessagingCredentialBinding[]; - readonly buildSteps: readonly MessagingBuildStep[]; -}; - -type MessagingPlanChannel = { - readonly channelId: string; - readonly active?: boolean; - readonly disabled?: boolean; -}; - -type MessagingCredentialBinding = { - readonly channelId: string; - readonly providerEnvKey?: unknown; - readonly placeholder?: unknown; -}; - -type MessagingBuildStep = { - readonly channelId: string; - readonly kind: string; - readonly outputId?: string; - readonly required?: boolean; - readonly value?: unknown; -}; - -type OpenClawPackageInstall = { - readonly manager: "openclaw-plugin"; - readonly spec: string; - readonly pin?: boolean; -}; - -const FALSE_VALUES = new Set(["0", "false", "no", "off"]); -const DIAGNOSTICS_OTEL_PACKAGE = "@openclaw/diagnostics-otel"; - -class OpenClawBuildHookError extends Error {} - -function readMessagingPlanFromEnv(env: Env): MessagingPlan | null { - const raw = env.NEMOCLAW_MESSAGING_PLAN_B64; - if (!raw || raw.trim() === "") return null; - - let parsed: unknown; - try { - parsed = JSON.parse(Buffer.from(raw, "base64").toString("utf-8")); - } catch (error) { - throw new OpenClawBuildHookError( - `NEMOCLAW_MESSAGING_PLAN_B64 must be base64-encoded JSON: ${formatError(error)}`, - ); - } - - if ( - !isObject(parsed) || - parsed.schemaVersion !== 1 || - parsed.agent !== "openclaw" || - !Array.isArray(parsed.channels) || - !Array.isArray(parsed.credentialBindings) || - !Array.isArray(parsed.buildSteps) - ) { - throw new OpenClawBuildHookError( - "NEMOCLAW_MESSAGING_PLAN_B64 must contain an openclaw messaging plan", - ); - } - return parsed as MessagingPlan; -} - -function activeChannels(plan: MessagingPlan | null): string[] { - if (!plan) return []; - const seen = new Set(); - const channels: string[] = []; - for (const item of plan.channels) { - if (!isObject(item)) continue; - const channel = String(item.channelId || "").trim().toLowerCase(); - if (!channel || seen.has(channel)) continue; - if (item.active === true && item.disabled !== true) { - seen.add(channel); - channels.push(channel); - } - } - return channels; -} - -function collectOpenClawInstallSpecs(plan: MessagingPlan | null, env: Env): string[] { - if (!plan) return []; - const active = new Set(activeChannels(plan)); - const specs: string[] = []; - for (const step of plan.buildSteps) { - if (step.kind !== "package-install" || !active.has(step.channelId)) continue; - if (step.value === undefined) { - if (step.required) { - throw new OpenClawBuildHookError( - `Messaging package-install output ${step.outputId || ""} is missing`, - ); - } - continue; - } - const install = readOpenClawPackageInstall(step.value, step.outputId || ""); - specs.push(resolveOpenClawPackageSpec(install.spec, env)); - } - return unique(specs); -} - -function readOpenClawPackageInstall(value: unknown, outputId: string): OpenClawPackageInstall { - if (!isObject(value)) { - throw new OpenClawBuildHookError( - `Messaging package-install output ${outputId} must be an object`, - ); - } - if (value.manager !== "openclaw-plugin") { - throw new OpenClawBuildHookError( - `Messaging package-install output ${outputId} must use manager 'openclaw-plugin'`, - ); - } - if (typeof value.spec !== "string" || value.spec.trim().length === 0) { - throw new OpenClawBuildHookError( - `Messaging package-install output ${outputId} must include a package spec`, - ); - } - if (value.pin !== undefined && typeof value.pin !== "boolean") { - throw new OpenClawBuildHookError( - `Messaging package-install output ${outputId} pin must be boolean`, - ); - } - return value as OpenClawPackageInstall; -} - -function resolveOpenClawPackageSpec(spec: string, env: Env): string { - const version = (env.OPENCLAW_VERSION || "").trim(); - const resolved = spec.replaceAll("{{openclaw.version}}", () => { - if (!version) { - throw new OpenClawBuildHookError( - "OPENCLAW_VERSION is required when OpenClaw package install hooks are active", - ); - } - return version; - }); - if (/\{\{\s*[^}]+\s*\}\}/.test(resolved)) { - throw new OpenClawBuildHookError(`Unresolved package-install template in ${spec}`); - } - return resolved; -} - -function diagnosticsOtelSpec(env: Env): string | null { - if (!isTruthyEnv(env.NEMOCLAW_OPENCLAW_OTEL)) return null; - const version = (env.OPENCLAW_VERSION || "").trim(); - if (!version) { - throw new OpenClawBuildHookError( - "OPENCLAW_VERSION is required when OpenClaw OTEL is enabled", - ); - } - return `npm:${DIAGNOSTICS_OTEL_PACKAGE}@${version}`; -} - -function doctorEnvOverrides(plan: MessagingPlan | null): Record { - if (!plan) return {}; - const active = new Set(activeChannels(plan)); - const overrides: Record = {}; - for (const binding of plan.credentialBindings) { - if (!active.has(binding.channelId)) continue; - if (typeof binding.providerEnvKey === "string" && typeof binding.placeholder === "string") { - overrides[binding.providerEnvKey] = binding.placeholder; - } - } - return overrides; -} - -function runCommand(args: readonly string[], env: NodeJS.ProcessEnv = process.env): void { - console.log(`+ ${args.join(" ")}`); - const result = spawnSync(args[0] as string, args.slice(1), { - env, - stdio: "inherit", - }); - if (result.error) throw result.error; - if (result.status !== 0) { - throw new OpenClawBuildHookError( - `${args[0]} exited with status ${String(result.status ?? "unknown")}`, - ); - } -} - -function isTruthyEnv(value: string | undefined): boolean { - if (value === undefined || value.trim() === "") return false; - return !FALSE_VALUES.has(value.trim().toLowerCase()); -} - -function isObject(value: unknown): value is JsonObject { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function unique(values: readonly string[]): string[] { - return [...new Set(values)]; -} - -function formatError(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -function main(argv: readonly string[]): void { - const dryRun = argv.includes("--dry-run"); - const plan = readMessagingPlanFromEnv(process.env); - const channels = activeChannels(plan); - const installSpecs = collectOpenClawInstallSpecs(plan, process.env); - const otelSpec = diagnosticsOtelSpec(process.env); - if (otelSpec) installSpecs.push(otelSpec); - const doctorEnv = doctorEnvOverrides(plan); - - if (dryRun) { - console.log( - JSON.stringify( - { - channels, - diagnosticsOtelEnabled: isTruthyEnv(process.env.NEMOCLAW_OPENCLAW_OTEL), - doctorEnv, - installSpecs: unique(installSpecs), - openclawVersion: process.env.OPENCLAW_VERSION || "", - }, - null, - 2, - ), - ); - return; - } - - for (const spec of unique(installSpecs)) { - runCommand(["openclaw", "plugins", "install", spec, "--pin"]); - } - - runCommand(["openclaw", "doctor", "--fix", "--non-interactive"], { - ...process.env, - ...doctorEnv, - }); -} - -try { - main(process.argv.slice(2)); -} catch (error) { - console.error(`ERROR: ${formatError(error)}`); - process.exit(2); -} diff --git a/src/lib/messaging/applier/build/messaging-build-applier.mts b/src/lib/messaging/applier/build/messaging-build-applier.mts new file mode 100755 index 0000000000..b3f886571a --- /dev/null +++ b/src/lib/messaging/applier/build/messaging-build-applier.mts @@ -0,0 +1,1025 @@ +#!/usr/bin/env -S node --experimental-strip-types +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import { + chmodSync, + existsSync, + mkdirSync, + readFileSync, + writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join, resolve, sep } from "node:path"; +import { pathToFileURL } from "node:url"; + +type Env = Record; +type JsonObject = Record; +type MessagingAgentId = "openclaw" | "hermes"; +type MessagingHookPhase = "agent-install" | "post-agent-install"; +type MessagingSerializableValue = + | string + | number + | boolean + | null + | readonly MessagingSerializableValue[] + | { readonly [key: string]: MessagingSerializableValue }; + +type MessagingPlanChannel = { + readonly channelId: string; + readonly active?: boolean; + readonly disabled?: boolean; + readonly hooks?: readonly MessagingPlanHook[]; +}; + +type MessagingCredentialBinding = { + readonly channelId: string; + readonly credentialId?: string; + readonly providerEnvKey?: unknown; + readonly placeholder?: unknown; +}; + +type MessagingPlanHook = { + readonly id: string; + readonly phase: string; + readonly handler: string; + readonly outputs?: readonly MessagingPlanHookOutput[]; + readonly onFailure?: "abort" | "skip-channel"; +}; + +type MessagingPlanHookOutput = { + readonly id: string; + readonly kind: string; + readonly required?: boolean; + readonly value?: MessagingSerializableValue; +}; + +type MessagingRenderEntry = { + readonly channelId: string; + readonly agent: MessagingAgentId; + readonly target: string; + readonly kind: "json-fragment" | "env-lines"; + readonly renderId?: string; + readonly hookId?: string; + readonly handler?: string; + readonly path?: string; + readonly value?: MessagingSerializableValue; + readonly lines?: readonly string[]; + readonly templateRefs?: readonly string[]; +}; + +type MessagingBuildStep = { + readonly channelId: string; + readonly kind: "build-arg" | "build-file" | "package-install"; + readonly hookId?: string; + readonly handler?: string; + readonly outputId: string; + readonly required?: boolean; + readonly value?: MessagingSerializableValue; +}; + +export type MessagingBuildPlan = { + readonly schemaVersion: 1; + readonly sandboxName: string; + readonly agent: MessagingAgentId; + readonly channels: readonly MessagingPlanChannel[]; + readonly credentialBindings: readonly MessagingCredentialBinding[]; + readonly agentRender: readonly MessagingRenderEntry[]; + readonly buildSteps: readonly MessagingBuildStep[]; +}; + +export type BuildFileOutput = { + readonly path: string; + readonly mode?: string; + readonly content?: MessagingSerializableValue; + readonly merge?: MessagingSerializableValue; +}; + +export type BuildCommandResult = { + readonly channels: readonly string[]; + readonly doctorEnv: Record; + readonly installSpecs: readonly string[]; + readonly openclawVersion: string; +}; + +export class MessagingBuildApplierError extends Error {} + +export function readMessagingBuildPlanFromEnv( + env: Env, + agent: MessagingAgentId, +): MessagingBuildPlan | null { + const encoded = env.NEMOCLAW_MESSAGING_PLAN_B64; + if (!encoded || encoded.trim() === "") return null; + + let parsed: unknown; + try { + parsed = JSON.parse(Buffer.from(encoded, "base64").toString("utf-8")); + } catch (error) { + throw new MessagingBuildApplierError( + `NEMOCLAW_MESSAGING_PLAN_B64 must be base64-encoded JSON: ${formatError(error)}`, + ); + } + + if ( + !isObject(parsed) || + parsed.schemaVersion !== 1 || + parsed.agent !== agent || + typeof parsed.sandboxName !== "string" || + !Array.isArray(parsed.channels) || + !Array.isArray(parsed.credentialBindings) || + !Array.isArray(parsed.agentRender) || + !Array.isArray(parsed.buildSteps) + ) { + throw new MessagingBuildApplierError( + `NEMOCLAW_MESSAGING_PLAN_B64 must contain a ${agent} messaging plan`, + ); + } + return parsed as MessagingBuildPlan; +} + +export function applyMessagingAgentRenderToObject( + config: JsonObject, + plan: MessagingBuildPlan | null, + target: string, +): void { + if (!plan) return; + for (const render of enabledAgentRender(plan)) { + if ( + render.kind !== "json-fragment" || + render.target !== target || + typeof render.path !== "string" + ) { + continue; + } + setJsonPath(config, render.path, requiredSerializableValue(render.value, "render value")); + } +} + +export function applyMessagingAgentRenderToEnvLines( + envLines: string[], + plan: MessagingBuildPlan | null, + target: string, +): void { + if (!plan) return; + for (const render of enabledAgentRender(plan)) { + if (render.kind !== "env-lines" || render.target !== target) continue; + if (!Array.isArray(render.lines)) { + throw new MessagingBuildApplierError( + `Messaging env render '${render.renderId ?? render.channelId}' is missing lines.`, + ); + } + mergeEnvLines(envLines, render.lines); + } +} + +export function applyMessagingAgentRenderToLocalFiles( + plan: MessagingBuildPlan | null, + options: { + readonly homeDir?: string; + } = {}, +): readonly string[] { + if (!plan) return []; + const appliedTargets: string[] = []; + const grouped = new Map(); + for (const render of enabledAgentRender(plan)) { + const entries = grouped.get(render.target) ?? []; + entries.push(render); + grouped.set(render.target, entries); + } + + for (const [target, renderEntries] of grouped) { + const kinds = uniqueStrings(renderEntries.map((entry) => entry.kind)); + if (kinds.length !== 1) { + throw new MessagingBuildApplierError(`Cannot apply mixed messaging render kinds to ${target}.`); + } + if (kinds[0] === "json-fragment") { + appliedTargets.push(applyJsonRenderEntriesToLocalFile(plan.agent, target, renderEntries, options)); + } else { + appliedTargets.push(applyEnvRenderEntriesToLocalFile(plan.agent, target, renderEntries, options)); + } + } + + return uniqueStrings(appliedTargets); +} + +export function activeChannels(plan: MessagingBuildPlan | null): string[] { + if (!plan) return []; + const seen = new Set(); + const channels: string[] = []; + for (const item of plan.channels) { + const channel = String(item.channelId || "").trim().toLowerCase(); + if (!channel || seen.has(channel)) continue; + if (item.active === true && item.disabled !== true) { + seen.add(channel); + channels.push(channel); + } + } + return channels; +} + +export function collectOpenClawMessagingPluginInstallSpecs( + plan: MessagingBuildPlan | null, + env: Env, +): string[] { + const specs: string[] = []; + for (const step of enabledBuildStepsForPhase(plan, "agent-install")) { + if (step.kind !== "package-install") continue; + if (step.value === undefined) { + if (step.required) { + throw new MessagingBuildApplierError( + `Messaging package-install output ${step.outputId} is missing`, + ); + } + continue; + } + const install = readOpenClawPackageInstall(step.value, step.outputId); + specs.push(resolveOpenClawPackageSpec(install.spec, env)); + } + return uniqueStrings(specs); +} + +export function openClawDoctorEnvOverrides( + plan: MessagingBuildPlan | null, +): Record { + if (!plan) return {}; + const active = new Set(activeChannels(plan)); + const overrides: Record = {}; + for (const binding of plan.credentialBindings) { + if (!active.has(binding.channelId)) continue; + if (typeof binding.providerEnvKey === "string" && typeof binding.placeholder === "string") { + overrides[binding.providerEnvKey] = binding.placeholder; + } + } + return overrides; +} + +export function installOpenClawMessagingPlugins( + plan: MessagingBuildPlan | null, + env: Env, +): void { + for (const spec of collectOpenClawMessagingPluginInstallSpecs(plan, env)) { + runCommand(["openclaw", "plugins", "install", spec, "--pin"], env); + } +} + +export function runOpenClawMessagingDoctor(plan: MessagingBuildPlan | null, env: Env): void { + if (!plan) return; + runCommand(["openclaw", "doctor", "--fix", "--non-interactive"], { + ...env, + ...openClawDoctorEnvOverrides(plan), + }); +} + +export function applyPostAgentInstallBuildFilesToLocalFiles( + plan: MessagingBuildPlan | null, + options: { + readonly homeDir?: string; + } = {}, +): readonly string[] { + const appliedTargets: string[] = []; + for (const step of enabledBuildStepsForPhase(plan, "post-agent-install")) { + if (step.kind !== "build-file") continue; + if (step.value === undefined) { + if (step.required) { + throw new MessagingBuildApplierError( + `Messaging build-file output ${step.outputId} is missing`, + ); + } + continue; + } + appliedTargets.push( + applyBuildFileOutputToLocalAgentRoot( + plan?.agent ?? "openclaw", + readBuildFileOutput(step.value), + options, + ), + ); + } + return uniqueStrings(appliedTargets); +} + +function applyJsonRenderEntriesToLocalFile( + agent: MessagingAgentId, + target: string, + renderEntries: readonly MessagingRenderEntry[], + options: { readonly homeDir?: string }, +): string { + const targetPath = resolveAgentRenderTarget(agent, target, options); + const config = targetPath.endsWith(".yaml") + ? parseGeneratedYamlObject(readTextIfExists(targetPath), targetPath) + : parseJsonObject(readTextIfExists(targetPath), targetPath); + applyMessagingRenderEntriesToObject(config, renderEntries, target); + if (agent === "hermes" && target === "~/.hermes/config.yaml") { + finalizeHermesRenderedPlatformToolsets(config); + } + mkdirSync(dirname(targetPath), { recursive: true }); + writeFileSync( + targetPath, + targetPath.endsWith(".yaml") ? serializeGeneratedYamlObject(config) : `${JSON.stringify(config, null, 2)}\n`, + ); + chmodSync(targetPath, 0o600); + return targetPath; +} + +function applyEnvRenderEntriesToLocalFile( + agent: MessagingAgentId, + target: string, + renderEntries: readonly MessagingRenderEntry[], + options: { readonly homeDir?: string }, +): string { + const targetPath = resolveAgentRenderTarget(agent, target, options); + const envLines = readTextIfExists(targetPath)?.split(/\r?\n/).filter((line) => line.length > 0) ?? []; + for (const render of renderEntries) { + if (!Array.isArray(render.lines)) { + throw new MessagingBuildApplierError( + `Messaging env render '${render.renderId ?? render.channelId}' is missing lines.`, + ); + } + mergeEnvLines(envLines, render.lines); + } + mkdirSync(dirname(targetPath), { recursive: true }); + writeFileSync(targetPath, envLines.length > 0 ? `${envLines.join("\n")}\n` : ""); + chmodSync(targetPath, 0o600); + return targetPath; +} + +function applyMessagingRenderEntriesToObject( + config: JsonObject, + renderEntries: readonly MessagingRenderEntry[], + target: string, +): void { + for (const render of renderEntries) { + if (render.kind !== "json-fragment" || typeof render.path !== "string") { + throw new MessagingBuildApplierError(`Messaging render for ${target} must be a JSON fragment with a path.`); + } + setJsonPath(config, render.path, requiredSerializableValue(render.value, "render value")); + } +} + +function finalizeHermesRenderedPlatformToolsets(config: JsonObject): void { + const platforms = config.platforms; + const platformToolsets = config.platform_toolsets; + if (!isObject(platforms) || !isObject(platformToolsets)) return; + const apiServerToolsets = platformToolsets.api_server; + if (!Array.isArray(apiServerToolsets)) return; + for (const [platform, platformConfig] of Object.entries(platforms)) { + if (platform === "api_server" || !isObject(platformConfig) || platformConfig.enabled !== true) { + continue; + } + if (!Array.isArray(platformToolsets[platform])) { + platformToolsets[platform] = [...apiServerToolsets]; + } + } +} + +function resolveAgentRenderTarget( + agent: MessagingAgentId, + target: string, + options: { readonly homeDir?: string } = {}, +): string { + const home = options.homeDir ?? homedir(); + if (agent === "openclaw" && target === "openclaw.json") { + return join(home, ".openclaw", "openclaw.json"); + } + if (target.startsWith("~/.openclaw/")) { + if (agent !== "openclaw") { + throw new MessagingBuildApplierError(`Messaging render target ${target} does not match ${agent}.`); + } + return join(home, ".openclaw", target.slice("~/.openclaw/".length)); + } + if (target.startsWith("~/.hermes/")) { + if (agent !== "hermes") { + throw new MessagingBuildApplierError(`Messaging render target ${target} does not match ${agent}.`); + } + return join(home, ".hermes", target.slice("~/.hermes/".length)); + } + throw new MessagingBuildApplierError(`Unsupported messaging render target ${target}.`); +} + +function enabledAgentRender(plan: MessagingBuildPlan): MessagingRenderEntry[] { + const active = new Set(activeChannels(plan)); + return plan.agentRender.filter( + (render) => render.agent === plan.agent && active.has(render.channelId), + ); +} + +function enabledBuildStepsForPhase( + plan: MessagingBuildPlan | null, + phase: MessagingHookPhase, +): MessagingBuildStep[] { + if (!plan) return []; + return enabledBuildSteps(plan).filter((step) => buildStepMatchesPhase(plan, step, phase)); +} + +function enabledBuildSteps(plan: MessagingBuildPlan): MessagingBuildStep[] { + const active = new Set(activeChannels(plan)); + return plan.buildSteps.filter((step) => active.has(step.channelId)); +} + +function buildStepMatchesPhase( + plan: MessagingBuildPlan, + step: MessagingBuildStep, + phase: MessagingHookPhase, +): boolean { + const hookPhase = step.hookId ? findHookPhase(plan, step.channelId, step.hookId) : undefined; + if (hookPhase) return hookPhase === phase; + + // Older compiled plans did not carry hook phase on build steps. Fall back by + // output kind so package installs remain agent-install and files remain + // post-agent-install without re-running channel-specific handlers. + if (phase === "agent-install") return step.kind === "package-install"; + if (phase === "post-agent-install") return step.kind === "build-file"; + return false; +} + +function findHookPhase( + plan: MessagingBuildPlan, + channelId: string, + hookId: string, +): string | undefined { + const channel = plan.channels.find((candidate) => candidate.channelId === channelId); + return channel?.hooks?.find((hook) => hook.id === hookId)?.phase; +} + +function applyBuildFileOutputToLocalAgentRoot( + agent: MessagingAgentId, + file: BuildFileOutput, + options: { readonly homeDir?: string } = {}, +): string { + const root = agent === "hermes" + ? join(options.homeDir ?? homedir(), ".hermes") + : join(options.homeDir ?? homedir(), ".openclaw"); + const relativePath = normalizeBuildFilePath(file.path); + const target = resolve(root, relativePath); + const normalizedRoot = resolve(root); + if (target !== normalizedRoot && !target.startsWith(`${normalizedRoot}${sep}`)) { + throw new MessagingBuildApplierError( + `Messaging build-file path ${file.path} must stay inside ${root}`, + ); + } + + const contents = + file.merge !== undefined + ? mergeBuildFileContent(readTextIfExists(target), file.merge, target) + : serializeBuildFileContent(file.content); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, contents); + if (file.mode) chmodSync(target, parseBuildFileMode(file.path, file.mode)); + return target; +} + +function mergeBuildFileContent( + existing: string | undefined, + patch: MessagingSerializableValue, + target: string, +): string { + if (!isObject(patch)) { + throw new MessagingBuildApplierError(`Messaging build-file merge for ${target} must be an object.`); + } + const root = parseJsonObject(existing, target); + mergeJsonObjects(root, patch as JsonObject); + return `${JSON.stringify(root, null, 2)}\n`; +} + +function parseJsonObject(existing: string | undefined, target: string): JsonObject { + if (!existing || existing.trim().length === 0) return {}; + const parsed = JSON.parse(existing) as unknown; + if (!isObject(parsed)) { + throw new MessagingBuildApplierError(`Messaging build-file target ${target} must contain an object.`); + } + return parsed as JsonObject; +} + +function readTextIfExists(path: string): string | undefined { + return existsSync(path) ? readFileSync(path, "utf-8") : undefined; +} + +function readBuildFileOutput(value: MessagingSerializableValue): BuildFileOutput { + if (!isObject(value)) { + throw new MessagingBuildApplierError("Messaging build-file output must include a path"); + } + const file = value as JsonObject; + if (typeof file.path !== "string" || file.path.trim().length === 0) { + throw new MessagingBuildApplierError("Messaging build-file output must include a path"); + } + if (file.content === undefined && file.merge === undefined) { + throw new MessagingBuildApplierError(`Messaging build-file ${file.path} must include content or merge`); + } + if (file.mode !== undefined && typeof file.mode !== "string") { + throw new MessagingBuildApplierError(`Messaging build-file ${file.path} mode must be a string`); + } + return file as BuildFileOutput; +} + +function normalizeBuildFilePath(pathValue: string): string { + if (pathValue.startsWith("/") || pathValue.includes("\\") || /[\0-\x1F\x7F]/.test(pathValue)) { + throw new MessagingBuildApplierError(`Messaging build-file path ${pathValue} must be a safe relative path`); + } + const segments = pathValue.split("/"); + if (segments.some((segment) => !segment || segment === "." || segment === "..")) { + throw new MessagingBuildApplierError(`Messaging build-file path ${pathValue} must not traverse directories`); + } + return pathValue; +} + +function serializeBuildFileContent(value: MessagingSerializableValue | undefined): string { + if (value === undefined) return ""; + if (typeof value === "string") return value.endsWith("\n") ? value : `${value}\n`; + return `${JSON.stringify(value, null, 2)}\n`; +} + +function parseBuildFileMode(pathValue: string, mode: string): number { + if (!/^[0-7]{3,4}$/.test(mode) || (mode.length === 4 && mode[0] !== "0")) { + throw new MessagingBuildApplierError(`Messaging build-file ${pathValue} mode must be an octal file mode`); + } + const parsed = Number.parseInt(mode, 8); + if ((parsed & 0o022) !== 0) { + throw new MessagingBuildApplierError(`Messaging build-file ${pathValue} mode must not be group/world writable`); + } + return parsed; +} + +function readOpenClawPackageInstall( + value: MessagingSerializableValue, + outputId: string, +): { + readonly manager: "openclaw-plugin"; + readonly spec: string; + readonly pin?: boolean; +} { + if (!isObject(value)) { + throw new MessagingBuildApplierError( + `Messaging package-install output ${outputId} must be an object`, + ); + } + const install = value as JsonObject; + if (install.manager !== "openclaw-plugin") { + throw new MessagingBuildApplierError( + `Messaging package-install output ${outputId} must use manager 'openclaw-plugin'`, + ); + } + if (typeof install.spec !== "string" || install.spec.trim().length === 0) { + throw new MessagingBuildApplierError( + `Messaging package-install output ${outputId} must include a package spec`, + ); + } + if (install.pin !== undefined && typeof install.pin !== "boolean") { + throw new MessagingBuildApplierError( + `Messaging package-install output ${outputId} pin must be boolean`, + ); + } + return install as { + readonly manager: "openclaw-plugin"; + readonly spec: string; + readonly pin?: boolean; + }; +} + +function resolveOpenClawPackageSpec(spec: string, env: Env): string { + const version = (env.OPENCLAW_VERSION || "").trim(); + const resolved = spec.replaceAll("{{openclaw.version}}", () => { + if (!version) { + throw new MessagingBuildApplierError( + "OPENCLAW_VERSION is required when OpenClaw package install hooks are active", + ); + } + return version; + }); + if (/\{\{\s*[^}]+\s*\}\}/.test(resolved)) { + throw new MessagingBuildApplierError(`Unresolved package-install template in ${spec}`); + } + return resolved; +} + +function runCommand(args: readonly string[], env: Env): void { + console.log(`+ ${args.join(" ")}`); + const result = spawnSync(args[0] as string, args.slice(1), { + env: env as NodeJS.ProcessEnv, + stdio: "inherit", + }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new MessagingBuildApplierError( + `${args[0]} exited with status ${String(result.status ?? "unknown")}`, + ); + } +} + +function setJsonPath(root: JsonObject, pathValue: string, value: MessagingSerializableValue): void { + const segments = pathValue.split(".").filter(Boolean); + if (segments.length === 0) { + throw new MessagingBuildApplierError("Messaging render path must not be empty"); + } + let cursor = root; + for (const segment of segments.slice(0, -1)) { + assertSafeObjectKey(segment, "Messaging render path"); + if (!isObject(cursor[segment])) cursor[segment] = {}; + cursor = cursor[segment] as JsonObject; + } + const finalSegment = segments[segments.length - 1] as string; + assertSafeObjectKey(finalSegment, "Messaging render path"); + if (isObject(cursor[finalSegment]) && isObject(value)) { + mergeJsonObjects(cursor[finalSegment] as JsonObject, value as JsonObject); + return; + } + cursor[finalSegment] = value; +} + +function mergeJsonObjects(target: JsonObject, patch: JsonObject): void { + for (const [key, value] of Object.entries(patch)) { + assertSafeObjectKey(key, "Messaging object merge"); + const existing = target[key]; + if (isObject(existing) && isObject(value)) { + mergeJsonObjects(existing as JsonObject, value as JsonObject); + } else if (Array.isArray(existing) && Array.isArray(value)) { + target[key] = [...new Set([...existing, ...value])]; + } else { + target[key] = value; + } + } +} + +function mergeEnvLines(existingLines: string[], desiredLines: readonly string[]): void { + const desired = new Map(); + const rawDesiredLines: string[] = []; + for (const line of desiredLines) { + const key = readEnvLineKey(line); + if (key) { + desired.set(key, line); + } else { + rawDesiredLines.push(line); + } + } + + const written = new Set(); + for (const [index, line] of existingLines.entries()) { + const key = readEnvLineKey(line); + if (!key || !desired.has(key)) continue; + existingLines[index] = desired.get(key) as string; + written.add(key); + } + + for (const [key, line] of desired) { + if (!written.has(key)) existingLines.push(line); + } + existingLines.push(...rawDesiredLines); +} + +type GeneratedYamlLine = { + readonly indent: number; + readonly text: string; + readonly lineNumber: number; +}; + +function parseGeneratedYamlObject(existing: string | undefined, target: string): JsonObject { + if (!existing || existing.trim().length === 0) return {}; + const lines = existing + .split(/\r?\n/) + .map((line, index): GeneratedYamlLine | null => { + if (line.trim().length === 0) return null; + const indent = line.match(/^ */)?.[0].length ?? 0; + return { indent, text: line.slice(indent), lineNumber: index + 1 }; + }) + .filter((line): line is GeneratedYamlLine => line !== null); + if (lines.length === 0) return {}; + const [parsed, nextIndex] = parseGeneratedYamlBlock(lines, 0, lines[0]?.indent ?? 0, target); + if (nextIndex !== lines.length || !isObject(parsed)) { + throw new MessagingBuildApplierError(`Messaging YAML target ${target} must contain an object.`); + } + return parsed as JsonObject; +} + +function parseGeneratedYamlBlock( + lines: readonly GeneratedYamlLine[], + startIndex: number, + indent: number, + target: string, +): [MessagingSerializableValue, number] { + const first = lines[startIndex]; + if (!first || first.indent < indent) return [{}, startIndex]; + if (first.indent !== indent) { + throw new MessagingBuildApplierError( + `Messaging YAML target ${target} has unsupported indentation at line ${first.lineNumber}.`, + ); + } + if (first.text.startsWith("-")) { + return parseGeneratedYamlArray(lines, startIndex, indent, target); + } + return parseGeneratedYamlMap(lines, startIndex, indent, target); +} + +function parseGeneratedYamlMap( + lines: readonly GeneratedYamlLine[], + startIndex: number, + indent: number, + target: string, +): [JsonObject, number] { + const parsed: JsonObject = {}; + let index = startIndex; + while (index < lines.length) { + const line = lines[index] as GeneratedYamlLine; + if (line.indent < indent) break; + if (line.indent !== indent) { + throw new MessagingBuildApplierError( + `Messaging YAML target ${target} has unsupported indentation at line ${line.lineNumber}.`, + ); + } + if (line.text.startsWith("-")) break; + const colonIndex = line.text.indexOf(":"); + if (colonIndex <= 0) { + throw new MessagingBuildApplierError( + `Messaging YAML target ${target} has unsupported mapping syntax at line ${line.lineNumber}.`, + ); + } + const key = line.text.slice(0, colonIndex).trim(); + assertSafeObjectKey(key, "Messaging YAML render path"); + const rest = line.text.slice(colonIndex + 1).trim(); + if (rest.length > 0) { + parsed[key] = parseGeneratedYamlScalar(rest, target, line.lineNumber); + index += 1; + continue; + } + const next = lines[index + 1]; + if (!next || next.indent < indent || (next.indent === indent && !next.text.startsWith("-"))) { + parsed[key] = {}; + index += 1; + continue; + } + const childIndent = next.text.startsWith("-") && next.indent === indent ? indent : indent + 2; + const [value, nextIndex] = parseGeneratedYamlBlock(lines, index + 1, childIndent, target); + parsed[key] = value; + index = nextIndex; + } + return [parsed, index]; +} + +function parseGeneratedYamlArray( + lines: readonly GeneratedYamlLine[], + startIndex: number, + indent: number, + target: string, +): [MessagingSerializableValue[], number] { + const parsed: MessagingSerializableValue[] = []; + let index = startIndex; + while (index < lines.length) { + const line = lines[index] as GeneratedYamlLine; + if (line.indent < indent) break; + if (line.indent !== indent || !line.text.startsWith("-")) { + throw new MessagingBuildApplierError( + `Messaging YAML target ${target} has unsupported array syntax at line ${line.lineNumber}.`, + ); + } + const rest = line.text.slice(1).trim(); + if (rest.length > 0) { + parsed.push(parseGeneratedYamlScalar(rest, target, line.lineNumber)); + index += 1; + continue; + } + const next = lines[index + 1]; + if (!next || next.indent <= indent) { + parsed.push({}); + index += 1; + continue; + } + const [value, nextIndex] = parseGeneratedYamlBlock(lines, index + 1, indent + 2, target); + parsed.push(value); + index = nextIndex; + } + return [parsed, index]; +} + +function parseGeneratedYamlScalar(value: string, target: string, lineNumber: number): MessagingSerializableValue { + if (value === "[]") return []; + if (value === "{}") return {}; + if (value === "null") return null; + if (value === "true") return true; + if (value === "false") return false; + if (/^-?\d+(?:\.\d+)?$/.test(value)) return Number(value); + if (value.startsWith('"')) { + try { + return JSON.parse(value) as MessagingSerializableValue; + } catch (error) { + throw new MessagingBuildApplierError( + `Messaging YAML target ${target} has invalid quoted scalar at line ${lineNumber}: ${formatError(error)}`, + ); + } + } + return value; +} + +function serializeGeneratedYamlObject(value: JsonObject): string { + return serializeGeneratedYamlValue(value); +} + +function serializeGeneratedYamlValue(value: MessagingSerializableValue, indent: number = 0): string { + const pad = " ".repeat(indent); + if (Array.isArray(value)) { + if (value.length === 0) return `${pad}[]\n`; + let out = ""; + for (const item of value) { + if (isObject(item)) { + out += `${pad}-\n`; + out += serializeGeneratedYamlValue(item as MessagingSerializableValue, indent + 1); + } else if (Array.isArray(item)) { + out += `${pad}-\n`; + out += serializeGeneratedYamlValue(item, indent + 1); + } else { + out += `${pad}- ${formatGeneratedYamlScalar(item)}\n`; + } + } + return out; + } + if (isObject(value)) { + let out = ""; + for (const [key, item] of Object.entries(value)) { + assertSafeObjectKey(key, "Messaging YAML object"); + if (Array.isArray(item)) { + out += item.length === 0 ? `${pad}${key}: []\n` : `${pad}${key}:\n${serializeGeneratedYamlValue(item, indent + 1)}`; + } else if (isObject(item)) { + const entries = Object.entries(item); + out += entries.length === 0 ? `${pad}${key}: {}\n` : `${pad}${key}:\n${serializeGeneratedYamlValue(item as MessagingSerializableValue, indent + 1)}`; + } else { + out += `${pad}${key}: ${formatGeneratedYamlScalar(item as MessagingSerializableValue)}\n`; + } + } + return out; + } + return `${pad}${formatGeneratedYamlScalar(value)}\n`; +} + +function formatGeneratedYamlScalar(value: MessagingSerializableValue): string { + if (value === null || value === undefined) return "null"; + if (typeof value === "number" || typeof value === "boolean") return String(value); + if (typeof value !== "string") return JSON.stringify(value); + if (value === "") return JSON.stringify(value); + if (/[:{}\[\],&*?|>!%@`#'\"]/.test(value) || value.includes("\n") || value.trim() !== value) { + return JSON.stringify(value); + } + return value; +} + +function readEnvLineKey(line: string): string | null { + const index = line.indexOf("="); + if (index <= 0) return null; + const key = line.slice(0, index).trim(); + return key.length > 0 ? key : null; +} + +function requiredSerializableValue(value: unknown, label: string): MessagingSerializableValue { + if (value === undefined) { + throw new MessagingBuildApplierError(`Messaging ${label} is missing`); + } + return value as MessagingSerializableValue; +} + +function assertSafeObjectKey(key: string, context: string): void { + if (key === "__proto__" || key === "prototype" || key === "constructor") { + throw new MessagingBuildApplierError(`${context} rejected unsafe object key ${key}`); + } +} + +function isObject(value: unknown): value is JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function uniqueStrings(values: readonly T[]): T[] { + return [...new Set(values)]; +} + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export type MessagingBuildPhase = "agent-install" | "post-agent-install"; + +export function applyMessagingBuildPhase( + plan: MessagingBuildPlan | null, + phase: MessagingBuildPhase, + env: Env = process.env, +): readonly string[] { + if (phase === "agent-install") { + installMessagingPackages(plan, env); + return []; + } + const appliedTargets = uniqueStrings([ + ...applyMessagingAgentRenderToLocalFiles(plan), + ...applyPostAgentInstallBuildFilesToLocalFiles(plan), + ]); + if (plan?.agent === "openclaw") { + runOpenClawMessagingDoctor(plan, env); + } + return appliedTargets; +} + +export function installMessagingPackages(plan: MessagingBuildPlan | null, env: Env): void { + if (!plan) return; + if (plan.agent === "openclaw") { + installOpenClawMessagingPlugins(plan, env); + return; + } + + const packageSteps = enabledBuildStepsForPhase(plan, "agent-install").filter( + (step) => step.kind === "package-install", + ); + if (packageSteps.length > 0) { + throw new MessagingBuildApplierError( + `Messaging package-install is not supported for ${plan.agent}`, + ); + } +} + +export function describeMessagingBuildPhase( + plan: MessagingBuildPlan | null, + phase: MessagingBuildPhase, + env: Env, +): BuildCommandResult & { readonly agent: MessagingAgentId | "unknown"; readonly phase: MessagingBuildPhase } { + return { + agent: plan?.agent ?? "unknown", + phase, + channels: activeChannels(plan), + doctorEnv: plan?.agent === "openclaw" ? openClawDoctorEnvOverrides(plan) : {}, + installSpecs: plan?.agent === "openclaw" ? collectOpenClawMessagingPluginInstallSpecs(plan, env) : [], + openclawVersion: env.OPENCLAW_VERSION || "", + }; +} + +export function main(argv: readonly string[] = process.argv.slice(2)): void { + const { agent, phase, dryRun } = parseMessagingBuildArgs(argv); + const plan = readMessagingBuildPlanFromEnv(process.env, agent); + if (dryRun) { + console.log(JSON.stringify(describeMessagingBuildPhase(plan, phase, process.env), null, 2)); + return; + } + applyMessagingBuildPhase(plan, phase, process.env); +} + +function parseMessagingBuildArgs(argv: readonly string[]): { + readonly agent: MessagingAgentId; + readonly phase: MessagingBuildPhase; + readonly dryRun: boolean; +} { + let agent: MessagingAgentId | undefined; + let phase: MessagingBuildPhase | undefined; + let dryRun = false; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--dry-run") { + dryRun = true; + continue; + } + if (arg === "--agent") { + agent = readAgentArg(argv[index + 1]); + index += 1; + continue; + } + if (arg.startsWith("--agent=")) { + agent = readAgentArg(arg.slice("--agent=".length)); + continue; + } + if (arg === "--phase") { + phase = readPhaseArg(argv[index + 1]); + index += 1; + continue; + } + if (arg.startsWith("--phase=")) { + phase = readPhaseArg(arg.slice("--phase=".length)); + continue; + } + if (!arg.startsWith("-") && !phase) { + phase = readPhaseArg(arg); + continue; + } + throw new MessagingBuildApplierError(`Unknown messaging build applier argument: ${arg}`); + } + + return { + agent: agent ?? "openclaw", + phase: phase ?? "post-agent-install", + dryRun, + }; +} + +function readAgentArg(value: string | undefined): MessagingAgentId { + if (value === "openclaw" || value === "hermes") return value; + throw new MessagingBuildApplierError("--agent must be 'openclaw' or 'hermes'"); +} + +function readPhaseArg(value: string | undefined): MessagingBuildPhase { + if (value === "agent-install" || value === "post-agent-install") return value; + throw new MessagingBuildApplierError("--phase must be 'agent-install' or 'post-agent-install'"); +} + +function isMainModule(): boolean { + return process.argv[1] ? import.meta.url === pathToFileURL(resolve(process.argv[1])).href : false; +} + +if (isMainModule()) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(2); + } +} diff --git a/src/lib/sandbox/build-context.ts b/src/lib/sandbox/build-context.ts index 8569068e79..cea2d7ff35 100644 --- a/src/lib/sandbox/build-context.ts +++ b/src/lib/sandbox/build-context.ts @@ -49,6 +49,12 @@ function stageLegacySandboxBuildContext( }); normalizeReadModesForDockerCopy(path.join(buildCtx, "nemoclaw-blueprint")); fs.cpSync(path.join(rootDir, "scripts"), path.join(buildCtx, "scripts"), { recursive: true }); + fs.cpSync( + path.join(rootDir, "src", "lib", "messaging"), + path.join(buildCtx, "src", "lib", "messaging"), + { recursive: true }, + ); + normalizeReadModesForDockerCopy(path.join(buildCtx, "src")); fs.rmSync(path.join(buildCtx, "nemoclaw", "node_modules"), { recursive: true, force: true }); normalizeReadModesForDockerCopy(path.join(buildCtx, "nemoclaw")); @@ -122,6 +128,10 @@ function stageOptimizedSandboxBuildContext( path.join(rootDir, "scripts", "codex-acp-wrapper.sh"), path.join(stagedScriptsDir, "codex-acp-wrapper.sh"), ); + fs.copyFileSync( + path.join(rootDir, "scripts", "generate-openclaw-config.mts"), + path.join(stagedScriptsDir, "generate-openclaw-config.mts"), + ); // Shared sandbox initialisation library sourced by the entrypoint (#2277) fs.mkdirSync(path.join(stagedScriptsDir, "lib"), { recursive: true }); fs.copyFileSync( @@ -136,15 +146,13 @@ function stageOptimizedSandboxBuildContext( path.join(rootDir, "scripts", "lib", "clean_runtime_shell_env_shim.py"), path.join(stagedScriptsDir, "lib", "clean_runtime_shell_env_shim.py"), ); - // OpenClaw config generator extracted in #2449 (fixed in #2565) - fs.copyFileSync( - path.join(rootDir, "scripts", "generate-openclaw-config.mts"), - path.join(stagedScriptsDir, "generate-openclaw-config.mts"), - ); - fs.copyFileSync( - path.join(rootDir, "scripts", "run-openclaw-build-hooks.mts"), - path.join(stagedScriptsDir, "run-openclaw-build-hooks.mts"), + // Build-time messaging applier used by OpenClaw and Hermes Dockerfiles. + fs.cpSync( + path.join(rootDir, "src", "lib", "messaging"), + path.join(buildCtx, "src", "lib", "messaging"), + { recursive: true }, ); + normalizeReadModesForDockerCopy(path.join(buildCtx, "src")); fs.copyFileSync( path.join(rootDir, "scripts", "patch-openclaw-tool-catalog.js"), path.join(stagedScriptsDir, "patch-openclaw-tool-catalog.js"), diff --git a/test/fetch-guard-patch-regression.test.ts b/test/fetch-guard-patch-regression.test.ts index b66333c199..6ae3e5ad59 100644 --- a/test/fetch-guard-patch-regression.test.ts +++ b/test/fetch-guard-patch-regression.test.ts @@ -358,7 +358,7 @@ describe("fetch-guard patch regression guard", () => { it("fails the image build when the NemoClaw OpenClaw plugin cannot install", () => { const command = dockerRunCommandBetween( "# Install NemoClaw plugin into OpenClaw", - "# Release the offline lock", + "# Apply messaging render and post-agent-install build-file hooks after agent/plugin installation.", ); const script = [ "openclaw() {", diff --git a/test/generate-hermes-config.test.ts b/test/generate-hermes-config.test.ts index f2eda0ba7f..21952a4e01 100644 --- a/test/generate-hermes-config.test.ts +++ b/test/generate-hermes-config.test.ts @@ -11,6 +11,16 @@ import { HERMES_PROXY_API_KEY_PLACEHOLDER } from "../src/lib/hermes-proxy-api-ke import { withLegacyMessagingPlanEnv } from "./messaging-plan-test-helper"; const SCRIPT_PATH = path.join(import.meta.dirname, "..", "agents", "hermes", "generate-config.ts"); +const APPLIER_PATH = path.join( + import.meta.dirname, + "..", + "src", + "lib", + "messaging", + "applier", + "build", + "messaging-build-applier.mts", +); const CONFIG_MODULE_DIR = path.join(import.meta.dirname, "..", "agents", "hermes", "config"); const BASE_ENV: Record = { @@ -47,16 +57,48 @@ function encodeJson(value: unknown): string { return Buffer.from(JSON.stringify(value)).toString("base64"); } +function buildHermesTestEnv(envOverrides: Record = {}): Record { + return withLegacyMessagingPlanEnv( + { + PATH: process.env.PATH || "/usr/bin:/bin", + ...BASE_ENV, + ...envOverrides, + HOME: tmpDir, + }, + "hermes", + ); +} + function runConfigScript(envOverrides: Record = {}): { config: Record; envFile: string; } { fs.mkdirSync(path.join(tmpDir, ".hermes"), { recursive: true }); + const env = buildHermesTestEnv(envOverrides); const result = runConfigScriptRaw(envOverrides); if (result.status !== 0) { throw new Error( - `Script failed (exit ${result.status}):\nstdout: ${result.stdout}\nstderr: ${result.stderr}`, + `Script failed (exit ${result.status}): +stdout: ${result.stdout} +stderr: ${result.stderr}`, + ); + } + + const applierResult = spawnSync( + process.execPath, + ["--experimental-strip-types", APPLIER_PATH, "--agent", "hermes", "--phase", "post-agent-install"], + { + encoding: "utf-8", + env, + timeout: 10_000, + }, + ); + if (applierResult.status !== 0) { + throw new Error( + `Messaging applier failed (exit ${applierResult.status}): +stdout: ${applierResult.stdout} +stderr: ${applierResult.stderr}`, ); } @@ -72,15 +114,7 @@ function runConfigScriptRaw( opts: { cwd?: string; scriptPath?: string } = {}, ) { fs.mkdirSync(path.join(tmpDir, ".hermes"), { recursive: true }); - const env = withLegacyMessagingPlanEnv( - { - PATH: process.env.PATH || "/usr/bin:/bin", - ...BASE_ENV, - ...envOverrides, - HOME: tmpDir, - }, - "hermes", - ); + const env = buildHermesTestEnv(envOverrides); return spawnSync( process.execPath, ["--experimental-strip-types", opts.scriptPath || SCRIPT_PATH], @@ -110,6 +144,11 @@ function copyConfigGeneratorFixture(fixtureRoot: string): string { fs.mkdirSync(path.dirname(fixtureScriptPath), { recursive: true }); fs.copyFileSync(SCRIPT_PATH, fixtureScriptPath); fs.cpSync(CONFIG_MODULE_DIR, fixtureConfigDir, { recursive: true }); + fs.cpSync( + path.join(import.meta.dirname, "..", "src", "lib", "messaging"), + path.join(fixtureRoot, "src", "lib", "messaging"), + { recursive: true }, + ); return fixtureScriptPath; } @@ -164,6 +203,18 @@ afterEach(() => { }); describe("agents/hermes/generate-config.ts", () => { + it("leaves messaging render to the messaging build applier", () => { + const result = runConfigScriptRaw({ + NEMOCLAW_MESSAGING_CHANNELS_B64: encodeJson(["telegram"]), + }); + expect(result.status, result.stderr).toBe(0); + const hermesDir = path.join(tmpDir, ".hermes"); + const config = YAML.parse(fs.readFileSync(path.join(hermesDir, "config.yaml"), "utf-8")); + const envFile = fs.readFileSync(path.join(hermesDir, ".env"), "utf-8"); + expect(config.platforms.telegram).toBeUndefined(); + expect(envFile).not.toContain("TELEGRAM_BOT_TOKEN="); + }); + it("generates API server config without messaging platform token blocks", () => { const { config, envFile } = runConfigScript(); diff --git a/test/run-openclaw-build-hooks.test.ts b/test/messaging-build-applier.test.ts similarity index 59% rename from test/run-openclaw-build-hooks.test.ts rename to test/messaging-build-applier.test.ts index 4e29be9e54..9163164bf5 100644 --- a/test/run-openclaw-build-hooks.test.ts +++ b/test/messaging-build-applier.test.ts @@ -2,7 +2,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 // -// Functional tests for scripts/run-openclaw-build-hooks.mts. +// Functional tests for src/lib/messaging/applier/build/messaging-build-applier.mts. import { describe, it, expect } from "vitest"; import fs from "node:fs"; @@ -14,8 +14,12 @@ import { withLegacyMessagingPlanEnv } from "./messaging-plan-test-helper"; const SCRIPT_PATH = path.join( import.meta.dirname, "..", - "scripts", - "run-openclaw-build-hooks.mts", + "src", + "lib", + "messaging", + "applier", + "build", + "messaging-build-applier.mts", ); const GENERATOR_PATH = path.join( import.meta.dirname, @@ -52,7 +56,7 @@ function runDryRun(envOverrides: Record = {}) { }, "openclaw", ); - return spawnSync("node", ["--experimental-strip-types", SCRIPT_PATH, "--dry-run"], { + return spawnSync("node", ["--experimental-strip-types", SCRIPT_PATH, "--agent", "openclaw", "--phase", "agent-install", "--dry-run"], { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], env, @@ -66,7 +70,7 @@ function parseDryRun(envOverrides: Record = {}) { return JSON.parse(result.stdout); } -describe("run-openclaw-build-hooks.mts", () => { +describe("messaging-build-applier.mts: agent-install", () => { it("pins selected external messaging plugins to OPENCLAW_VERSION", () => { const payload = parseDryRun({ OPENCLAW_VERSION: "2026.5.22", @@ -124,23 +128,13 @@ describe("run-openclaw-build-hooks.mts", () => { expect(payload.installSpecs).toEqual(["npm:@openclaw/whatsapp@2026.5.18"]); }); - it("pins the diagnostics OTEL plugin when OpenClaw OTEL is enabled", () => { + it("does not include non-messaging OTEL diagnostics in messaging package installs", () => { const payload = parseDryRun({ OPENCLAW_VERSION: "2026.5.22", NEMOCLAW_OPENCLAW_OTEL: "1", }); - expect(payload.diagnosticsOtelEnabled).toBe(true); - expect(payload.installSpecs).toEqual(["npm:@openclaw/diagnostics-otel@2026.5.22"]); - }); - - it("requires OPENCLAW_VERSION when OpenClaw OTEL is enabled", () => { - const result = runDryRun({ - NEMOCLAW_OPENCLAW_OTEL: "1", - }); - - expect(result.status).not.toBe(0); - expect(result.stderr).toContain("OPENCLAW_VERSION is required"); + expect(payload.installSpecs).toEqual([]); }); it("fails fast on malformed messaging plans", () => { @@ -153,7 +147,7 @@ describe("run-openclaw-build-hooks.mts", () => { expect(result.stderr).toContain("NEMOCLAW_MESSAGING_PLAN_B64"); }); - it("runs pinned installs before doctor and limits doctor env injection to the doctor command", () => { + it("runs pinned installs during agent-install without doctor env injection", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-message-plugins-")); const tracePath = path.join(tmp, "openclaw.trace"); const fakeOpenclaw = path.join(tmp, "openclaw"); @@ -183,7 +177,7 @@ describe("run-openclaw-build-hooks.mts", () => { }, "openclaw", ); - const result = spawnSync("node", ["--experimental-strip-types", SCRIPT_PATH], { + const result = spawnSync("node", ["--experimental-strip-types", SCRIPT_PATH, "--agent", "openclaw", "--phase", "agent-install"], { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], env: planEnv, @@ -195,22 +189,13 @@ describe("run-openclaw-build-hooks.mts", () => { "plugins|install|npm:@openclaw/discord@2026.5.22|--pin|||", "plugins|install|npm:@openclaw/slack@2026.5.22|--pin|||", "plugins|install|npm:@openclaw/whatsapp@2026.5.22|--pin|||", - [ - "doctor", - "--fix", - "--non-interactive", - "", - "openshell:resolve:env:TELEGRAM_BOT_TOKEN", - "openshell:resolve:env:DISCORD_BOT_TOKEN", - "xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN", - ].join("|"), ]); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } }); - it("#4246: generated Discord config reaches the mocked OpenClaw plugin-load boundary", () => { + it("#4246: messaging post-agent-install render reaches the mocked OpenClaw doctor boundary", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-discord-runtime-contract-")); const tracePath = path.join(tmp, "openclaw.trace"); const fakeOpenclaw = path.join(tmp, "openclaw"); @@ -260,24 +245,159 @@ describe("run-openclaw-build-hooks.mts", () => { }); expect(generatorResult.status, generatorResult.stderr).toBe(0); - const pluginResult = spawnSync("node", ["--experimental-strip-types", SCRIPT_PATH], { + const applierEnv = { + PATH: `${tmp}:${process.env.PATH || "/usr/bin:/bin"}`, + HOME: tmp, + OPENCLAW_TRACE: tracePath, + OPENCLAW_VERSION: "2026.5.22", + NEMOCLAW_MESSAGING_PLAN_B64: generatorEnv.NEMOCLAW_MESSAGING_PLAN_B64, + }; + const pluginResult = spawnSync( + "node", + ["--experimental-strip-types", SCRIPT_PATH, "--agent", "openclaw", "--phase", "agent-install"], + { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + env: applierEnv, + timeout: 10_000, + }, + ); + expect(pluginResult.status, pluginResult.stderr).toBe(0); + + const postInstallResult = spawnSync( + "node", + ["--experimental-strip-types", SCRIPT_PATH, "--agent", "openclaw", "--phase", "post-agent-install"], + { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + env: applierEnv, + timeout: 10_000, + }, + ); + + expect(postInstallResult.status, postInstallResult.stderr).toBe(0); + expect(fs.readFileSync(tracePath, "utf-8").trim().split("\n")).toEqual([ + "plugins|install|npm:@openclaw/discord@2026.5.22|--pin|", + "doctor|--fix|--non-interactive|openshell:resolve:env:DISCORD_BOT_TOKEN", + ]); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("applies post-agent-install WeChat build files from the compiled messaging plan", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-post-agent-install-")); + const channels = channelsB64(["wechat"]); + const wechatConfig = Buffer.from( + JSON.stringify({ accountId: "primary", baseUrl: "https://example", userId: "u1" }), + ).toString("base64"); + + try { + const generatorEnv = withLegacyMessagingPlanEnv( + { + PATH: process.env.PATH || "/usr/bin:/bin", + HOME: tmp, + ...BASE_GENERATOR_ENV, + NEMOCLAW_MESSAGING_CHANNELS_B64: channels, + NEMOCLAW_WECHAT_CONFIG_B64: wechatConfig, + NEMOCLAW_OPENCLAW_MANAGED_PROXY: "0", + }, + "openclaw", + ); + const generatorResult = spawnSync("node", ["--experimental-strip-types", GENERATOR_PATH], { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + env: generatorEnv, + timeout: 10_000, + }); + expect(generatorResult.status, generatorResult.stderr).toBe(0); + + const fakeOpenclaw = path.join(tmp, "openclaw"); + fs.writeFileSync(fakeOpenclaw, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + const postInstallResult = spawnSync("node", ["--experimental-strip-types", SCRIPT_PATH, "--agent", "openclaw", "--phase", "post-agent-install"], { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], env: { PATH: `${tmp}:${process.env.PATH || "/usr/bin:/bin"}`, HOME: tmp, - OPENCLAW_TRACE: tracePath, - OPENCLAW_VERSION: "2026.5.22", NEMOCLAW_MESSAGING_PLAN_B64: generatorEnv.NEMOCLAW_MESSAGING_PLAN_B64, }, timeout: 10_000, }); + expect(postInstallResult.status, postInstallResult.stderr).toBe(0); - expect(pluginResult.status, pluginResult.stderr).toBe(0); - expect(fs.readFileSync(tracePath, "utf-8").trim().split("\n")).toEqual([ - "plugins|install|npm:@openclaw/discord@2026.5.22|--pin|", - "doctor|--fix|--non-interactive|openshell:resolve:env:DISCORD_BOT_TOKEN", - ]); + const config = JSON.parse(fs.readFileSync(path.join(tmp, ".openclaw", "openclaw.json"), "utf-8")); + expect(config.plugins?.installs?.["openclaw-weixin"]).toEqual({ + source: "npm", + spec: "@tencent-weixin/openclaw-weixin@2.4.3", + installPath: "/sandbox/.openclaw/extensions/openclaw-weixin", + }); + expect(config.plugins?.load?.paths).toEqual(["/sandbox/.openclaw/extensions/openclaw-weixin"]); + expect(config.channels?.["openclaw-weixin"]?.accounts?.primary).toEqual({ enabled: true }); + expect(config.channels?.wechat).toBeUndefined(); + + const account = JSON.parse( + fs.readFileSync(path.join(tmp, ".openclaw", "openclaw-weixin", "accounts", "primary.json"), "utf-8"), + ); + expect(account).toMatchObject({ + token: "openshell:resolve:env:WECHAT_BOT_TOKEN", + baseUrl: "https://example", + userId: "u1", + }); + expect( + JSON.parse(fs.readFileSync(path.join(tmp, ".openclaw", "openclaw-weixin", "accounts.json"), "utf-8")), + ).toEqual(["primary"]); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("applies Hermes messaging render to config.yaml and .env in post-agent-install", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-render-")); + try { + const hermesDir = path.join(tmp, ".hermes"); + fs.mkdirSync(hermesDir, { recursive: true }); + fs.writeFileSync( + path.join(hermesDir, "config.yaml"), + [ + "_config_version: 12", + "platform_toolsets:", + " api_server:", + " - web", + "platforms:", + " api_server:", + " enabled: true", + "", + ].join("\n"), + ); + fs.writeFileSync(path.join(hermesDir, ".env"), "API_SERVER_PORT=18642\n"); + const env = withLegacyMessagingPlanEnv( + { + PATH: process.env.PATH || "/usr/bin:/bin", + HOME: tmp, + NEMOCLAW_MESSAGING_CHANNELS_B64: channelsB64(["telegram"]), + }, + "hermes", + ); + + const result = spawnSync( + "node", + ["--experimental-strip-types", SCRIPT_PATH, "--agent", "hermes", "--phase", "post-agent-install"], + { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + env, + timeout: 10_000, + }, + ); + + expect(result.status, result.stderr).toBe(0); + const configYaml = fs.readFileSync(path.join(hermesDir, "config.yaml"), "utf-8"); + expect(configYaml).toContain("telegram:"); + expect(configYaml).toContain("enabled: true"); + const envFile = fs.readFileSync(path.join(hermesDir, ".env"), "utf-8"); + expect(envFile).toContain("API_SERVER_PORT=18642\n"); + expect(envFile).toContain("TELEGRAM_BOT_TOKEN=openshell:resolve:env:TELEGRAM_BOT_TOKEN\n"); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } diff --git a/test/generate-openclaw-config.test.ts b/test/openclaw-config-render.test.ts similarity index 91% rename from test/generate-openclaw-config.test.ts rename to test/openclaw-config-render.test.ts index e54875e030..9c061ee2d6 100644 --- a/test/generate-openclaw-config.test.ts +++ b/test/openclaw-config-render.test.ts @@ -13,10 +13,24 @@ import path from "node:path"; import { spawnSync } from "node:child_process"; import { buildConfig, main } from "../scripts/generate-openclaw-config.mts"; +import { + applyMessagingAgentRenderToObject, + readMessagingBuildPlanFromEnv, +} from "../src/lib/messaging/applier/build/messaging-build-applier.mts"; import { withLegacyMessagingPlanEnv } from "./messaging-plan-test-helper"; const SCRIPT_PATH = path.join(import.meta.dirname, "..", "scripts", "generate-openclaw-config.mts"); const SCRIPT_ARGS = ["--experimental-strip-types", SCRIPT_PATH]; +const APPLIER_PATH = path.join( + import.meta.dirname, + "..", + "src", + "lib", + "messaging", + "applier", + "build", + "messaging-build-applier.mts", +); /** Minimal env vars required for a valid config generation run. */ const BASE_ENV: Record = { @@ -37,9 +51,18 @@ const BASE_ENV: Record = { let tmpDir: string; +function ensureFakeOpenClaw(): string { + const fakeOpenclaw = path.join(tmpDir, "openclaw"); + if (!fs.existsSync(fakeOpenclaw)) { + fs.writeFileSync(fakeOpenclaw, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + } + return fakeOpenclaw; +} + function buildTestEnv(envOverrides: Record = {}): Record { + ensureFakeOpenClaw(); const env = { - PATH: process.env.PATH || "/usr/bin:/bin", + PATH: `${tmpDir}:${process.env.PATH || "/usr/bin:/bin"}`, ...BASE_ENV, ...envOverrides, HOME: tmpDir, @@ -58,9 +81,8 @@ function runConfigScriptRaw(envOverrides: Record = {}) { return result; } -function withConfigEnv(envOverrides: Record, fn: () => T): T { +function withEnv(env: Record, fn: () => T): T { const originalEnv = { ...process.env }; - const env = buildTestEnv(envOverrides); try { for (const key of Object.keys(process.env)) { delete process.env[key]; @@ -75,28 +97,76 @@ function withConfigEnv(envOverrides: Record, fn: () => T): T } } +function withConfigEnv(envOverrides: Record, fn: () => T): T { + return withEnv(buildTestEnv(envOverrides), fn); +} + +function runMessagingPostInstall(env: Record): void { + const result = spawnSync( + "node", + ["--experimental-strip-types", APPLIER_PATH, "--agent", "openclaw", "--phase", "post-agent-install"], + { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + env, + timeout: 10_000, + }, + ); + if (result.status !== 0) { + throw new Error( + `Messaging applier failed (exit ${result.status}): +stdout: ${result.stdout} +stderr: ${result.stderr}`, + ); + } +} + function runConfigScript(envOverrides: Record = {}): any { - withConfigEnv(envOverrides, () => main()); + const env = buildTestEnv(envOverrides); + withEnv(env, () => main()); + runMessagingPostInstall(env); const configPath = path.join(tmpDir, ".openclaw", "openclaw.json"); return JSON.parse(fs.readFileSync(configPath, "utf-8")); } function runConfigSubprocess(envOverrides: Record = {}): any { - const result = runConfigScriptRaw(envOverrides); + const env = buildTestEnv(envOverrides); + const result = spawnSync("node", SCRIPT_ARGS, { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + env, + timeout: 10_000, + }); if (result.status !== 0) { throw new Error( - `Script failed (exit ${result.status}):\nstdout: ${result.stdout}\nstderr: ${result.stderr}`, + `Script failed (exit ${result.status}): +stdout: ${result.stdout} +stderr: ${result.stderr}`, ); } + runMessagingPostInstall(env); const configPath = path.join(tmpDir, ".openclaw", "openclaw.json"); return JSON.parse(fs.readFileSync(configPath, "utf-8")); } -function buildConfigDirect(envOverrides: Record = {}): any { +function buildBaseConfigDirect(envOverrides: Record = {}): any { return withConfigEnv(envOverrides, () => buildConfig()); } +function buildConfigDirect(envOverrides: Record = {}): any { + const env = buildTestEnv(envOverrides); + return withEnv(env, () => { + const config = buildConfig(); + applyMessagingAgentRenderToObject( + config, + readMessagingBuildPlanFromEnv(env, "openclaw"), + "openclaw.json", + ); + return config; + }); +} + function expectBuildConfigError(envOverrides: Record, message: string | RegExp) { expect(() => buildConfigDirect(envOverrides)).toThrow(message); } @@ -371,6 +441,12 @@ describe("generate-openclaw-config.mts: config generation", () => { expect(origins).not.toContain("https://example.com"); }); + it("leaves messaging render to the messaging build applier", () => { + const channels = Buffer.from(JSON.stringify(["telegram"])).toString("base64"); + const config = buildBaseConfigDirect({ NEMOCLAW_MESSAGING_CHANNELS_B64: channels }); + expect(config.channels.telegram).toBeUndefined(); + }); + it("parses messaging channels from base64", () => { const channels = Buffer.from(JSON.stringify(["telegram"])).toString("base64"); const config = runConfigScript({ NEMOCLAW_MESSAGING_CHANNELS_B64: channels }); @@ -449,105 +525,7 @@ describe("generate-openclaw-config.mts: config generation", () => { expect(config.channels.discord.guilds).toEqual(guilds); }); - it("seeds channels.openclaw-weixin from manifest build-file outputs", () => { - const channels = Buffer.from(JSON.stringify(["wechat"])).toString("base64"); - const wechatConfig = Buffer.from( - JSON.stringify({ accountId: "primary", baseUrl: "https://example", userId: "u1" }), - ).toString("base64"); - const config = runConfigScript({ - NEMOCLAW_MESSAGING_CHANNELS_B64: channels, - NEMOCLAW_WECHAT_CONFIG_B64: wechatConfig, - }); - expect(config.plugins?.installs?.["openclaw-weixin"]).toEqual({ - source: "npm", - spec: "@tencent-weixin/openclaw-weixin@2.4.3", - installPath: SANDBOX_WECHAT_PLUGIN_INSTALL_PATH, - }); - expect(config.plugins?.load?.paths).toEqual([SANDBOX_WECHAT_PLUGIN_INSTALL_PATH]); - expect(config.channels?.["openclaw-weixin"]?.accounts?.primary).toEqual({ - enabled: true, - }); - // The "wechat" alias is the NemoClaw channel name, not an OpenClaw - // channel id — must never appear under channels. - expect(config.channels?.wechat).toBeUndefined(); - }); - - it("ignores installed WeChat metadata in nested extension directories", () => { - const pluginDir = path.join(tmpDir, ".openclaw", "extensions", "vendor", "openclaw-weixin"); - fs.mkdirSync(pluginDir, { recursive: true }); - fs.mkdirSync(path.join(tmpDir, ".openclaw", "extensions", "node_modules"), { recursive: true }); - fs.writeFileSync( - path.join(pluginDir, "package.json"), - JSON.stringify({ name: "@tencent-weixin/openclaw-weixin" }), - ); - - const channels = Buffer.from(JSON.stringify(["wechat"])).toString("base64"); - const wechatConfig = Buffer.from( - JSON.stringify({ accountId: "primary", baseUrl: "https://example", userId: "u1" }), - ).toString("base64"); - const config = runConfigScript({ - NEMOCLAW_MESSAGING_CHANNELS_B64: channels, - NEMOCLAW_WECHAT_CONFIG_B64: wechatConfig, - }); - - expect(config.channels?.["openclaw-weixin"]?.accounts?.primary).toEqual({ - enabled: true, - }); - }); - - it("uses canonical sandbox WeChat install metadata when the base plugin install registry exists", () => { - const configPath = path.join(tmpDir, ".openclaw", "openclaw.json"); - const installEntry = { - source: "npm", - spec: "@tencent-weixin/openclaw-weixin@2.4.3", - }; - fs.mkdirSync(path.dirname(configPath), { recursive: true }); - fs.writeFileSync( - configPath, - JSON.stringify({ plugins: { installs: { "openclaw-weixin": installEntry } } }), - ); - - const channels = Buffer.from(JSON.stringify(["wechat"])).toString("base64"); - const wechatConfig = Buffer.from( - JSON.stringify({ accountId: "primary", baseUrl: "https://example", userId: "u1" }), - ).toString("base64"); - const config = runConfigScript({ - NEMOCLAW_MESSAGING_CHANNELS_B64: channels, - NEMOCLAW_WECHAT_CONFIG_B64: wechatConfig, - }); - - expect(config.plugins?.installs?.["openclaw-weixin"]).toEqual({ - ...installEntry, - installPath: SANDBOX_WECHAT_PLUGIN_INSTALL_PATH, - }); - expect(config.plugins?.load?.paths).toEqual([SANDBOX_WECHAT_PLUGIN_INSTALL_PATH]); - expect(config.channels?.["openclaw-weixin"]?.accounts?.primary).toEqual({ - enabled: true, - }); - expect(config.channels?.wechat).toBeUndefined(); - - const accountFile = path.join( - tmpDir, - ".openclaw", - "openclaw-weixin", - "accounts", - "primary.json", - ); - const account = JSON.parse(fs.readFileSync(accountFile, "utf-8")); - expect(account).toMatchObject({ - token: "openshell:resolve:env:WECHAT_BOT_TOKEN", - baseUrl: "https://example", - userId: "u1", - }); - }); - - it("uses canonical sandbox WeChat install metadata when host plugin metadata exists", () => { - writeWeChatPluginMetadata({ - id: "openclaw-weixin", - channels: ["openclaw-weixin"], - channelConfigs: { "openclaw-weixin": {} }, - }); - + it("applies WeChat post-agent-install build-file outputs through the messaging applier", () => { const channels = Buffer.from(JSON.stringify(["wechat"])).toString("base64"); const wechatConfig = Buffer.from( JSON.stringify({ accountId: "primary", baseUrl: "https://example", userId: "u1" }), @@ -560,87 +538,9 @@ describe("generate-openclaw-config.mts: config generation", () => { expect(config.plugins?.installs?.["openclaw-weixin"]).toEqual({ source: "npm", spec: "@tencent-weixin/openclaw-weixin@2.4.3", - installPath: SANDBOX_WECHAT_PLUGIN_INSTALL_PATH, - }); - expect(config.plugins?.load?.paths).toEqual([SANDBOX_WECHAT_PLUGIN_INSTALL_PATH]); - expect(config.channels?.["openclaw-weixin"]?.accounts?.primary).toEqual({ - enabled: true, - }); - expect(config.channels?.wechat).toBeUndefined(); - }); - - it("ignores npm package metadata when manifest build-file output seeds WeChat", () => { - writeWeChatNpmPackageMetadata({ - name: "@tencent-weixin/openclaw-weixin", - openclaw: { channels: ["vendor-weixin"] }, - }); - - const channels = Buffer.from(JSON.stringify(["wechat"])).toString("base64"); - const wechatConfig = Buffer.from( - JSON.stringify({ accountId: "primary", baseUrl: "https://example", userId: "u1" }), - ).toString("base64"); - const config = runConfigScript({ - NEMOCLAW_MESSAGING_CHANNELS_B64: channels, - NEMOCLAW_WECHAT_CONFIG_B64: wechatConfig, - }); - - expect(config.plugins?.installs?.["openclaw-weixin"]).toEqual({ - source: "npm", - spec: "@tencent-weixin/openclaw-weixin@2.4.3", - installPath: SANDBOX_WECHAT_PLUGIN_INSTALL_PATH, - }); - expect(config.plugins?.load?.paths).toEqual([SANDBOX_WECHAT_PLUGIN_INSTALL_PATH]); - expect(config.channels?.["vendor-weixin"]).toBeUndefined(); - expect(config.channels?.["openclaw-weixin"]?.accounts?.primary).toEqual({ - enabled: true, - }); - expect(config.channels?.wechat).toBeUndefined(); - expect(fs.existsSync(wechatExtensionPath())).toBe(false); - }); - - it("ignores npm plugin metadata when manifest build-file output seeds WeChat", () => { - writeWeChatNpmPluginMetadata({ - id: "openclaw-weixin", - channelConfigs: { "vendor-weixin": {} }, - }); - - const channels = Buffer.from(JSON.stringify(["wechat"])).toString("base64"); - const wechatConfig = Buffer.from( - JSON.stringify({ accountId: "primary", baseUrl: "https://example", userId: "u1" }), - ).toString("base64"); - const config = runConfigScript({ - NEMOCLAW_MESSAGING_CHANNELS_B64: channels, - NEMOCLAW_WECHAT_CONFIG_B64: wechatConfig, - }); - - expect(config.plugins?.installs?.["openclaw-weixin"]).toEqual({ - source: "npm", - spec: "@tencent-weixin/openclaw-weixin@2.4.3", - installPath: SANDBOX_WECHAT_PLUGIN_INSTALL_PATH, - }); - expect(config.plugins?.load?.paths).toEqual([SANDBOX_WECHAT_PLUGIN_INSTALL_PATH]); - expect(config.channels?.["vendor-weixin"]).toBeUndefined(); - expect(config.channels?.["openclaw-weixin"]?.accounts?.primary).toEqual({ - enabled: true, - }); - expect(config.channels?.wechat).toBeUndefined(); - expect(fs.existsSync(wechatExtensionPath())).toBe(false); - }); - - it("seeds channels.openclaw-weixin when the Dockerfile marks the plugin preinstalled", () => { - const channels = Buffer.from(JSON.stringify(["wechat"])).toString("base64"); - const wechatConfig = Buffer.from( - JSON.stringify({ accountId: "primary", baseUrl: "https://example", userId: "u1" }), - ).toString("base64"); - const config = runConfigScript({ - NEMOCLAW_MESSAGING_CHANNELS_B64: channels, - NEMOCLAW_WECHAT_CONFIG_B64: wechatConfig, - NEMOCLAW_OPENCLAW_WECHAT_PLUGIN_PREINSTALLED: "1", - }); - - expect(config.channels?.["openclaw-weixin"]?.accounts?.primary).toEqual({ - enabled: true, + installPath: "/sandbox/.openclaw/extensions/openclaw-weixin", }); + expect(config.channels?.["openclaw-weixin"]?.accounts?.primary).toEqual({ enabled: true }); expect(config.channels?.wechat).toBeUndefined(); }); diff --git a/test/sandbox-build-context.test.ts b/test/sandbox-build-context.test.ts index ffa9becef4..ef744d2630 100644 --- a/test/sandbox-build-context.test.ts +++ b/test/sandbox-build-context.test.ts @@ -72,11 +72,12 @@ describe("sandbox build context staging", () => { fs.chmodSync(blueprintManifestDir, 0o700); writeFixture(path.join("scripts", "nemoclaw-start.sh")); writeFixture(path.join("scripts", "codex-acp-wrapper.sh")); + writeFixture(path.join("scripts", "generate-openclaw-config.mts")); writeFixture(path.join("scripts", "lib", "sandbox-init.sh")); writeFixture(path.join("scripts", "lib", "openclaw_device_approval_policy.py")); writeFixture(path.join("scripts", "lib", "clean_runtime_shell_env_shim.py")); - writeFixture(path.join("scripts", "generate-openclaw-config.mts")); - writeFixture(path.join("scripts", "run-openclaw-build-hooks.mts")); + writeFixture(path.join("src", "lib", "messaging", "applier", "build", "messaging-build-applier.mts")); + writeFixture(path.join("src", "lib", "messaging", "channels", "fixture", "hooks", "example.ts")); writeFixture(path.join("scripts", "patch-openclaw-tool-catalog.js")); writeFixture(path.join("scripts", "patch-openclaw-chat-send.js")); } @@ -245,11 +246,32 @@ describe("sandbox build context staging", () => { ).toBe(true); expect(fs.existsSync(path.join(buildCtx, "scripts", "nemoclaw-start.sh"))).toBe(true); expect(fs.existsSync(path.join(buildCtx, "scripts", "codex-acp-wrapper.sh"))).toBe(true); - expect(fs.existsSync(path.join(buildCtx, "scripts", "generate-openclaw-config.mts"))).toBe( - true, - ); + expect(fs.existsSync(path.join(buildCtx, "scripts", "generate-openclaw-config.mts"))).toBe(true); + expect( + fs.existsSync( + path.join( + buildCtx, + "src", + "lib", + "messaging", + "applier", + "build", + "messaging-build-applier.mts", + ), + ), + ).toBe(true); expect( - fs.existsSync(path.join(buildCtx, "scripts", "run-openclaw-build-hooks.mts")), + fs.existsSync( + path.join( + buildCtx, + "src", + "lib", + "messaging", + "hooks", + "common", + "static-outputs.ts", + ), + ), ).toBe(true); expect( fs.existsSync(path.join(buildCtx, "scripts", "lib", "openclaw_device_approval_policy.py")), diff --git a/test/sandbox-provisioning.test.ts b/test/sandbox-provisioning.test.ts index e9200ff710..f3638cb7a0 100644 --- a/test/sandbox-provisioning.test.ts +++ b/test/sandbox-provisioning.test.ts @@ -850,6 +850,11 @@ describe("sandbox provisioning: copied OpenClaw helper permissions (#2861)", () const localBin = path.join(tmp, "usr", "local", "bin"); const localLib = path.join(tmp, "usr", "local", "lib", "nemoclaw"); const localShare = path.join(tmp, "usr", "local", "share", "nemoclaw"); + const localSrc = path.join(tmp, "src"); + const localScripts = path.join(tmp, "scripts"); + const generatorPath = path.join(localScripts, "generate-openclaw-config.mts"); + const applierPath = path.join(localSrc, "lib", "messaging", "applier", "build", "messaging-build-applier.mts"); + const messagingHookPath = path.join(localSrc, "lib", "messaging", "channels", "fixture", "hooks", "example.ts"); const pluginDir = path.join(localShare, "openclaw-plugins", "kimi-inference-compat"); const pluginFile = path.join(pluginDir, "index.js"); const nestedPluginDir = path.join(pluginDir, "lib"); @@ -860,8 +865,9 @@ describe("sandbox provisioning: copied OpenClaw helper permissions (#2861)", () path.join(localLib, "sandbox-init.sh"), path.join(localLib, "openclaw_device_approval_policy.py"), path.join(localLib, "clean_runtime_shell_env_shim.py"), - path.join(localLib, "generate-openclaw-config.mts"), - path.join(localLib, "run-openclaw-build-hooks.mts"), + generatorPath, + applierPath, + messagingHookPath, path.join(localLib, "ws-proxy-fix.js"), pluginFile, nestedPluginFile, @@ -870,7 +876,10 @@ describe("sandbox provisioning: copied OpenClaw helper permissions (#2861)", () try { fs.mkdirSync(localBin, { recursive: true }); fs.mkdirSync(localLib, { recursive: true }); + fs.mkdirSync(localScripts, { recursive: true }); fs.mkdirSync(nestedPluginDir, { recursive: true }); + fs.mkdirSync(path.dirname(applierPath), { recursive: true }); + fs.mkdirSync(path.dirname(messagingHookPath), { recursive: true }); for (const file of files) { fs.writeFileSync(file, "# fixture\n", { mode: 0o600 }); fs.chmodSync(file, 0o600); @@ -883,16 +892,15 @@ describe("sandbox provisioning: copied OpenClaw helper permissions (#2861)", () ) .replaceAll("/usr/local/bin", localBin) .replaceAll("/usr/local/lib/nemoclaw", localLib) - .replaceAll("/usr/local/share/nemoclaw", localShare); + .replaceAll("/usr/local/share/nemoclaw", localShare) + .replaceAll("/src", localSrc) + .replaceAll("/scripts", localScripts); const { result } = runLoggedDockerShell(command, tmp); expect(result.status, result.stderr).toBe(0); - const generatorMode = ( - fs.statSync(path.join(localLib, "generate-openclaw-config.mts")).mode & 0o777 - ).toString(8); - const buildHookRunnerMode = ( - fs.statSync(path.join(localLib, "run-openclaw-build-hooks.mts")).mode & 0o777 - ).toString(8); + const generatorMode = (fs.statSync(generatorPath).mode & 0o777).toString(8); + const applierMode = (fs.statSync(applierPath).mode & 0o777).toString(8); + const messagingHookMode = (fs.statSync(messagingHookPath).mode & 0o777).toString(8); const approvalPolicyMode = ( fs.statSync(path.join(localLib, "openclaw_device_approval_policy.py")).mode & 0o777 ).toString(8); @@ -901,7 +909,8 @@ describe("sandbox provisioning: copied OpenClaw helper permissions (#2861)", () const nestedPluginDirMode = (fs.statSync(nestedPluginDir).mode & 0o777).toString(8); const nestedPluginMode = (fs.statSync(nestedPluginFile).mode & 0o777).toString(8); expect(generatorMode).toBe("755"); - expect(buildHookRunnerMode).toBe("755"); + expect(applierMode).toBe("755"); + expect(messagingHookMode).toBe("644"); expect(approvalPolicyMode).toBe("644"); expect(pluginDirMode).toBe("755"); expect(pluginMode).toBe("644"); diff --git a/test/security-c2-dockerfile-injection.test.ts b/test/security-c2-dockerfile-injection.test.ts index b9daa7b7da..902da8d1f0 100644 --- a/test/security-c2-dockerfile-injection.test.ts +++ b/test/security-c2-dockerfile-injection.test.ts @@ -148,11 +148,12 @@ describe("Gateway auth hardening: Dockerfile must not hardcode insecure auth def inEnvBlock = false; } if ( - /^\s*RUN\b.*node\s+--experimental-strip-types\s+\/usr\/local\/lib\/nemoclaw\/generate-openclaw-config\.mts\b/.test( + /^\s*RUN\b.*node\s+--experimental-strip-types\s+\/scripts\/generate-openclaw-config\.mts\b/.test( line, ) ) { expect(promoted).toBeTruthy(); + sawGeneratorRun = true; return; } } From d25245a05551f064706467ccd9a66ea7f8099ede Mon Sep 17 00:00:00 2001 From: San Dang Date: Mon, 8 Jun 2026 19:41:16 +0530 Subject: [PATCH 03/23] fix(messaging): address PR review failures --- ci/test-file-size-budget.json | 6 +- .../applier/build/messaging-build-applier.mts | 17 +++++- .../compiler/engines/agent-render-engine.ts | 8 ++- .../messaging/compiler/manifest-compiler.ts | 16 +++-- src/lib/onboard.ts | 7 ++- test/e2e/docs/parity-inventory.generated.json | 2 +- test/helpers/messaging-plan-fixtures.ts | 61 +++++++++++++++++++ test/onboard-messaging.test.ts | 59 +----------------- test/openclaw-config-render.test.ts | 7 +-- test/security-c2-dockerfile-injection.test.ts | 4 +- 10 files changed, 103 insertions(+), 84 deletions(-) create mode 100644 test/helpers/messaging-plan-fixtures.ts diff --git a/ci/test-file-size-budget.json b/ci/test-file-size-budget.json index e5458c88b9..c097e80dd7 100644 --- a/ci/test-file-size-budget.json +++ b/ci/test-file-size-budget.json @@ -6,12 +6,12 @@ "src/lib/inference/nim.test.ts": 2079, "src/lib/onboard/preflight.test.ts": 1905, "test/channels-add-preset.test.ts": 1915, - "test/openclaw-config-render.test.ts": 2005, + "test/openclaw-config-render.test.ts": 2000, "test/install-preflight.test.ts": 4397, "test/nemoclaw-start.test.ts": 5300, - "test/onboard-messaging.test.ts": 2122, + "test/onboard-messaging.test.ts": 2120, "test/onboard-selection.test.ts": 7757, - "test/onboard.test.ts": 4887, + "test/onboard.test.ts": 4879, "test/policies.test.ts": 2763 } } diff --git a/src/lib/messaging/applier/build/messaging-build-applier.mts b/src/lib/messaging/applier/build/messaging-build-applier.mts index b3f886571a..f2a384525e 100755 --- a/src/lib/messaging/applier/build/messaging-build-applier.mts +++ b/src/lib/messaging/applier/build/messaging-build-applier.mts @@ -628,18 +628,29 @@ function setJsonPath(root: JsonObject, pathValue: string, value: MessagingSerial function mergeJsonObjects(target: JsonObject, patch: JsonObject): void { for (const [key, value] of Object.entries(patch)) { - assertSafeObjectKey(key, "Messaging object merge"); + if (key === "__proto__" || key === "prototype" || key === "constructor") { + throw new MessagingBuildApplierError("Messaging object merge rejected unsafe object key " + key); + } const existing = target[key]; if (isObject(existing) && isObject(value)) { mergeJsonObjects(existing as JsonObject, value as JsonObject); } else if (Array.isArray(existing) && Array.isArray(value)) { - target[key] = [...new Set([...existing, ...value])]; + setMergedObjectValue(target, key, [...new Set([...existing, ...value])]); } else { - target[key] = value; + setMergedObjectValue(target, key, value); } } } +function setMergedObjectValue(target: JsonObject, key: string, value: unknown): void { + Object.defineProperty(target, key, { + value, + enumerable: true, + configurable: true, + writable: true, + }); +} + function mergeEnvLines(existingLines: string[], desiredLines: readonly string[]): void { const desired = new Map(); const rawDesiredLines: string[] = []; diff --git a/src/lib/messaging/compiler/engines/agent-render-engine.ts b/src/lib/messaging/compiler/engines/agent-render-engine.ts index be0dc7f860..88e8933bcc 100644 --- a/src/lib/messaging/compiler/engines/agent-render-engine.ts +++ b/src/lib/messaging/compiler/engines/agent-render-engine.ts @@ -1,7 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { MessagingHookRegistry, runMessagingHook } from "../../hooks"; +import { + createBuiltInMessagingHookRegistry, + MessagingHookRegistry, + runMessagingHook, +} from "../../hooks"; import { COMMON_STATIC_OUTPUTS_HOOK_HANDLER_ID } from "../../hooks/common/static-outputs"; import type { ChannelHookSpec, @@ -28,7 +32,7 @@ export async function planAgentRender( manifest: ChannelManifest, context: ManifestCompilerContext, inputs: readonly SandboxMessagingInputReference[] = [], - hooks = new MessagingHookRegistry(), + hooks = createBuiltInMessagingHookRegistry(), ): Promise { const plans: SandboxMessagingAgentRenderPlan[] = []; const templateContext = { inputs, env: process.env }; diff --git a/src/lib/messaging/compiler/manifest-compiler.ts b/src/lib/messaging/compiler/manifest-compiler.ts index 27ae761b6c..37336307c3 100644 --- a/src/lib/messaging/compiler/manifest-compiler.ts +++ b/src/lib/messaging/compiler/manifest-compiler.ts @@ -331,16 +331,20 @@ function inputReferenceBase( } function readInputEnvValue(input: ChannelInputSpec): MessagingSerializableValue | undefined { + const normalize = (raw: string | null | undefined): string | undefined => { + const normalized = raw?.replace(/\r/g, "").trim(); + if (!normalized || normalized.length === 0) return undefined; + if (input.validValues && !input.validValues.includes(normalized)) return undefined; + return normalized; + }; + if (!input.envKey) return undefined; if (input.kind === "config") { const resolved = resolveMessagingChannelConfigEnvValue(input.envKey, process.env); - if (resolved.value) return resolved.value; + const normalizedResolved = normalize(resolved.value); + if (normalizedResolved !== undefined) return normalizedResolved; } - const value = process.env[input.envKey]; - const normalized = value?.replace(/\r/g, "").trim(); - if (!normalized || normalized.length === 0) return undefined; - if (input.validValues && !input.validValues.includes(normalized)) return undefined; - return normalized; + return normalize(process.env[input.envKey]); } function readInputStatePath(input: ChannelInputSpec): MessagingStatePath | undefined { diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 32cb2e0a5d..515b237a0d 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -3380,7 +3380,9 @@ async function createSandbox( // Off by default so existing sandboxes behave the same; opt-in via // TELEGRAM_REQUIRE_MENTION=1 or the interactive prompt. See #1737. const telegramConfig: { requireMention?: boolean } = {}; - if (activeMessagingChannels.includes("telegram")) { + const configuredMessagingChannels = + enabledChannels != null ? [...new Set(enabledChannels)] : activeMessagingChannels; + if (configuredMessagingChannels.includes("telegram")) { const telegramRequireMention = computeTelegramRequireMention(); if (telegramRequireMention !== null) { telegramConfig.requireMention = telegramRequireMention; @@ -3706,8 +3708,7 @@ async function createSandbox( // X, but X is still configured — losing it here means a later `channels start // X` has nothing to re-enable (the next rebuild sees an empty channel set and // never reattaches the gateway bridge). See #3381. - messagingChannels: - enabledChannels != null ? [...new Set(enabledChannels)] : activeMessagingChannels, + messagingChannels: configuredMessagingChannels, messagingChannelConfig: messagingChannelConfig || undefined, messaging: messagingState, disabledChannels: disabledChannels.length > 0 ? [...disabledChannels] : undefined, diff --git a/test/e2e/docs/parity-inventory.generated.json b/test/e2e/docs/parity-inventory.generated.json index 1f481405b8..7fcc7f3fa1 100644 --- a/test/e2e/docs/parity-inventory.generated.json +++ b/test/e2e/docs/parity-inventory.generated.json @@ -8804,7 +8804,7 @@ "line": 1212, "text": "M-W9: Real WeChat token spliced into accounts/${WECHAT_ACCOUNT}.json — manifest seed placeholder regression", "polarity": "fail", - "normalized_id": "m.w9.real.wechat.token.spliced.into.accounts.wechat.account.json.seed.wechat.accounts.py.placeholder.regression", + "normalized_id": "m.w9.real.wechat.token.spliced.into.accounts.wechat.account.json.manifest.seed.placeholder.regression", "mapping_status": "deferred" }, { diff --git a/test/helpers/messaging-plan-fixtures.ts b/test/helpers/messaging-plan-fixtures.ts new file mode 100644 index 0000000000..bf3bc2e652 --- /dev/null +++ b/test/helpers/messaging-plan-fixtures.ts @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; + +type MessagingPlanChannel = { + channelId?: unknown; + active?: unknown; +}; + +type MessagingPlan = { + channels?: MessagingPlanChannel[]; +}; + +function readMessagingPlanFromDockerfile(dockerfileContent: string | undefined): MessagingPlan { + assert.ok(dockerfileContent, "expected Dockerfile content"); + const prefix = "ARG NEMOCLAW_MESSAGING_PLAN_B64="; + const line = dockerfileContent.split("\n").find((entry) => entry.startsWith(prefix)); + assert.ok(line, "expected messaging plan build arg in Dockerfile"); + return JSON.parse(Buffer.from(line.slice(prefix.length), "base64").toString("utf8")); +} + +export function activeChannelsFromDockerfile(dockerfileContent: string | undefined): string[] { + const plan = readMessagingPlanFromDockerfile(dockerfileContent); + return (plan.channels ?? []) + .filter((channel) => channel.active === true && typeof channel.channelId === "string") + .map((channel) => String(channel.channelId)) + .sort(); +} + +export function encodeTestMessagingPlan( + channels: ReadonlyArray<{ readonly channelId: string; readonly active: boolean }>, +): string { + const plan = { + schemaVersion: 1, + sandboxName: "my-assistant", + agent: "openclaw", + workflow: "onboard", + channels: channels.map(({ channelId, active }) => ({ + channelId, + displayName: channelId, + authMode: "none", + active, + selected: true, + configured: true, + disabled: !active, + inputs: [], + hooks: [], + })), + disabledChannels: channels + .filter((channel) => !channel.active) + .map((channel) => channel.channelId), + credentialBindings: [], + networkPolicy: { presets: [], entries: [] }, + agentRender: [], + buildSteps: [], + stateUpdates: [], + healthChecks: [], + }; + return Buffer.from(JSON.stringify(plan), "utf8").toString("base64"); +} diff --git a/test/onboard-messaging.test.ts b/test/onboard-messaging.test.ts index 555ec6aef5..a7b6673c0c 100644 --- a/test/onboard-messaging.test.ts +++ b/test/onboard-messaging.test.ts @@ -12,6 +12,8 @@ import { pathToFileURL } from "node:url"; import { describe, it } from "vitest"; import YAML from "yaml"; +import { activeChannelsFromDockerfile, encodeTestMessagingPlan } from "./helpers/messaging-plan-fixtures"; + type CommandEntry = { command: string; env?: Record; @@ -21,63 +23,6 @@ type CommandEntry = { dockerfileReadError?: string; }; -type MessagingPlanChannel = { - channelId?: unknown; - active?: unknown; -}; - -type MessagingPlan = { - channels?: MessagingPlanChannel[]; -}; - -function readMessagingPlanFromDockerfile(dockerfileContent: string | undefined): MessagingPlan { - assert.ok(dockerfileContent, "expected Dockerfile content"); - const line = dockerfileContent - .split("\n") - .find((entry) => entry.startsWith("ARG NEMOCLAW_MESSAGING_PLAN_B64=")); - assert.ok(line, "expected messaging plan build arg in Dockerfile"); - const prefix = "ARG NEMOCLAW_MESSAGING_PLAN_B64="; - return JSON.parse(Buffer.from(line.slice(prefix.length), "base64").toString("utf8")); -} - -function activeChannelsFromDockerfile(dockerfileContent: string | undefined): string[] { - const plan = readMessagingPlanFromDockerfile(dockerfileContent); - return (plan.channels ?? []) - .filter((channel) => channel.active === true && typeof channel.channelId === "string") - .map((channel) => String(channel.channelId)) - .sort(); -} - -function encodeTestMessagingPlan( - channels: ReadonlyArray<{ readonly channelId: string; readonly active: boolean }>, -): string { - const plan = { - schemaVersion: 1, - sandboxName: "my-assistant", - agent: "openclaw", - workflow: "onboard", - channels: channels.map(({ channelId, active }) => ({ - channelId, - displayName: channelId, - authMode: "none", - active, - selected: true, - configured: true, - disabled: !active, - inputs: [], - hooks: [], - })), - disabledChannels: channels.filter((channel) => !channel.active).map((channel) => channel.channelId), - credentialBindings: [], - networkPolicy: { presets: [], entries: [] }, - agentRender: [], - buildSteps: [], - stateUpdates: [], - healthChecks: [], - }; - return Buffer.from(JSON.stringify(plan), "utf8").toString("base64"); -} - function parseStdoutJson(stdout: string): T { const line = stdout.trim().split("\n").pop(); assert.ok(line, `expected JSON payload in stdout:\n${stdout}`); diff --git a/test/openclaw-config-render.test.ts b/test/openclaw-config-render.test.ts index 9c061ee2d6..a0e863b78c 100644 --- a/test/openclaw-config-render.test.ts +++ b/test/openclaw-config-render.test.ts @@ -53,9 +53,7 @@ let tmpDir: string; function ensureFakeOpenClaw(): string { const fakeOpenclaw = path.join(tmpDir, "openclaw"); - if (!fs.existsSync(fakeOpenclaw)) { - fs.writeFileSync(fakeOpenclaw, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); - } + fs.writeFileSync(fakeOpenclaw, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); return fakeOpenclaw; } @@ -220,9 +218,6 @@ function wechatExtensionPath(stateDir = path.join(tmpDir, ".openclaw")) { return path.join(fs.realpathSync(stateDir), "extensions", "openclaw-weixin"); } - -const SANDBOX_WECHAT_PLUGIN_INSTALL_PATH = "/sandbox/.openclaw/extensions/openclaw-weixin"; - function writeRegistryManifest( blueprintDir: string, relativeManifestPath: string, diff --git a/test/security-c2-dockerfile-injection.test.ts b/test/security-c2-dockerfile-injection.test.ts index 902da8d1f0..d41d47685a 100644 --- a/test/security-c2-dockerfile-injection.test.ts +++ b/test/security-c2-dockerfile-injection.test.ts @@ -131,7 +131,6 @@ describe("Gateway auth hardening: Dockerfile must not hardcode insecure auth def const lines = src.split("\n"); let promoted = false; let inEnvBlock = false; - let sawGeneratorRun = false; for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (/^\s*FROM\b/.test(line)) { @@ -153,10 +152,9 @@ describe("Gateway auth hardening: Dockerfile must not hardcode insecure auth def ) ) { expect(promoted).toBeTruthy(); - sawGeneratorRun = true; return; } } - expect(sawGeneratorRun).toBeTruthy(); + throw new Error("expected generate-openclaw-config RUN layer"); }); }); From 19ab1d17b2572102f114afc4007198ff410128b4 Mon Sep 17 00:00:00 2001 From: San Dang Date: Mon, 8 Jun 2026 19:46:01 +0530 Subject: [PATCH 04/23] fix(ci): preserve openclaw config test budget --- ci/test-file-size-budget.json | 2 +- ...w-config-render.test.ts => generate-openclaw-config.test.ts} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename test/{openclaw-config-render.test.ts => generate-openclaw-config.test.ts} (100%) diff --git a/ci/test-file-size-budget.json b/ci/test-file-size-budget.json index c097e80dd7..613169ad2c 100644 --- a/ci/test-file-size-budget.json +++ b/ci/test-file-size-budget.json @@ -6,7 +6,7 @@ "src/lib/inference/nim.test.ts": 2079, "src/lib/onboard/preflight.test.ts": 1905, "test/channels-add-preset.test.ts": 1915, - "test/openclaw-config-render.test.ts": 2000, + "test/generate-openclaw-config.test.ts": 2000, "test/install-preflight.test.ts": 4397, "test/nemoclaw-start.test.ts": 5300, "test/onboard-messaging.test.ts": 2120, diff --git a/test/openclaw-config-render.test.ts b/test/generate-openclaw-config.test.ts similarity index 100% rename from test/openclaw-config-render.test.ts rename to test/generate-openclaw-config.test.ts From 440668e0f8ecb3cdb68cc91a0bc57f530d69d0a4 Mon Sep 17 00:00:00 2001 From: San Dang Date: Mon, 8 Jun 2026 21:34:50 +0700 Subject: [PATCH 05/23] Potential fix for pull request finding 'CodeQL / Unused variable, import, function or class' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- src/lib/messaging/compiler/engines/agent-render-engine.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/lib/messaging/compiler/engines/agent-render-engine.ts b/src/lib/messaging/compiler/engines/agent-render-engine.ts index 88e8933bcc..a38518aab9 100644 --- a/src/lib/messaging/compiler/engines/agent-render-engine.ts +++ b/src/lib/messaging/compiler/engines/agent-render-engine.ts @@ -3,7 +3,6 @@ import { createBuiltInMessagingHookRegistry, - MessagingHookRegistry, runMessagingHook, } from "../../hooks"; import { COMMON_STATIC_OUTPUTS_HOOK_HANDLER_ID } from "../../hooks/common/static-outputs"; From eb1209a4c68f1e0e4614f317a3a3cb126121c78d Mon Sep 17 00:00:00 2001 From: San Dang Date: Mon, 8 Jun 2026 20:11:54 +0530 Subject: [PATCH 06/23] refactor(messaging): split template reference resolvers --- .../messaging/compiler/engines/template.ts | 121 ++++++++++++++---- 1 file changed, 95 insertions(+), 26 deletions(-) diff --git a/src/lib/messaging/compiler/engines/template.ts b/src/lib/messaging/compiler/engines/template.ts index 7504ea49ec..2f2abc736f 100644 --- a/src/lib/messaging/compiler/engines/template.ts +++ b/src/lib/messaging/compiler/engines/template.ts @@ -16,6 +16,22 @@ const DEFAULT_PROXY_HOST = "10.200.0.1"; const DEFAULT_PROXY_PORT = "3128"; type RenderTemplateValue = MessagingSerializableValue | undefined; +const UNRESOLVED_TEMPLATE = Symbol("unresolved-template"); +type TemplateReferenceResult = RenderTemplateValue | typeof UNRESOLVED_TEMPLATE; +type TemplateReferenceResolver = ( + reference: string, + context: RenderTemplateContext, +) => TemplateReferenceResult; + +const TEMPLATE_REFERENCE_RESOLVERS: Record = { + allowedIds: resolveAllowedIdsTemplateReference, + discord: resolveDiscordTemplateReference, + discordProxyUrl: resolveDiscordProxyUrlTemplateReference, + proxyUrl: resolveProxyUrlTemplateReference, + slackConfig: resolveSlackConfigTemplateReference, + telegramConfig: resolveTelegramConfigTemplateReference, + wechatConfig: resolveWechatConfigTemplateReference, +}; type DiscordGuildConfig = { readonly enabled: true; @@ -162,38 +178,91 @@ function resolveTemplateReference( reference: string, context: RenderTemplateContext, ): RenderTemplateValue { - if (reference === "proxyUrl") return proxyUrl(context.env); - if (reference === "discordProxyUrl") return undefined; - if (reference === "discord.guilds") return nonEmptyObject(discordGuilds(context)); - if (reference === "discord.hasGuilds") return Object.keys(discordGuilds(context)).length > 0; - if (reference === "discord.guildIds.csv") return nonEmptyCsv(Object.keys(discordGuilds(context))); - if (reference === "discord.allowedUsers.values") return nonEmptyArray(discordAllowedUsers(context)); - if (reference === "discord.allowedUsers.csv") return nonEmptyCsv(discordAllowedUsers(context)); - if (reference === "discord.allowedUsers.dmPolicy") { - return discordAllowedUsers(context).length > 0 ? "allowlist" : undefined; - } - if (reference === "discord.allowAllUsers") { - return Object.keys(discordGuilds(context)).length > 0 && discordAllowedUsers(context).length === 0 - ? true - : undefined; - } - if (reference === "discord.requireMention") return discordRequireMention(context); + const resolver = TEMPLATE_REFERENCE_RESOLVERS[templateReferenceKey(reference)]; + if (!resolver) return "{{" + reference + "}}"; + const resolved = resolver(reference, context); + return resolved === UNRESOLVED_TEMPLATE ? "{{" + reference + "}}" : resolved; +} - const allowedIds = reference.match(/^allowedIds\.([A-Za-z0-9_-]+)\.(values|csv|dmPolicy|groupPolicy|channels)$/); - if (allowedIds?.[1] && allowedIds[2]) { - return resolveAllowedIdsTemplate(allowedIds[1], allowedIds[2], context); - } +function templateReferenceKey(reference: string): string { + const separator = reference.indexOf("."); + return separator === -1 ? reference : reference.slice(0, separator); +} + +function resolveProxyUrlTemplateReference( + reference: string, + context: RenderTemplateContext, +): TemplateReferenceResult { + return reference === "proxyUrl" ? proxyUrl(context.env) : UNRESOLVED_TEMPLATE; +} + +function resolveDiscordProxyUrlTemplateReference(reference: string): TemplateReferenceResult { + return reference === "discordProxyUrl" ? undefined : UNRESOLVED_TEMPLATE; +} - if (reference === "telegramConfig.requireMention") { - return parseBoolean(stateValue(context, "telegramConfig.requireMention")); +function resolveDiscordTemplateReference( + reference: string, + context: RenderTemplateContext, +): TemplateReferenceResult { + switch (reference) { + case "discord.guilds": + return nonEmptyObject(discordGuilds(context)); + case "discord.hasGuilds": + return Object.keys(discordGuilds(context)).length > 0; + case "discord.guildIds.csv": + return nonEmptyCsv(Object.keys(discordGuilds(context))); + case "discord.allowedUsers.values": + return nonEmptyArray(discordAllowedUsers(context)); + case "discord.allowedUsers.csv": + return nonEmptyCsv(discordAllowedUsers(context)); + case "discord.allowedUsers.dmPolicy": + return discordAllowedUsers(context).length > 0 ? "allowlist" : undefined; + case "discord.allowAllUsers": + return Object.keys(discordGuilds(context)).length > 0 && + discordAllowedUsers(context).length === 0 + ? true + : undefined; + case "discord.requireMention": + return discordRequireMention(context); + default: + return UNRESOLVED_TEMPLATE; } +} - const wechatConfig = reference.match(/^wechatConfig\.(accountId|baseUrl|userId)$/); - if (wechatConfig?.[1]) return nonEmptyString(stateValue(context, `wechatConfig.${wechatConfig[1]}`)); +function resolveAllowedIdsTemplateReference( + reference: string, + context: RenderTemplateContext, +): TemplateReferenceResult { + const allowedIds = reference.match( + /^allowedIds[.]([A-Za-z0-9_-]+)[.](values|csv|dmPolicy|groupPolicy|channels)$/, + ); + if (!allowedIds?.[1] || !allowedIds[2]) return UNRESOLVED_TEMPLATE; + return resolveAllowedIdsTemplate(allowedIds[1], allowedIds[2], context); +} - if (reference === "slackConfig.allowedChannels.csv") return nonEmptyCsv(slackAllowedChannels(context)); +function resolveTelegramConfigTemplateReference( + reference: string, + context: RenderTemplateContext, +): TemplateReferenceResult { + if (reference !== "telegramConfig.requireMention") return UNRESOLVED_TEMPLATE; + return parseBoolean(stateValue(context, "telegramConfig.requireMention")); +} - return `{{${reference}}}`; +function resolveWechatConfigTemplateReference( + reference: string, + context: RenderTemplateContext, +): TemplateReferenceResult { + const wechatConfig = reference.match(/^wechatConfig[.](accountId|baseUrl|userId)$/); + if (!wechatConfig?.[1]) return UNRESOLVED_TEMPLATE; + return nonEmptyString(stateValue(context, "wechatConfig." + wechatConfig[1])); +} + +function resolveSlackConfigTemplateReference( + reference: string, + context: RenderTemplateContext, +): TemplateReferenceResult { + if (reference !== "slackConfig.allowedChannels.csv") return UNRESOLVED_TEMPLATE; + return nonEmptyCsv(slackAllowedChannels(context)); } function resolveAllowedIdsTemplate( From 71ad7e58e7d0403e2bc7cb8f54f394a9c2b60f05 Mon Sep 17 00:00:00 2001 From: San Dang Date: Mon, 8 Jun 2026 20:24:15 +0530 Subject: [PATCH 07/23] refactor(messaging): move template resolvers to channels --- src/lib/actions/sandbox/policy-channel.ts | 2 + .../messaging/applier/setup-applier.test.ts | 6 +- src/lib/messaging/channels/index.ts | 1 + .../messaging/channels/template-resolvers.ts | 265 ++++++++++++++++++ .../compiler/engines/agent-render-engine.ts | 4 +- .../messaging/compiler/engines/template.ts | 251 ++--------------- .../compiler/manifest-compiler.test.ts | 10 +- .../messaging/compiler/manifest-compiler.ts | 3 + .../compiler/workflow-planner.test.ts | 11 +- .../messaging/compiler/workflow-planner.ts | 4 +- src/lib/onboard/messaging-channel-setup.ts | 17 +- test/messaging-plan-test-helper.ts | 2 + 12 files changed, 331 insertions(+), 245 deletions(-) create mode 100644 src/lib/messaging/channels/template-resolvers.ts diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index 4abacf851b..bfb8e9cf38 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -13,6 +13,7 @@ import { type ChannelManifest, createBuiltInChannelManifestRegistry, createBuiltInMessagingHookRegistry, + createBuiltInRenderTemplateResolver, getMessagingManifestAvailabilityContext, MessagingHostStateApplier, MessagingSetupApplier, @@ -769,6 +770,7 @@ async function planSandboxChannelAdd( const planner = new MessagingWorkflowPlanner( messagingManifestRegistry, createBuiltInMessagingHookRegistry(), + createBuiltInRenderTemplateResolver(), ); const availableChannels = availableManifestChannelsForAgent(agent); const supportedChannelIds = availableChannels.map((manifest) => manifest.id); diff --git a/src/lib/messaging/applier/setup-applier.test.ts b/src/lib/messaging/applier/setup-applier.test.ts index 5d4a0adb30..f1d8543eac 100644 --- a/src/lib/messaging/applier/setup-applier.test.ts +++ b/src/lib/messaging/applier/setup-applier.test.ts @@ -3,7 +3,10 @@ import { describe, expect, it } from "vitest"; -import { createBuiltInChannelManifestRegistry } from "../channels"; +import { + createBuiltInChannelManifestRegistry, + createBuiltInRenderTemplateResolver, +} from "../channels"; import { MessagingWorkflowPlanner } from "../compiler"; import { createBuiltInMessagingHookRegistry, runMessagingHook } from "../hooks"; import type { @@ -99,6 +102,7 @@ function planner(): MessagingWorkflowPlanner { }, }, }), + createBuiltInRenderTemplateResolver(), ); } diff --git a/src/lib/messaging/channels/index.ts b/src/lib/messaging/channels/index.ts index 441e224122..057f11b91e 100644 --- a/src/lib/messaging/channels/index.ts +++ b/src/lib/messaging/channels/index.ts @@ -12,6 +12,7 @@ import { whatsappManifest } from "./whatsapp/manifest"; export { discordManifest } from "./discord/manifest"; export { slackManifest } from "./slack/manifest"; export { telegramManifest } from "./telegram/manifest"; +export { createBuiltInRenderTemplateResolver } from "./template-resolvers"; export { wechatManifest } from "./wechat/manifest"; export { whatsappManifest } from "./whatsapp/manifest"; diff --git a/src/lib/messaging/channels/template-resolvers.ts b/src/lib/messaging/channels/template-resolvers.ts new file mode 100644 index 0000000000..ebf564ac3b --- /dev/null +++ b/src/lib/messaging/channels/template-resolvers.ts @@ -0,0 +1,265 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + type RenderTemplateContext, + type RenderTemplateReferenceResolution, + resolvedRenderTemplateReference, +} from "../compiler/engines/template"; +import type { MessagingSerializableValue } from "../manifest"; + +const DEFAULT_PROXY_HOST = "10.200.0.1"; +const DEFAULT_PROXY_PORT = "3128"; + +type BuiltInRenderTemplateResolver = ( + reference: string, + context: RenderTemplateContext, +) => RenderTemplateReferenceResolution | undefined; + +type DiscordGuildConfig = { + readonly enabled: true; + readonly requireMention?: boolean; + readonly users?: readonly string[]; +}; + +const BUILT_IN_TEMPLATE_REFERENCE_RESOLVERS: Record = { + allowedIds: resolveAllowedIdsTemplateReference, + discord: resolveDiscordTemplateReference, + discordProxyUrl: resolveDiscordProxyUrlTemplateReference, + proxyUrl: resolveProxyUrlTemplateReference, + slackConfig: resolveSlackConfigTemplateReference, + telegramConfig: resolveTelegramConfigTemplateReference, + wechatConfig: resolveWechatConfigTemplateReference, +}; + +export function createBuiltInRenderTemplateResolver(): BuiltInRenderTemplateResolver { + return (reference, context) => + BUILT_IN_TEMPLATE_REFERENCE_RESOLVERS[templateReferenceKey(reference)]?.( + reference, + context, + ); +} + +function templateReferenceKey(reference: string): string { + const separator = reference.indexOf("."); + return separator === -1 ? reference : reference.slice(0, separator); +} + +function resolveProxyUrlTemplateReference( + reference: string, + context: RenderTemplateContext, +): RenderTemplateReferenceResolution | undefined { + if (reference !== "proxyUrl") return undefined; + return resolvedRenderTemplateReference(proxyUrl(context.env)); +} + +function resolveDiscordProxyUrlTemplateReference( + reference: string, +): RenderTemplateReferenceResolution | undefined { + if (reference !== "discordProxyUrl") return undefined; + return resolvedRenderTemplateReference(undefined); +} + +function resolveDiscordTemplateReference( + reference: string, + context: RenderTemplateContext, +): RenderTemplateReferenceResolution | undefined { + switch (reference) { + case "discord.guilds": + return resolvedRenderTemplateReference(nonEmptyObject(discordGuilds(context))); + case "discord.hasGuilds": + return resolvedRenderTemplateReference(Object.keys(discordGuilds(context)).length > 0); + case "discord.guildIds.csv": + return resolvedRenderTemplateReference(nonEmptyCsv(Object.keys(discordGuilds(context)))); + case "discord.allowedUsers.values": + return resolvedRenderTemplateReference(nonEmptyArray(discordAllowedUsers(context))); + case "discord.allowedUsers.csv": + return resolvedRenderTemplateReference(nonEmptyCsv(discordAllowedUsers(context))); + case "discord.allowedUsers.dmPolicy": + return resolvedRenderTemplateReference( + discordAllowedUsers(context).length > 0 ? "allowlist" : undefined, + ); + case "discord.allowAllUsers": + return resolvedRenderTemplateReference( + Object.keys(discordGuilds(context)).length > 0 && + discordAllowedUsers(context).length === 0 + ? true + : undefined, + ); + case "discord.requireMention": + return resolvedRenderTemplateReference(discordRequireMention(context)); + default: + return undefined; + } +} + +function resolveAllowedIdsTemplateReference( + reference: string, + context: RenderTemplateContext, +): RenderTemplateReferenceResolution | undefined { + const allowedIds = reference.match( + /^allowedIds[.]([A-Za-z0-9_-]+)[.](values|csv|dmPolicy|groupPolicy|channels)$/, + ); + if (!allowedIds?.[1] || !allowedIds[2]) return undefined; + return resolvedRenderTemplateReference( + resolveAllowedIdsTemplate(allowedIds[1], allowedIds[2], context), + ); +} + +function resolveTelegramConfigTemplateReference( + reference: string, + context: RenderTemplateContext, +): RenderTemplateReferenceResolution | undefined { + if (reference !== "telegramConfig.requireMention") return undefined; + return resolvedRenderTemplateReference( + parseBoolean(stateValue(context, "telegramConfig.requireMention")), + ); +} + +function resolveWechatConfigTemplateReference( + reference: string, + context: RenderTemplateContext, +): RenderTemplateReferenceResolution | undefined { + const wechatConfig = reference.match(/^wechatConfig[.](accountId|baseUrl|userId)$/); + if (!wechatConfig?.[1]) return undefined; + return resolvedRenderTemplateReference( + nonEmptyString(stateValue(context, "wechatConfig." + wechatConfig[1])), + ); +} + +function resolveSlackConfigTemplateReference( + reference: string, + context: RenderTemplateContext, +): RenderTemplateReferenceResolution | undefined { + if (reference !== "slackConfig.allowedChannels.csv") return undefined; + return resolvedRenderTemplateReference(nonEmptyCsv(slackAllowedChannels(context))); +} + +function resolveAllowedIdsTemplate( + channel: string, + selector: string, + context: RenderTemplateContext, +): MessagingSerializableValue | undefined { + const ids = allowedIds(context, channel); + if (selector === "values") return nonEmptyArray(ids); + if (selector === "csv") return nonEmptyCsv(ids); + if (selector === "dmPolicy") return ids.length > 0 ? "allowlist" : undefined; + if (selector === "groupPolicy") { + return ids.length > 0 || (channel === "slack" && slackAllowedChannels(context).length > 0) + ? "allowlist" + : undefined; + } + if (selector === "channels" && channel === "slack") return slackChannelConfig(context, ids); + return undefined; +} + +function proxyUrl(env: RenderTemplateContext["env"]): string { + const host = nonEmptyString(env?.NEMOCLAW_PROXY_HOST) ?? DEFAULT_PROXY_HOST; + const port = nonEmptyString(env?.NEMOCLAW_PROXY_PORT) ?? DEFAULT_PROXY_PORT; + return `http://${host}:${port}`; +} + +function slackChannelConfig( + context: RenderTemplateContext, + users: readonly string[], +): Record | undefined { + const allowedChannels = slackAllowedChannels(context); + const entry: Record = { + enabled: true, + requireMention: true, + ...(users.length > 0 ? { users: [...users] } : {}), + }; + if (allowedChannels.length > 0) { + return Object.fromEntries(allowedChannels.map((channelId) => [channelId, { ...entry }])); + } + return users.length > 0 ? { "*": entry } : undefined; +} + +function discordGuilds(context: RenderTemplateContext): Record { + const serverIds = parseList(stateValue(context, "discordGuilds.serverId")); + if (serverIds.length === 0) return {}; + const users = parseList(stateValue(context, "discordGuilds.userIds")); + const requireMention = parseBoolean(stateValue(context, "discordGuilds.requireMention")) ?? true; + return Object.fromEntries( + serverIds.map((serverId) => [ + serverId, + { + enabled: true, + requireMention, + ...(users.length > 0 ? { users } : {}), + }, + ]), + ); +} + +function discordAllowedUsers(context: RenderTemplateContext): string[] { + const users = new Set(allowedIds(context, "discord")); + for (const guild of Object.values(discordGuilds(context))) { + for (const user of guild.users ?? []) users.add(String(user)); + } + return [...users]; +} + +function discordRequireMention(context: RenderTemplateContext): boolean { + for (const guild of Object.values(discordGuilds(context))) { + if (typeof guild.requireMention === "boolean") return guild.requireMention; + } + return true; +} + +function allowedIds(context: RenderTemplateContext, channel: string): string[] { + const ids = parseList(stateValue(context, `allowedIds.${channel}`)); + if (channel !== "wechat") return ids; + const userId = nonEmptyString(stateValue(context, "wechatConfig.userId")); + return userId && !ids.includes(userId) ? [userId, ...ids] : ids; +} + +function slackAllowedChannels(context: RenderTemplateContext): string[] { + return parseList(stateValue(context, "slackConfig.allowedChannels")); +} + +function stateValue(context: RenderTemplateContext, path: string): MessagingSerializableValue | undefined { + const stateInput = context.inputs.find((input) => input.statePath === path); + if (stateInput?.value !== undefined) return stateInput.value; + const inputId = path.split(".").at(-1); + return context.inputs.find((input) => input.inputId === inputId)?.value; +} + +function parseList(value: MessagingSerializableValue | undefined): string[] { + if (Array.isArray(value)) return unique(value.map(String).map(cleanString).filter(Boolean)); + const text = cleanString(value); + if (!text) return []; + return unique(text.split(",").map(cleanString).filter(Boolean)); +} + +function parseBoolean(value: MessagingSerializableValue | undefined): boolean | undefined { + if (typeof value === "boolean") return value; + const text = cleanString(value)?.toLowerCase(); + if (text === "1" || text === "true" || text === "yes" || text === "on") return true; + if (text === "0" || text === "false" || text === "no" || text === "off") return false; + return undefined; +} + +function nonEmptyString(value: unknown): string | undefined { + return cleanString(value) || undefined; +} + +function cleanString(value: unknown): string { + return String(value ?? "").replace(/\r/g, "").trim(); +} + +function nonEmptyArray(values: readonly string[]): string[] | undefined { + return values.length > 0 ? [...values] : undefined; +} + +function nonEmptyCsv(values: readonly string[]): string | undefined { + return values.length > 0 ? values.join(",") : undefined; +} + +function nonEmptyObject>(value: T): T | undefined { + return Object.keys(value).length > 0 ? value : undefined; +} + +function unique(values: readonly T[]): T[] { + return [...new Set(values)]; +} diff --git a/src/lib/messaging/compiler/engines/agent-render-engine.ts b/src/lib/messaging/compiler/engines/agent-render-engine.ts index a38518aab9..2d6971ff38 100644 --- a/src/lib/messaging/compiler/engines/agent-render-engine.ts +++ b/src/lib/messaging/compiler/engines/agent-render-engine.ts @@ -21,6 +21,7 @@ import { collectTemplateReferencesInLines, collectTemplateReferencesInValue, isTruthyRenderTemplate, + type RenderTemplateReferenceResolver, resolveCredentialTemplatesInLines, resolveCredentialTemplatesInValue, resolveRenderTemplatesInLines, @@ -32,9 +33,10 @@ export async function planAgentRender( context: ManifestCompilerContext, inputs: readonly SandboxMessagingInputReference[] = [], hooks = createBuiltInMessagingHookRegistry(), + referenceResolver?: RenderTemplateReferenceResolver, ): Promise { const plans: SandboxMessagingAgentRenderPlan[] = []; - const templateContext = { inputs, env: process.env }; + const templateContext = { inputs, env: process.env, referenceResolver }; for (const [index, render] of manifest.render.entries()) { if (render.agent !== context.agent) continue; diff --git a/src/lib/messaging/compiler/engines/template.ts b/src/lib/messaging/compiler/engines/template.ts index 2f2abc736f..a85acb321f 100644 --- a/src/lib/messaging/compiler/engines/template.ts +++ b/src/lib/messaging/compiler/engines/template.ts @@ -12,36 +12,29 @@ const CREDENTIAL_PLACEHOLDER_PATTERN = /\{\{\s*credential\.([A-Za-z0-9_-]+)\.placeholder\s*\}\}/g; const EXACT_TEMPLATE_PATTERN = /^\{\{\s*([^}]+?)\s*\}\}$/; const TEMPLATE_REFERENCE_PATTERN = /\{\{\s*([^}]+?)\s*\}\}/g; -const DEFAULT_PROXY_HOST = "10.200.0.1"; -const DEFAULT_PROXY_PORT = "3128"; -type RenderTemplateValue = MessagingSerializableValue | undefined; -const UNRESOLVED_TEMPLATE = Symbol("unresolved-template"); -type TemplateReferenceResult = RenderTemplateValue | typeof UNRESOLVED_TEMPLATE; -type TemplateReferenceResolver = ( - reference: string, - context: RenderTemplateContext, -) => TemplateReferenceResult; +export type RenderTemplateValue = MessagingSerializableValue | undefined; -const TEMPLATE_REFERENCE_RESOLVERS: Record = { - allowedIds: resolveAllowedIdsTemplateReference, - discord: resolveDiscordTemplateReference, - discordProxyUrl: resolveDiscordProxyUrlTemplateReference, - proxyUrl: resolveProxyUrlTemplateReference, - slackConfig: resolveSlackConfigTemplateReference, - telegramConfig: resolveTelegramConfigTemplateReference, - wechatConfig: resolveWechatConfigTemplateReference, -}; +export interface RenderTemplateReferenceResolution { + readonly matched: true; + readonly value: RenderTemplateValue; +} -type DiscordGuildConfig = { - readonly enabled: true; - readonly requireMention?: boolean; - readonly users?: readonly string[]; -}; +export type RenderTemplateReferenceResolver = ( + reference: string, + context: RenderTemplateContext, +) => RenderTemplateReferenceResolution | undefined; export interface RenderTemplateContext { readonly inputs: readonly SandboxMessagingInputReference[]; readonly env?: Record; + readonly referenceResolver?: RenderTemplateReferenceResolver; +} + +export function resolvedRenderTemplateReference( + value: RenderTemplateValue, +): RenderTemplateReferenceResolution { + return { matched: true, value }; } export function resolveSandboxNameTemplate( @@ -178,216 +171,8 @@ function resolveTemplateReference( reference: string, context: RenderTemplateContext, ): RenderTemplateValue { - const resolver = TEMPLATE_REFERENCE_RESOLVERS[templateReferenceKey(reference)]; - if (!resolver) return "{{" + reference + "}}"; - const resolved = resolver(reference, context); - return resolved === UNRESOLVED_TEMPLATE ? "{{" + reference + "}}" : resolved; -} - -function templateReferenceKey(reference: string): string { - const separator = reference.indexOf("."); - return separator === -1 ? reference : reference.slice(0, separator); -} - -function resolveProxyUrlTemplateReference( - reference: string, - context: RenderTemplateContext, -): TemplateReferenceResult { - return reference === "proxyUrl" ? proxyUrl(context.env) : UNRESOLVED_TEMPLATE; -} - -function resolveDiscordProxyUrlTemplateReference(reference: string): TemplateReferenceResult { - return reference === "discordProxyUrl" ? undefined : UNRESOLVED_TEMPLATE; -} - -function resolveDiscordTemplateReference( - reference: string, - context: RenderTemplateContext, -): TemplateReferenceResult { - switch (reference) { - case "discord.guilds": - return nonEmptyObject(discordGuilds(context)); - case "discord.hasGuilds": - return Object.keys(discordGuilds(context)).length > 0; - case "discord.guildIds.csv": - return nonEmptyCsv(Object.keys(discordGuilds(context))); - case "discord.allowedUsers.values": - return nonEmptyArray(discordAllowedUsers(context)); - case "discord.allowedUsers.csv": - return nonEmptyCsv(discordAllowedUsers(context)); - case "discord.allowedUsers.dmPolicy": - return discordAllowedUsers(context).length > 0 ? "allowlist" : undefined; - case "discord.allowAllUsers": - return Object.keys(discordGuilds(context)).length > 0 && - discordAllowedUsers(context).length === 0 - ? true - : undefined; - case "discord.requireMention": - return discordRequireMention(context); - default: - return UNRESOLVED_TEMPLATE; - } -} - -function resolveAllowedIdsTemplateReference( - reference: string, - context: RenderTemplateContext, -): TemplateReferenceResult { - const allowedIds = reference.match( - /^allowedIds[.]([A-Za-z0-9_-]+)[.](values|csv|dmPolicy|groupPolicy|channels)$/, - ); - if (!allowedIds?.[1] || !allowedIds[2]) return UNRESOLVED_TEMPLATE; - return resolveAllowedIdsTemplate(allowedIds[1], allowedIds[2], context); -} - -function resolveTelegramConfigTemplateReference( - reference: string, - context: RenderTemplateContext, -): TemplateReferenceResult { - if (reference !== "telegramConfig.requireMention") return UNRESOLVED_TEMPLATE; - return parseBoolean(stateValue(context, "telegramConfig.requireMention")); -} - -function resolveWechatConfigTemplateReference( - reference: string, - context: RenderTemplateContext, -): TemplateReferenceResult { - const wechatConfig = reference.match(/^wechatConfig[.](accountId|baseUrl|userId)$/); - if (!wechatConfig?.[1]) return UNRESOLVED_TEMPLATE; - return nonEmptyString(stateValue(context, "wechatConfig." + wechatConfig[1])); -} - -function resolveSlackConfigTemplateReference( - reference: string, - context: RenderTemplateContext, -): TemplateReferenceResult { - if (reference !== "slackConfig.allowedChannels.csv") return UNRESOLVED_TEMPLATE; - return nonEmptyCsv(slackAllowedChannels(context)); -} - -function resolveAllowedIdsTemplate( - channel: string, - selector: string, - context: RenderTemplateContext, -): RenderTemplateValue { - const ids = allowedIds(context, channel); - if (selector === "values") return nonEmptyArray(ids); - if (selector === "csv") return nonEmptyCsv(ids); - if (selector === "dmPolicy") return ids.length > 0 ? "allowlist" : undefined; - if (selector === "groupPolicy") { - return ids.length > 0 || (channel === "slack" && slackAllowedChannels(context).length > 0) - ? "allowlist" - : undefined; - } - if (selector === "channels" && channel === "slack") return slackChannelConfig(context, ids); - return undefined; -} - -function proxyUrl(env: RenderTemplateContext["env"]): string { - const host = nonEmptyString(env?.NEMOCLAW_PROXY_HOST) ?? DEFAULT_PROXY_HOST; - const port = nonEmptyString(env?.NEMOCLAW_PROXY_PORT) ?? DEFAULT_PROXY_PORT; - return `http://${host}:${port}`; -} - -function slackChannelConfig( - context: RenderTemplateContext, - users: readonly string[], -): Record | undefined { - const allowedChannels = slackAllowedChannels(context); - const entry: Record = { - enabled: true, - requireMention: true, - ...(users.length > 0 ? { users: [...users] } : {}), - }; - if (allowedChannels.length > 0) { - return Object.fromEntries(allowedChannels.map((channelId) => [channelId, { ...entry }])); - } - return users.length > 0 ? { "*": entry } : undefined; -} - -function discordGuilds(context: RenderTemplateContext): Record { - const serverIds = parseList(stateValue(context, "discordGuilds.serverId")); - if (serverIds.length === 0) return {}; - const users = parseList(stateValue(context, "discordGuilds.userIds")); - const requireMention = parseBoolean(stateValue(context, "discordGuilds.requireMention")) ?? true; - return Object.fromEntries( - serverIds.map((serverId) => [ - serverId, - { - enabled: true, - requireMention, - ...(users.length > 0 ? { users } : {}), - }, - ]), - ); -} - -function discordAllowedUsers(context: RenderTemplateContext): string[] { - const users = new Set(allowedIds(context, "discord")); - for (const guild of Object.values(discordGuilds(context))) { - for (const user of guild.users ?? []) users.add(String(user)); - } - return [...users]; -} - -function discordRequireMention(context: RenderTemplateContext): boolean { - for (const guild of Object.values(discordGuilds(context))) { - if (typeof guild.requireMention === "boolean") return guild.requireMention; - } - return true; -} - -function allowedIds(context: RenderTemplateContext, channel: string): string[] { - const ids = parseList(stateValue(context, `allowedIds.${channel}`)); - if (channel !== "wechat") return ids; - const userId = nonEmptyString(stateValue(context, "wechatConfig.userId")); - return userId && !ids.includes(userId) ? [userId, ...ids] : ids; -} - -function slackAllowedChannels(context: RenderTemplateContext): string[] { - return parseList(stateValue(context, "slackConfig.allowedChannels")); -} - -function stateValue(context: RenderTemplateContext, path: string): MessagingSerializableValue | undefined { - const stateInput = context.inputs.find((input) => input.statePath === path); - if (stateInput?.value !== undefined) return stateInput.value; - const inputId = path.split(".").at(-1); - return context.inputs.find((input) => input.inputId === inputId)?.value; -} - -function parseList(value: MessagingSerializableValue | undefined): string[] { - if (Array.isArray(value)) return unique(value.map(String).map(cleanString).filter(Boolean)); - const text = cleanString(value); - if (!text) return []; - return unique(text.split(",").map(cleanString).filter(Boolean)); -} - -function parseBoolean(value: MessagingSerializableValue | undefined): boolean | undefined { - if (typeof value === "boolean") return value; - const text = cleanString(value)?.toLowerCase(); - if (text === "1" || text === "true" || text === "yes" || text === "on") return true; - if (text === "0" || text === "false" || text === "no" || text === "off") return false; - return undefined; -} - -function nonEmptyString(value: unknown): string | undefined { - return cleanString(value) || undefined; -} - -function cleanString(value: unknown): string { - return String(value ?? "").replace(/\r/g, "").trim(); -} - -function nonEmptyArray(values: readonly string[]): string[] | undefined { - return values.length > 0 ? [...values] : undefined; -} - -function nonEmptyCsv(values: readonly string[]): string | undefined { - return values.length > 0 ? values.join(",") : undefined; -} - -function nonEmptyObject>(value: T): T | undefined { - return Object.keys(value).length > 0 ? value : undefined; + const resolved = context.referenceResolver?.(reference, context); + return resolved?.matched ? resolved.value : "{{" + reference + "}}"; } function collectTemplateReferencesInString(value: MessagingTemplateString): string[] { diff --git a/src/lib/messaging/compiler/manifest-compiler.test.ts b/src/lib/messaging/compiler/manifest-compiler.test.ts index cea1406b40..24850a4652 100644 --- a/src/lib/messaging/compiler/manifest-compiler.test.ts +++ b/src/lib/messaging/compiler/manifest-compiler.test.ts @@ -3,7 +3,10 @@ import { describe, expect, it } from "vitest"; -import { createBuiltInChannelManifestRegistry } from "../channels"; +import { + createBuiltInChannelManifestRegistry, + createBuiltInRenderTemplateResolver, +} from "../channels"; import { createBuiltInMessagingHookRegistry, MessagingHookRegistry } from "../hooks"; import { type ChannelManifest, @@ -71,6 +74,7 @@ function compiler(): ManifestCompiler { }, }, }), + createBuiltInRenderTemplateResolver(), ); } @@ -418,6 +422,7 @@ describe("ManifestCompiler", () => { const plan = await new ManifestCompiler( createBuiltInChannelManifestRegistry(), hooks, + createBuiltInRenderTemplateResolver(), ).compile({ sandboxName: "demo", agent: "openclaw", @@ -499,7 +504,7 @@ describe("ManifestCompiler", () => { TELEGRAM_BOT_TOKEN: "123456:raw-telegram-token", }, () => - new ManifestCompiler(createBuiltInChannelManifestRegistry(), hooks).compile({ + new ManifestCompiler(createBuiltInChannelManifestRegistry(), hooks, createBuiltInRenderTemplateResolver()).compile({ sandboxName: "demo", agent: "openclaw", workflow: "onboard", @@ -553,6 +558,7 @@ describe("ManifestCompiler", () => { const plan = await new ManifestCompiler( createBuiltInChannelManifestRegistry(), hooks, + createBuiltInRenderTemplateResolver(), ).compile({ sandboxName: "demo", agent: "openclaw", diff --git a/src/lib/messaging/compiler/manifest-compiler.ts b/src/lib/messaging/compiler/manifest-compiler.ts index 37336307c3..592bfc8608 100644 --- a/src/lib/messaging/compiler/manifest-compiler.ts +++ b/src/lib/messaging/compiler/manifest-compiler.ts @@ -32,6 +32,7 @@ import { planCredentialBindings } from "./engines/credential-binding-engine"; import { planHealthChecks } from "./engines/health-check-engine"; import { planNetworkPolicy } from "./engines/policy-resolver"; import { planStateUpdates } from "./engines/state-update-engine"; +import type { RenderTemplateReferenceResolver } from "./engines/template"; import type { ManifestCompilerContext } from "./types"; export class ManifestCompiler { @@ -40,6 +41,7 @@ export class ManifestCompiler { constructor( private readonly registry: ChannelManifestRegistry, hooks = new MessagingHookRegistry(), + private readonly renderTemplateResolver?: RenderTemplateReferenceResolver, ) { this.hooks = ensureCommonCompilerHooks(hooks); } @@ -68,6 +70,7 @@ export class ManifestCompiler { context, inputRegistry.get(manifest.id) ?? [], this.hooks, + this.renderTemplateResolver, ), ), ) diff --git a/src/lib/messaging/compiler/workflow-planner.test.ts b/src/lib/messaging/compiler/workflow-planner.test.ts index cb2a64b86c..db92707d84 100644 --- a/src/lib/messaging/compiler/workflow-planner.test.ts +++ b/src/lib/messaging/compiler/workflow-planner.test.ts @@ -3,7 +3,10 @@ import { describe, expect, it } from "vitest"; -import { createBuiltInChannelManifestRegistry } from "../channels"; +import { + createBuiltInChannelManifestRegistry, + createBuiltInRenderTemplateResolver, +} from "../channels"; import { createBuiltInMessagingHookRegistry, MessagingHookRegistry } from "../hooks"; import { MessagingWorkflowPlanner } from "./workflow-planner"; @@ -65,6 +68,7 @@ function planner(): MessagingWorkflowPlanner { }, }, }), + createBuiltInRenderTemplateResolver(), ); } @@ -222,6 +226,7 @@ describe("MessagingWorkflowPlanner", () => { const plan = await new MessagingWorkflowPlanner( createBuiltInChannelManifestRegistry(), hooks, + createBuiltInRenderTemplateResolver(), ).buildPlan({ sandboxName: "demo", agent: "openclaw", @@ -289,7 +294,8 @@ describe("MessagingWorkflowPlanner", () => { const plan = await new MessagingWorkflowPlanner( createBuiltInChannelManifestRegistry(), hooks, - ).buildPlan({ + createBuiltInRenderTemplateResolver(), + ).buildPlan({ sandboxName: "demo", agent: "openclaw", workflow: "onboard", @@ -496,6 +502,7 @@ describe("MessagingWorkflowPlanner", () => { const plan = await new MessagingWorkflowPlanner( createBuiltInChannelManifestRegistry(), hooks, + createBuiltInRenderTemplateResolver(), ).buildChannelAddPlanFromSandboxEntry({ sandboxName: "demo", agent: "openclaw", diff --git a/src/lib/messaging/compiler/workflow-planner.ts b/src/lib/messaging/compiler/workflow-planner.ts index 853ff39456..f395bb52c1 100644 --- a/src/lib/messaging/compiler/workflow-planner.ts +++ b/src/lib/messaging/compiler/workflow-planner.ts @@ -10,6 +10,7 @@ import type { SandboxMessagingChannelPlan, SandboxMessagingPlan, } from "../manifest"; +import type { RenderTemplateReferenceResolver } from "./engines/template"; import { ManifestCompiler } from "./manifest-compiler"; import type { ManifestCompilerContext, @@ -33,8 +34,9 @@ export class MessagingWorkflowPlanner { constructor( private readonly registry: ChannelManifestRegistry, hooks = new MessagingHookRegistry(), + renderTemplateResolver?: RenderTemplateReferenceResolver, ) { - this.compiler = new ManifestCompiler(registry, hooks); + this.compiler = new ManifestCompiler(registry, hooks, renderTemplateResolver); } async buildPlan( diff --git a/src/lib/onboard/messaging-channel-setup.ts b/src/lib/onboard/messaging-channel-setup.ts index cc31646b28..1ad05a807e 100644 --- a/src/lib/onboard/messaging-channel-setup.ts +++ b/src/lib/onboard/messaging-channel-setup.ts @@ -3,12 +3,12 @@ import type { AgentDefinition } from "../agent/defs"; import { getCredential, normalizeCredentialValue } from "../credentials/store"; -import * as registry from "../state/registry"; import { type ChannelInputSpec, type ChannelManifest, - createBuiltInMessagingHookRegistry, createBuiltInChannelManifestRegistry, + createBuiltInMessagingHookRegistry, + createBuiltInRenderTemplateResolver, getMessagingManifestAvailabilityContext, hasMessagingManifestRequiredInputs, MessagingHostStateApplier, @@ -18,14 +18,17 @@ import { type SandboxMessagingPlan, toMessagingAgentId, } from "../messaging"; +import * as registry from "../state/registry"; + export { MessagingHostStateApplier }; + import { resolveMessagingChannelConfigEnvValue } from "../messaging-channel-config"; import { + type MessagingSelectorInput, + type MessagingSelectorOutput, promptMessagingChannelLineSelection, readMessagingChannelSelection, renderMessagingChannelList, - type MessagingSelectorInput, - type MessagingSelectorOutput, } from "./messaging-selector"; export interface SetupSelectedMessagingChannelsOptions { @@ -157,7 +160,11 @@ export async function setupSelectedMessagingChannels( const agent = toMessagingAgentId(options.agent); const sandboxName = resolveMessagingSetupSandboxName(options); - const planner = new MessagingWorkflowPlanner(registry, createBuiltInMessagingHookRegistry()); + const planner = new MessagingWorkflowPlanner( + registry, + createBuiltInMessagingHookRegistry(), + createBuiltInRenderTemplateResolver(), + ); if (options.interactive === false) { const plan = await planner.buildPlan({ diff --git a/test/messaging-plan-test-helper.ts b/test/messaging-plan-test-helper.ts index 0acbfacad4..88060aedc1 100644 --- a/test/messaging-plan-test-helper.ts +++ b/test/messaging-plan-test-helper.ts @@ -12,6 +12,7 @@ import { MessagingWorkflowPlanner, createBuiltInChannelManifestRegistry, createBuiltInMessagingHookRegistry, + createBuiltInRenderTemplateResolver, } from "./src/lib/messaging/index.ts"; const agent = process.env.NEMOCLAW_TEST_MESSAGING_PLAN_AGENT; @@ -30,6 +31,7 @@ async function main() { }, }, }), + createBuiltInRenderTemplateResolver(), ); const plan = await planner.buildPlan({ sandboxName: "test-sandbox", From f3bea0047325ff02bd1c87abd8df98b6dce2d6c1 Mon Sep 17 00:00:00 2001 From: San Dang Date: Mon, 8 Jun 2026 20:33:52 +0530 Subject: [PATCH 08/23] refactor(messaging): split channel template resolvers --- .../channels/discord/template-resolver.ts | 88 ++++++ src/lib/messaging/channels/index.ts | 2 +- .../channels/slack/template-resolver.ts | 64 +++++ .../channels/telegram/template-resolver.ts | 49 ++++ .../channels/template-resolver-utils.ts | 69 +++++ .../messaging/channels/template-resolver.ts | 27 ++ .../messaging/channels/template-resolvers.ts | 265 ------------------ .../channels/wechat/template-resolver.ts | 45 +++ .../channels/whatsapp/template-resolver.ts | 29 ++ 9 files changed, 372 insertions(+), 266 deletions(-) create mode 100644 src/lib/messaging/channels/discord/template-resolver.ts create mode 100644 src/lib/messaging/channels/slack/template-resolver.ts create mode 100644 src/lib/messaging/channels/telegram/template-resolver.ts create mode 100644 src/lib/messaging/channels/template-resolver-utils.ts create mode 100644 src/lib/messaging/channels/template-resolver.ts delete mode 100644 src/lib/messaging/channels/template-resolvers.ts create mode 100644 src/lib/messaging/channels/wechat/template-resolver.ts create mode 100644 src/lib/messaging/channels/whatsapp/template-resolver.ts diff --git a/src/lib/messaging/channels/discord/template-resolver.ts b/src/lib/messaging/channels/discord/template-resolver.ts new file mode 100644 index 0000000000..6e4e1232c2 --- /dev/null +++ b/src/lib/messaging/channels/discord/template-resolver.ts @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { RenderTemplateContext } from "../../compiler/engines/template"; +import { + allowedIds, + type BuiltInRenderTemplateResolver, + nonEmptyArray, + nonEmptyCsv, + nonEmptyObject, + parseBoolean, + parseList, + resolvedRenderTemplateReference, + stateValue, +} from "../template-resolver-utils"; + +type DiscordGuildConfig = { + readonly enabled: true; + readonly requireMention?: boolean; + readonly users?: readonly string[]; +}; + +export const resolveDiscordTemplateReference: BuiltInRenderTemplateResolver = ( + reference, + context, +) => { + if (reference === "discordProxyUrl") return resolvedRenderTemplateReference(undefined); + + switch (reference) { + case "discord.guilds": + return resolvedRenderTemplateReference(nonEmptyObject(discordGuilds(context))); + case "discord.hasGuilds": + return resolvedRenderTemplateReference(Object.keys(discordGuilds(context)).length > 0); + case "discord.guildIds.csv": + return resolvedRenderTemplateReference(nonEmptyCsv(Object.keys(discordGuilds(context)))); + case "discord.allowedUsers.values": + return resolvedRenderTemplateReference(nonEmptyArray(discordAllowedUsers(context))); + case "discord.allowedUsers.csv": + return resolvedRenderTemplateReference(nonEmptyCsv(discordAllowedUsers(context))); + case "discord.allowedUsers.dmPolicy": + return resolvedRenderTemplateReference( + discordAllowedUsers(context).length > 0 ? "allowlist" : undefined, + ); + case "discord.allowAllUsers": + return resolvedRenderTemplateReference( + Object.keys(discordGuilds(context)).length > 0 && + discordAllowedUsers(context).length === 0 + ? true + : undefined, + ); + case "discord.requireMention": + return resolvedRenderTemplateReference(discordRequireMention(context)); + default: + return undefined; + } +}; + +function discordGuilds(context: RenderTemplateContext): Record { + const serverIds = parseList(stateValue(context, "discordGuilds.serverId")); + if (serverIds.length === 0) return {}; + const users = parseList(stateValue(context, "discordGuilds.userIds")); + const requireMention = parseBoolean(stateValue(context, "discordGuilds.requireMention")) ?? true; + return Object.fromEntries( + serverIds.map((serverId) => [ + serverId, + { + enabled: true, + requireMention, + ...(users.length > 0 ? { users } : {}), + }, + ]), + ); +} + +function discordAllowedUsers(context: RenderTemplateContext): string[] { + const users = new Set(allowedIds(context, "discord")); + for (const guild of Object.values(discordGuilds(context))) { + for (const user of guild.users ?? []) users.add(String(user)); + } + return [...users]; +} + +function discordRequireMention(context: RenderTemplateContext): boolean { + for (const guild of Object.values(discordGuilds(context))) { + if (typeof guild.requireMention === "boolean") return guild.requireMention; + } + return true; +} diff --git a/src/lib/messaging/channels/index.ts b/src/lib/messaging/channels/index.ts index 057f11b91e..3281260d73 100644 --- a/src/lib/messaging/channels/index.ts +++ b/src/lib/messaging/channels/index.ts @@ -12,7 +12,7 @@ import { whatsappManifest } from "./whatsapp/manifest"; export { discordManifest } from "./discord/manifest"; export { slackManifest } from "./slack/manifest"; export { telegramManifest } from "./telegram/manifest"; -export { createBuiltInRenderTemplateResolver } from "./template-resolvers"; +export { createBuiltInRenderTemplateResolver } from "./template-resolver"; export { wechatManifest } from "./wechat/manifest"; export { whatsappManifest } from "./whatsapp/manifest"; diff --git a/src/lib/messaging/channels/slack/template-resolver.ts b/src/lib/messaging/channels/slack/template-resolver.ts new file mode 100644 index 0000000000..351660c08f --- /dev/null +++ b/src/lib/messaging/channels/slack/template-resolver.ts @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { MessagingSerializableValue } from "../../manifest"; +import { + allowedIds, + type BuiltInRenderTemplateResolver, + nonEmptyArray, + nonEmptyCsv, + parseList, + resolvedRenderTemplateReference, + stateValue, +} from "../template-resolver-utils"; + +export const resolveSlackTemplateReference: BuiltInRenderTemplateResolver = ( + reference, + context, +) => { + if (reference === "slackConfig.allowedChannels.csv") { + return resolvedRenderTemplateReference(nonEmptyCsv(slackAllowedChannels(context))); + } + + const allowedIdsReference = reference.match( + /^allowedIds[.]slack[.](values|csv|dmPolicy|groupPolicy|channels)$/, + ); + if (!allowedIdsReference?.[1]) return undefined; + const ids = allowedIds(context, "slack"); + switch (allowedIdsReference[1]) { + case "values": + return resolvedRenderTemplateReference(nonEmptyArray(ids)); + case "csv": + return resolvedRenderTemplateReference(nonEmptyCsv(ids)); + case "dmPolicy": + return resolvedRenderTemplateReference(ids.length > 0 ? "allowlist" : undefined); + case "groupPolicy": + return resolvedRenderTemplateReference( + ids.length > 0 || slackAllowedChannels(context).length > 0 ? "allowlist" : undefined, + ); + case "channels": + return resolvedRenderTemplateReference(slackChannelConfig(context, ids)); + default: + return undefined; + } +}; + +function slackChannelConfig( + context: Parameters[1], + users: readonly string[], +): Record | undefined { + const allowedChannels = slackAllowedChannels(context); + const entry: Record = { + enabled: true, + requireMention: true, + ...(users.length > 0 ? { users: [...users] } : {}), + }; + if (allowedChannels.length > 0) { + return Object.fromEntries(allowedChannels.map((channelId) => [channelId, { ...entry }])); + } + return users.length > 0 ? { "*": entry } : undefined; +} + +function slackAllowedChannels(context: Parameters[1]): string[] { + return parseList(stateValue(context, "slackConfig.allowedChannels")); +} diff --git a/src/lib/messaging/channels/telegram/template-resolver.ts b/src/lib/messaging/channels/telegram/template-resolver.ts new file mode 100644 index 0000000000..7031e7ddc9 --- /dev/null +++ b/src/lib/messaging/channels/telegram/template-resolver.ts @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { RenderTemplateContext } from "../../compiler/engines/template"; +import { + allowedIds, + type BuiltInRenderTemplateResolver, + nonEmptyArray, + nonEmptyCsv, + nonEmptyString, + parseBoolean, + resolvedRenderTemplateReference, + stateValue, +} from "../template-resolver-utils"; + +const DEFAULT_PROXY_HOST = "10.200.0.1"; +const DEFAULT_PROXY_PORT = "3128"; + +export const resolveTelegramTemplateReference: BuiltInRenderTemplateResolver = ( + reference, + context, +) => { + if (reference === "proxyUrl") return resolvedRenderTemplateReference(proxyUrl(context.env)); + if (reference === "telegramConfig.requireMention") { + return resolvedRenderTemplateReference( + parseBoolean(stateValue(context, "telegramConfig.requireMention")), + ); + } + + const allowedIdsReference = reference.match(/^allowedIds[.]telegram[.](values|csv|dmPolicy)$/); + if (!allowedIdsReference?.[1]) return undefined; + const ids = allowedIds(context, "telegram"); + switch (allowedIdsReference[1]) { + case "values": + return resolvedRenderTemplateReference(nonEmptyArray(ids)); + case "csv": + return resolvedRenderTemplateReference(nonEmptyCsv(ids)); + case "dmPolicy": + return resolvedRenderTemplateReference(ids.length > 0 ? "allowlist" : undefined); + default: + return undefined; + } +}; + +function proxyUrl(env: RenderTemplateContext["env"]): string { + const host = nonEmptyString(env?.NEMOCLAW_PROXY_HOST) ?? DEFAULT_PROXY_HOST; + const port = nonEmptyString(env?.NEMOCLAW_PROXY_PORT) ?? DEFAULT_PROXY_PORT; + return `http://${host}:${port}`; +} diff --git a/src/lib/messaging/channels/template-resolver-utils.ts b/src/lib/messaging/channels/template-resolver-utils.ts new file mode 100644 index 0000000000..a956ee1654 --- /dev/null +++ b/src/lib/messaging/channels/template-resolver-utils.ts @@ -0,0 +1,69 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + type RenderTemplateContext, + type RenderTemplateReferenceResolution, + resolvedRenderTemplateReference, +} from "../compiler/engines/template"; +import type { MessagingSerializableValue } from "../manifest"; + +export type BuiltInRenderTemplateResolver = ( + reference: string, + context: RenderTemplateContext, +) => RenderTemplateReferenceResolution | undefined; + +export { resolvedRenderTemplateReference }; + +export function allowedIds(context: RenderTemplateContext, channel: string): string[] { + return parseList(stateValue(context, `allowedIds.${channel}`)); +} + +export function stateValue( + context: RenderTemplateContext, + path: string, +): MessagingSerializableValue | undefined { + const stateInput = context.inputs.find((input) => input.statePath === path); + if (stateInput?.value !== undefined) return stateInput.value; + const inputId = path.split(".").at(-1); + return context.inputs.find((input) => input.inputId === inputId)?.value; +} + +export function parseList(value: MessagingSerializableValue | undefined): string[] { + if (Array.isArray(value)) return unique(value.map(String).map(cleanString).filter(Boolean)); + const text = cleanString(value); + if (!text) return []; + return unique(text.split(",").map(cleanString).filter(Boolean)); +} + +export function parseBoolean(value: MessagingSerializableValue | undefined): boolean | undefined { + if (typeof value === "boolean") return value; + const text = cleanString(value)?.toLowerCase(); + if (text === "1" || text === "true" || text === "yes" || text === "on") return true; + if (text === "0" || text === "false" || text === "no" || text === "off") return false; + return undefined; +} + +export function nonEmptyString(value: unknown): string | undefined { + return cleanString(value) || undefined; +} + +export function cleanString(value: unknown): string { + return String(value ?? "").replace(/\r/g, "").trim(); +} + +export function nonEmptyArray(values: readonly string[]): string[] | undefined { + return values.length > 0 ? [...values] : undefined; +} + +export function nonEmptyCsv(values: readonly string[]): string | undefined { + return values.length > 0 ? values.join(",") : undefined; +} + +export function nonEmptyObject>(value: T): T | undefined { + return Object.keys(value).length > 0 ? value : undefined; +} + +export function unique(values: readonly T[]): T[] { + return [...new Set(values)]; +} diff --git a/src/lib/messaging/channels/template-resolver.ts b/src/lib/messaging/channels/template-resolver.ts new file mode 100644 index 0000000000..6e93187fe0 --- /dev/null +++ b/src/lib/messaging/channels/template-resolver.ts @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { resolveDiscordTemplateReference } from "./discord/template-resolver"; +import { resolveSlackTemplateReference } from "./slack/template-resolver"; +import { resolveTelegramTemplateReference } from "./telegram/template-resolver"; +import type { BuiltInRenderTemplateResolver } from "./template-resolver-utils"; +import { resolveWechatTemplateReference } from "./wechat/template-resolver"; +import { resolveWhatsappTemplateReference } from "./whatsapp/template-resolver"; + +const BUILT_IN_TEMPLATE_REFERENCE_RESOLVERS: readonly BuiltInRenderTemplateResolver[] = [ + resolveTelegramTemplateReference, + resolveDiscordTemplateReference, + resolveWechatTemplateReference, + resolveSlackTemplateReference, + resolveWhatsappTemplateReference, +]; + +export function createBuiltInRenderTemplateResolver(): BuiltInRenderTemplateResolver { + return (reference, context) => { + for (const resolver of BUILT_IN_TEMPLATE_REFERENCE_RESOLVERS) { + const resolved = resolver(reference, context); + if (resolved) return resolved; + } + return undefined; + }; +} diff --git a/src/lib/messaging/channels/template-resolvers.ts b/src/lib/messaging/channels/template-resolvers.ts deleted file mode 100644 index ebf564ac3b..0000000000 --- a/src/lib/messaging/channels/template-resolvers.ts +++ /dev/null @@ -1,265 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { - type RenderTemplateContext, - type RenderTemplateReferenceResolution, - resolvedRenderTemplateReference, -} from "../compiler/engines/template"; -import type { MessagingSerializableValue } from "../manifest"; - -const DEFAULT_PROXY_HOST = "10.200.0.1"; -const DEFAULT_PROXY_PORT = "3128"; - -type BuiltInRenderTemplateResolver = ( - reference: string, - context: RenderTemplateContext, -) => RenderTemplateReferenceResolution | undefined; - -type DiscordGuildConfig = { - readonly enabled: true; - readonly requireMention?: boolean; - readonly users?: readonly string[]; -}; - -const BUILT_IN_TEMPLATE_REFERENCE_RESOLVERS: Record = { - allowedIds: resolveAllowedIdsTemplateReference, - discord: resolveDiscordTemplateReference, - discordProxyUrl: resolveDiscordProxyUrlTemplateReference, - proxyUrl: resolveProxyUrlTemplateReference, - slackConfig: resolveSlackConfigTemplateReference, - telegramConfig: resolveTelegramConfigTemplateReference, - wechatConfig: resolveWechatConfigTemplateReference, -}; - -export function createBuiltInRenderTemplateResolver(): BuiltInRenderTemplateResolver { - return (reference, context) => - BUILT_IN_TEMPLATE_REFERENCE_RESOLVERS[templateReferenceKey(reference)]?.( - reference, - context, - ); -} - -function templateReferenceKey(reference: string): string { - const separator = reference.indexOf("."); - return separator === -1 ? reference : reference.slice(0, separator); -} - -function resolveProxyUrlTemplateReference( - reference: string, - context: RenderTemplateContext, -): RenderTemplateReferenceResolution | undefined { - if (reference !== "proxyUrl") return undefined; - return resolvedRenderTemplateReference(proxyUrl(context.env)); -} - -function resolveDiscordProxyUrlTemplateReference( - reference: string, -): RenderTemplateReferenceResolution | undefined { - if (reference !== "discordProxyUrl") return undefined; - return resolvedRenderTemplateReference(undefined); -} - -function resolveDiscordTemplateReference( - reference: string, - context: RenderTemplateContext, -): RenderTemplateReferenceResolution | undefined { - switch (reference) { - case "discord.guilds": - return resolvedRenderTemplateReference(nonEmptyObject(discordGuilds(context))); - case "discord.hasGuilds": - return resolvedRenderTemplateReference(Object.keys(discordGuilds(context)).length > 0); - case "discord.guildIds.csv": - return resolvedRenderTemplateReference(nonEmptyCsv(Object.keys(discordGuilds(context)))); - case "discord.allowedUsers.values": - return resolvedRenderTemplateReference(nonEmptyArray(discordAllowedUsers(context))); - case "discord.allowedUsers.csv": - return resolvedRenderTemplateReference(nonEmptyCsv(discordAllowedUsers(context))); - case "discord.allowedUsers.dmPolicy": - return resolvedRenderTemplateReference( - discordAllowedUsers(context).length > 0 ? "allowlist" : undefined, - ); - case "discord.allowAllUsers": - return resolvedRenderTemplateReference( - Object.keys(discordGuilds(context)).length > 0 && - discordAllowedUsers(context).length === 0 - ? true - : undefined, - ); - case "discord.requireMention": - return resolvedRenderTemplateReference(discordRequireMention(context)); - default: - return undefined; - } -} - -function resolveAllowedIdsTemplateReference( - reference: string, - context: RenderTemplateContext, -): RenderTemplateReferenceResolution | undefined { - const allowedIds = reference.match( - /^allowedIds[.]([A-Za-z0-9_-]+)[.](values|csv|dmPolicy|groupPolicy|channels)$/, - ); - if (!allowedIds?.[1] || !allowedIds[2]) return undefined; - return resolvedRenderTemplateReference( - resolveAllowedIdsTemplate(allowedIds[1], allowedIds[2], context), - ); -} - -function resolveTelegramConfigTemplateReference( - reference: string, - context: RenderTemplateContext, -): RenderTemplateReferenceResolution | undefined { - if (reference !== "telegramConfig.requireMention") return undefined; - return resolvedRenderTemplateReference( - parseBoolean(stateValue(context, "telegramConfig.requireMention")), - ); -} - -function resolveWechatConfigTemplateReference( - reference: string, - context: RenderTemplateContext, -): RenderTemplateReferenceResolution | undefined { - const wechatConfig = reference.match(/^wechatConfig[.](accountId|baseUrl|userId)$/); - if (!wechatConfig?.[1]) return undefined; - return resolvedRenderTemplateReference( - nonEmptyString(stateValue(context, "wechatConfig." + wechatConfig[1])), - ); -} - -function resolveSlackConfigTemplateReference( - reference: string, - context: RenderTemplateContext, -): RenderTemplateReferenceResolution | undefined { - if (reference !== "slackConfig.allowedChannels.csv") return undefined; - return resolvedRenderTemplateReference(nonEmptyCsv(slackAllowedChannels(context))); -} - -function resolveAllowedIdsTemplate( - channel: string, - selector: string, - context: RenderTemplateContext, -): MessagingSerializableValue | undefined { - const ids = allowedIds(context, channel); - if (selector === "values") return nonEmptyArray(ids); - if (selector === "csv") return nonEmptyCsv(ids); - if (selector === "dmPolicy") return ids.length > 0 ? "allowlist" : undefined; - if (selector === "groupPolicy") { - return ids.length > 0 || (channel === "slack" && slackAllowedChannels(context).length > 0) - ? "allowlist" - : undefined; - } - if (selector === "channels" && channel === "slack") return slackChannelConfig(context, ids); - return undefined; -} - -function proxyUrl(env: RenderTemplateContext["env"]): string { - const host = nonEmptyString(env?.NEMOCLAW_PROXY_HOST) ?? DEFAULT_PROXY_HOST; - const port = nonEmptyString(env?.NEMOCLAW_PROXY_PORT) ?? DEFAULT_PROXY_PORT; - return `http://${host}:${port}`; -} - -function slackChannelConfig( - context: RenderTemplateContext, - users: readonly string[], -): Record | undefined { - const allowedChannels = slackAllowedChannels(context); - const entry: Record = { - enabled: true, - requireMention: true, - ...(users.length > 0 ? { users: [...users] } : {}), - }; - if (allowedChannels.length > 0) { - return Object.fromEntries(allowedChannels.map((channelId) => [channelId, { ...entry }])); - } - return users.length > 0 ? { "*": entry } : undefined; -} - -function discordGuilds(context: RenderTemplateContext): Record { - const serverIds = parseList(stateValue(context, "discordGuilds.serverId")); - if (serverIds.length === 0) return {}; - const users = parseList(stateValue(context, "discordGuilds.userIds")); - const requireMention = parseBoolean(stateValue(context, "discordGuilds.requireMention")) ?? true; - return Object.fromEntries( - serverIds.map((serverId) => [ - serverId, - { - enabled: true, - requireMention, - ...(users.length > 0 ? { users } : {}), - }, - ]), - ); -} - -function discordAllowedUsers(context: RenderTemplateContext): string[] { - const users = new Set(allowedIds(context, "discord")); - for (const guild of Object.values(discordGuilds(context))) { - for (const user of guild.users ?? []) users.add(String(user)); - } - return [...users]; -} - -function discordRequireMention(context: RenderTemplateContext): boolean { - for (const guild of Object.values(discordGuilds(context))) { - if (typeof guild.requireMention === "boolean") return guild.requireMention; - } - return true; -} - -function allowedIds(context: RenderTemplateContext, channel: string): string[] { - const ids = parseList(stateValue(context, `allowedIds.${channel}`)); - if (channel !== "wechat") return ids; - const userId = nonEmptyString(stateValue(context, "wechatConfig.userId")); - return userId && !ids.includes(userId) ? [userId, ...ids] : ids; -} - -function slackAllowedChannels(context: RenderTemplateContext): string[] { - return parseList(stateValue(context, "slackConfig.allowedChannels")); -} - -function stateValue(context: RenderTemplateContext, path: string): MessagingSerializableValue | undefined { - const stateInput = context.inputs.find((input) => input.statePath === path); - if (stateInput?.value !== undefined) return stateInput.value; - const inputId = path.split(".").at(-1); - return context.inputs.find((input) => input.inputId === inputId)?.value; -} - -function parseList(value: MessagingSerializableValue | undefined): string[] { - if (Array.isArray(value)) return unique(value.map(String).map(cleanString).filter(Boolean)); - const text = cleanString(value); - if (!text) return []; - return unique(text.split(",").map(cleanString).filter(Boolean)); -} - -function parseBoolean(value: MessagingSerializableValue | undefined): boolean | undefined { - if (typeof value === "boolean") return value; - const text = cleanString(value)?.toLowerCase(); - if (text === "1" || text === "true" || text === "yes" || text === "on") return true; - if (text === "0" || text === "false" || text === "no" || text === "off") return false; - return undefined; -} - -function nonEmptyString(value: unknown): string | undefined { - return cleanString(value) || undefined; -} - -function cleanString(value: unknown): string { - return String(value ?? "").replace(/\r/g, "").trim(); -} - -function nonEmptyArray(values: readonly string[]): string[] | undefined { - return values.length > 0 ? [...values] : undefined; -} - -function nonEmptyCsv(values: readonly string[]): string | undefined { - return values.length > 0 ? values.join(",") : undefined; -} - -function nonEmptyObject>(value: T): T | undefined { - return Object.keys(value).length > 0 ? value : undefined; -} - -function unique(values: readonly T[]): T[] { - return [...new Set(values)]; -} diff --git a/src/lib/messaging/channels/wechat/template-resolver.ts b/src/lib/messaging/channels/wechat/template-resolver.ts new file mode 100644 index 0000000000..c97820ad0d --- /dev/null +++ b/src/lib/messaging/channels/wechat/template-resolver.ts @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { RenderTemplateContext } from "../../compiler/engines/template"; +import { + allowedIds, + type BuiltInRenderTemplateResolver, + nonEmptyArray, + nonEmptyCsv, + nonEmptyString, + resolvedRenderTemplateReference, + stateValue, +} from "../template-resolver-utils"; + +export const resolveWechatTemplateReference: BuiltInRenderTemplateResolver = ( + reference, + context, +) => { + const wechatConfig = reference.match(/^wechatConfig[.](accountId|baseUrl|userId)$/); + if (wechatConfig?.[1]) { + return resolvedRenderTemplateReference( + nonEmptyString(stateValue(context, "wechatConfig." + wechatConfig[1])), + ); + } + + const allowedIdsReference = reference.match(/^allowedIds[.]wechat[.](values|csv|dmPolicy)$/); + if (!allowedIdsReference?.[1]) return undefined; + const ids = wechatAllowedIds(context); + switch (allowedIdsReference[1]) { + case "values": + return resolvedRenderTemplateReference(nonEmptyArray(ids)); + case "csv": + return resolvedRenderTemplateReference(nonEmptyCsv(ids)); + case "dmPolicy": + return resolvedRenderTemplateReference(ids.length > 0 ? "allowlist" : undefined); + default: + return undefined; + } +}; + +function wechatAllowedIds(context: RenderTemplateContext): string[] { + const ids = allowedIds(context, "wechat"); + const userId = nonEmptyString(stateValue(context, "wechatConfig.userId")); + return userId && !ids.includes(userId) ? [userId, ...ids] : ids; +} diff --git a/src/lib/messaging/channels/whatsapp/template-resolver.ts b/src/lib/messaging/channels/whatsapp/template-resolver.ts new file mode 100644 index 0000000000..f863ce2f57 --- /dev/null +++ b/src/lib/messaging/channels/whatsapp/template-resolver.ts @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + allowedIds, + type BuiltInRenderTemplateResolver, + nonEmptyArray, + nonEmptyCsv, + resolvedRenderTemplateReference, +} from "../template-resolver-utils"; + +export const resolveWhatsappTemplateReference: BuiltInRenderTemplateResolver = ( + reference, + context, +) => { + const allowedIdsReference = reference.match(/^allowedIds[.]whatsapp[.](values|csv|dmPolicy)$/); + if (!allowedIdsReference?.[1]) return undefined; + const ids = allowedIds(context, "whatsapp"); + switch (allowedIdsReference[1]) { + case "values": + return resolvedRenderTemplateReference(nonEmptyArray(ids)); + case "csv": + return resolvedRenderTemplateReference(nonEmptyCsv(ids)); + case "dmPolicy": + return resolvedRenderTemplateReference(ids.length > 0 ? "allowlist" : undefined); + default: + return undefined; + } +}; From 1360ea1ad05a07530b9ad0c025805f3fb50b4a91 Mon Sep 17 00:00:00 2001 From: San Dang Date: Tue, 9 Jun 2026 10:14:22 +0530 Subject: [PATCH 09/23] fix(messaging): emit OpenClaw-valid channel config --- src/lib/messaging/applier/setup-applier.test.ts | 4 ++-- .../channels/discord/template-resolver.ts | 2 -- .../channels/wechat/hooks/seed-openclaw-account.ts | 3 --- test/generate-openclaw-config.test.ts | 14 ++++++++++---- test/messaging-build-applier.test.ts | 4 +++- 5 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/lib/messaging/applier/setup-applier.test.ts b/src/lib/messaging/applier/setup-applier.test.ts index f1d8543eac..49846aafb2 100644 --- a/src/lib/messaging/applier/setup-applier.test.ts +++ b/src/lib/messaging/applier/setup-applier.test.ts @@ -532,9 +532,9 @@ describe("MessagingSetupApplier", () => { expect(openclawConfig.plugins.installs["openclaw-weixin"].spec).toBe( "@tencent-weixin/openclaw-weixin@2.4.3", ); - expect(openclawConfig.plugins.load.paths).toEqual([ + expect(openclawConfig.plugins.load?.paths ?? []).not.toContain( "/sandbox/.openclaw/extensions/openclaw-weixin", - ]); + ); expect(openclawConfig.channels["openclaw-weixin"].accounts["wechat-account"]).toEqual({ enabled: true, }); diff --git a/src/lib/messaging/channels/discord/template-resolver.ts b/src/lib/messaging/channels/discord/template-resolver.ts index 6e4e1232c2..ba0d94b22d 100644 --- a/src/lib/messaging/channels/discord/template-resolver.ts +++ b/src/lib/messaging/channels/discord/template-resolver.ts @@ -15,7 +15,6 @@ import { } from "../template-resolver-utils"; type DiscordGuildConfig = { - readonly enabled: true; readonly requireMention?: boolean; readonly users?: readonly string[]; }; @@ -64,7 +63,6 @@ function discordGuilds(context: RenderTemplateContext): Record [ serverId, { - enabled: true, requireMention, ...(users.length > 0 ? { users } : {}), }, diff --git a/src/lib/messaging/channels/wechat/hooks/seed-openclaw-account.ts b/src/lib/messaging/channels/wechat/hooks/seed-openclaw-account.ts index 88abb55cb2..1daca4adbd 100644 --- a/src/lib/messaging/channels/wechat/hooks/seed-openclaw-account.ts +++ b/src/lib/messaging/channels/wechat/hooks/seed-openclaw-account.ts @@ -88,9 +88,6 @@ export function buildWechatSeedOpenClawAccountOutputs( installPath: pluginInstallPath, }, }, - load: { - paths: [pluginInstallPath], - }, entries: { [WECHAT_PLUGIN_ID]: { enabled: true, diff --git a/test/generate-openclaw-config.test.ts b/test/generate-openclaw-config.test.ts index a0e863b78c..f865bf172e 100644 --- a/test/generate-openclaw-config.test.ts +++ b/test/generate-openclaw-config.test.ts @@ -508,16 +508,19 @@ describe("generate-openclaw-config.mts: config generation", () => { expect(config.channels.telegram.groups).toBeUndefined(); }); - it("emits Discord guild allowlist config when guilds are provided", () => { + it("emits OpenClaw-valid Discord guild allowlist config when guilds are provided", () => { const channels = Buffer.from(JSON.stringify(["discord"])).toString("base64"); - const guilds = { "1234567890": { enabled: true, requireMention: true } }; + const legacyGuilds = { "1234567890": { enabled: true, requireMention: true } }; const config = buildConfigDirect({ NEMOCLAW_MESSAGING_CHANNELS_B64: channels, - NEMOCLAW_DISCORD_GUILDS_B64: Buffer.from(JSON.stringify(guilds)).toString("base64"), + NEMOCLAW_DISCORD_GUILDS_B64: Buffer.from(JSON.stringify(legacyGuilds)).toString("base64"), }); expect(config.channels.discord.groupPolicy).toBe("allowlist"); - expect(config.channels.discord.guilds).toEqual(guilds); + expect(config.channels.discord.guilds).toEqual({ + "1234567890": { requireMention: true }, + }); + expect(config.channels.discord.guilds["1234567890"].enabled).toBeUndefined(); }); it("applies WeChat post-agent-install build-file outputs through the messaging applier", () => { @@ -535,6 +538,9 @@ describe("generate-openclaw-config.mts: config generation", () => { spec: "@tencent-weixin/openclaw-weixin@2.4.3", installPath: "/sandbox/.openclaw/extensions/openclaw-weixin", }); + expect(config.plugins?.load?.paths ?? []).not.toContain( + "/sandbox/.openclaw/extensions/openclaw-weixin", + ); expect(config.channels?.["openclaw-weixin"]?.accounts?.primary).toEqual({ enabled: true }); expect(config.channels?.wechat).toBeUndefined(); }); diff --git a/test/messaging-build-applier.test.ts b/test/messaging-build-applier.test.ts index 9163164bf5..88668886c0 100644 --- a/test/messaging-build-applier.test.ts +++ b/test/messaging-build-applier.test.ts @@ -332,7 +332,9 @@ describe("messaging-build-applier.mts: agent-install", () => { spec: "@tencent-weixin/openclaw-weixin@2.4.3", installPath: "/sandbox/.openclaw/extensions/openclaw-weixin", }); - expect(config.plugins?.load?.paths).toEqual(["/sandbox/.openclaw/extensions/openclaw-weixin"]); + expect(config.plugins?.load?.paths ?? []).not.toContain( + "/sandbox/.openclaw/extensions/openclaw-weixin", + ); expect(config.channels?.["openclaw-weixin"]?.accounts?.primary).toEqual({ enabled: true }); expect(config.channels?.wechat).toBeUndefined(); From 4840c79e74e5fe5734a07d7b4fd2ea178f216690 Mon Sep 17 00:00:00 2001 From: San Dang Date: Tue, 9 Jun 2026 17:54:32 +0700 Subject: [PATCH 10/23] test: restore openclaw config test size budget --- ci/test-file-size-budget.json | 2 +- test/generate-openclaw-config.test.ts | 6 ------ 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/ci/test-file-size-budget.json b/ci/test-file-size-budget.json index eafb8b3872..d9b75995fb 100644 --- a/ci/test-file-size-budget.json +++ b/ci/test-file-size-budget.json @@ -6,7 +6,7 @@ "src/lib/inference/nim.test.ts": 2079, "src/lib/onboard/preflight.test.ts": 1905, "test/channels-add-preset.test.ts": 1901, - "test/generate-openclaw-config.test.ts": 2006, + "test/generate-openclaw-config.test.ts": 2000, "test/install-preflight.test.ts": 4397, "test/nemoclaw-start.test.ts": 5300, "test/onboard-messaging.test.ts": 2112, diff --git a/test/generate-openclaw-config.test.ts b/test/generate-openclaw-config.test.ts index f865bf172e..1638aa50da 100644 --- a/test/generate-openclaw-config.test.ts +++ b/test/generate-openclaw-config.test.ts @@ -237,9 +237,6 @@ afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); -// ═══════════════════════════════════════════════════════════════════ -// Phase 1: Extraction — behavior-preserving tests -// ═══════════════════════════════════════════════════════════════════ describe("generate-openclaw-config.mts: config generation", () => { it("generates valid JSON with minimal env vars", () => { const config = runConfigScript(); @@ -1857,9 +1854,6 @@ describe("generate-openclaw-config.mts: config generation", () => { }); }); -// ═══════════════════════════════════════════════════════════════════ -// Phase 2: Auto-disable device auth for non-loopback URLs -// ═══════════════════════════════════════════════════════════════════ describe("generate-openclaw-config.mts: non-loopback auto-disable device auth", () => { it("auto-disables device auth for Brev Launchable URL", () => { const config = runConfigScript({ From 765fa21840ccc4ae3feab4c7b9a8d31f793a1741 Mon Sep 17 00:00:00 2001 From: San Dang Date: Tue, 9 Jun 2026 17:57:51 +0700 Subject: [PATCH 11/23] fix(messaging): block render target traversal --- .../applier/build/messaging-build-applier.mts | 18 ++++++-- test/messaging-build-applier.test.ts | 45 +++++++++++++++++++ 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/src/lib/messaging/applier/build/messaging-build-applier.mts b/src/lib/messaging/applier/build/messaging-build-applier.mts index f2a384525e..ecdc56e997 100755 --- a/src/lib/messaging/applier/build/messaging-build-applier.mts +++ b/src/lib/messaging/applier/build/messaging-build-applier.mts @@ -379,20 +379,32 @@ function resolveAgentRenderTarget( options: { readonly homeDir?: string } = {}, ): string { const home = options.homeDir ?? homedir(); + const agentRoot = agent === "hermes" ? join(home, ".hermes") : join(home, ".openclaw"); + const normalizedRoot = resolve(agentRoot); if (agent === "openclaw" && target === "openclaw.json") { - return join(home, ".openclaw", "openclaw.json"); + return join(agentRoot, "openclaw.json"); } + let relativePath: string | null = null; if (target.startsWith("~/.openclaw/")) { if (agent !== "openclaw") { throw new MessagingBuildApplierError(`Messaging render target ${target} does not match ${agent}.`); } - return join(home, ".openclaw", target.slice("~/.openclaw/".length)); + relativePath = target.slice("~/.openclaw/".length); } if (target.startsWith("~/.hermes/")) { if (agent !== "hermes") { throw new MessagingBuildApplierError(`Messaging render target ${target} does not match ${agent}.`); } - return join(home, ".hermes", target.slice("~/.hermes/".length)); + relativePath = target.slice("~/.hermes/".length); + } + if (relativePath !== null) { + const resolvedTarget = resolve(agentRoot, relativePath); + if (resolvedTarget !== normalizedRoot && !resolvedTarget.startsWith(`${normalizedRoot}${sep}`)) { + throw new MessagingBuildApplierError( + `Messaging render target ${target} must stay inside ${agentRoot}.`, + ); + } + return resolvedTarget; } throw new MessagingBuildApplierError(`Unsupported messaging render target ${target}.`); } diff --git a/test/messaging-build-applier.test.ts b/test/messaging-build-applier.test.ts index 88668886c0..99dfc4984e 100644 --- a/test/messaging-build-applier.test.ts +++ b/test/messaging-build-applier.test.ts @@ -354,6 +354,51 @@ describe("messaging-build-applier.mts: agent-install", () => { } }); + it("rejects post-agent-install render targets that escape the agent root", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-render-target-escape-")); + const plan = { + schemaVersion: 1, + sandboxName: "test-sandbox", + agent: "openclaw", + channels: [{ channelId: "telegram", active: true }], + credentialBindings: [], + agentRender: [ + { + channelId: "telegram", + agent: "openclaw", + target: "~/.openclaw/../escaped.json", + kind: "json-fragment", + path: "channels.telegram.enabled", + value: true, + }, + ], + buildSteps: [], + }; + + try { + const result = spawnSync( + "node", + ["--experimental-strip-types", SCRIPT_PATH, "--agent", "openclaw", "--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"), + }, + timeout: 10_000, + }, + ); + + expect(result.status).toBe(2); + expect(result.stderr).toContain("must stay inside"); + expect(fs.existsSync(path.join(tmp, "escaped.json"))).toBe(false); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + it("applies Hermes messaging render to config.yaml and .env in post-agent-install", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-render-")); try { From e9ec0126980ea8c686b73f8d776e2d1dff4919a7 Mon Sep 17 00:00:00 2001 From: San Dang Date: Tue, 9 Jun 2026 18:02:49 +0700 Subject: [PATCH 12/23] test: restore onboard messaging size budget --- ci/test-file-size-budget.json | 2 +- test/onboard-messaging.test.ts | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/ci/test-file-size-budget.json b/ci/test-file-size-budget.json index d9b75995fb..2cee2202af 100644 --- a/ci/test-file-size-budget.json +++ b/ci/test-file-size-budget.json @@ -9,7 +9,7 @@ "test/generate-openclaw-config.test.ts": 2000, "test/install-preflight.test.ts": 4397, "test/nemoclaw-start.test.ts": 5300, - "test/onboard-messaging.test.ts": 2112, + "test/onboard-messaging.test.ts": 2110, "test/onboard-selection.test.ts": 7156, "test/onboard.test.ts": 4879, "test/policies.test.ts": 2763 diff --git a/test/onboard-messaging.test.ts b/test/onboard-messaging.test.ts index 1e2f8f2a55..024a9a4c7d 100644 --- a/test/onboard-messaging.test.ts +++ b/test/onboard-messaging.test.ts @@ -491,7 +491,6 @@ const { createSandbox } = require(${onboardPath}); "reuses existing messaging providers during non-interactive recreate when tokens are not in the host env", { timeout: 60_000 }, async () => { - const repoRoot = path.join(import.meta.dirname, ".."); const tmpDir = fs.mkdtempSync( path.join(os.tmpdir(), "nemoclaw-onboard-messaging-reuse-provider-"), ); @@ -653,7 +652,6 @@ const { createSandbox } = require(${onboardPath}); "preserves disabled channels in the registry after a recreate so `channels start` can re-enable them (#3381)", { timeout: 60_000 }, async () => { - const repoRoot = path.join(import.meta.dirname, ".."); const tmpDir = fs.mkdtempSync( path.join(os.tmpdir(), "nemoclaw-onboard-disabled-channels-preserve-"), ); From 2ed61a93e1086160d71ca4bc52c07fa20dd86e87 Mon Sep 17 00:00:00 2001 From: San Dang Date: Tue, 9 Jun 2026 18:14:48 +0700 Subject: [PATCH 13/23] fix(messaging): parse generated yaml headers --- .../messaging/applier/build/messaging-build-applier.mts | 7 ++++++- test/messaging-build-applier.test.ts | 3 +++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/lib/messaging/applier/build/messaging-build-applier.mts b/src/lib/messaging/applier/build/messaging-build-applier.mts index ecdc56e997..17387322fb 100755 --- a/src/lib/messaging/applier/build/messaging-build-applier.mts +++ b/src/lib/messaging/applier/build/messaging-build-applier.mts @@ -700,7 +700,7 @@ function parseGeneratedYamlObject(existing: string | undefined, target: string): const lines = existing .split(/\r?\n/) .map((line, index): GeneratedYamlLine | null => { - if (line.trim().length === 0) return null; + if (isIgnorableGeneratedYamlLine(line)) return null; const indent = line.match(/^ */)?.[0].length ?? 0; return { indent, text: line.slice(indent), lineNumber: index + 1 }; }) @@ -713,6 +713,11 @@ function parseGeneratedYamlObject(existing: string | undefined, target: string): return parsed as JsonObject; } +function isIgnorableGeneratedYamlLine(line: string): boolean { + const trimmed = line.trim(); + return trimmed.length === 0 || trimmed.startsWith("#") || trimmed === "---" || trimmed === "..."; +} + function parseGeneratedYamlBlock( lines: readonly GeneratedYamlLine[], startIndex: number, diff --git a/test/messaging-build-applier.test.ts b/test/messaging-build-applier.test.ts index 99dfc4984e..d90b82aa87 100644 --- a/test/messaging-build-applier.test.ts +++ b/test/messaging-build-applier.test.ts @@ -407,6 +407,9 @@ describe("messaging-build-applier.mts: agent-install", () => { fs.writeFileSync( path.join(hermesDir, "config.yaml"), [ + "# Managed by NemoClaw - Hermes configuration", + "# Upstream provider: openai", + "# OpenShell rewrites model.base_url to the upstream endpoint at request time.", "_config_version: 12", "platform_toolsets:", " api_server:", From 0ac4062209a1bf047f422f60db9d67b308fd274a Mon Sep 17 00:00:00 2001 From: San Dang Date: Wed, 10 Jun 2026 10:16:06 +0530 Subject: [PATCH 14/23] test: tighten messaging size budgets --- ci/test-file-size-budget.json | 14 +++++++------- test/onboard-messaging.test.ts | 16 ---------------- 2 files changed, 7 insertions(+), 23 deletions(-) diff --git a/ci/test-file-size-budget.json b/ci/test-file-size-budget.json index 6d4a07fc27..01b7768956 100644 --- a/ci/test-file-size-budget.json +++ b/ci/test-file-size-budget.json @@ -5,13 +5,13 @@ "nemoclaw/src/commands/migration-state.test.ts": 1566, "src/lib/inference/nim.test.ts": 2068, "src/lib/onboard/preflight.test.ts": 1905, - "test/channels-add-preset.test.ts": 1901, - "test/generate-openclaw-config.test.ts": 2000, - "test/install-preflight.test.ts": 4397, - "test/nemoclaw-start.test.ts": 5300, - "test/onboard-messaging.test.ts": 2110, - "test/onboard-selection.test.ts": 7156, - "test/onboard.test.ts": 4879, + "test/channels-add-preset.test.ts": 1872, + "test/generate-openclaw-config.test.ts": 1985, + "test/install-preflight.test.ts": 4396, + "test/nemoclaw-start.test.ts": 5289, + "test/onboard-messaging.test.ts": 2094, + "test/onboard-selection.test.ts": 6922, + "test/onboard.test.ts": 4866, "test/policies.test.ts": 2763 } } diff --git a/test/onboard-messaging.test.ts b/test/onboard-messaging.test.ts index d8d30352c4..a3c69a0bd4 100644 --- a/test/onboard-messaging.test.ts +++ b/test/onboard-messaging.test.ts @@ -43,7 +43,6 @@ describe("onboard messaging", () => { it("creates providers for messaging tokens and attaches them to the sandbox", { timeout: 60_000, }, async () => { - const repoRoot = path.join(import.meta.dirname, ".."); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-messaging-providers-")); const fakeBin = path.join(tmpDir, "bin"); const scriptPath = path.join(tmpDir, "messaging-provider-check.js"); @@ -294,7 +293,6 @@ const { createSandbox, setupMessagingChannels } = require(${onboardPath}); it("preserves Hermes Slack policy when Slack is active at sandbox create time", { timeout: 60_000, }, async () => { - const repoRoot = path.join(import.meta.dirname, ".."); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-hermes-slack-")); try { const fakeBin = path.join(tmpDir, "bin"); @@ -495,7 +493,6 @@ const { createSandbox } = require(${onboardPath}); it("reuses existing messaging providers during non-interactive recreate when tokens are not in the host env", { timeout: 60_000, }, async () => { - const repoRoot = path.join(import.meta.dirname, ".."); const tmpDir = fs.mkdtempSync( path.join(os.tmpdir(), "nemoclaw-onboard-messaging-reuse-provider-"), ); @@ -663,7 +660,6 @@ const { createSandbox } = require(${onboardPath}); it("preserves disabled channels in the registry after a recreate so `channels start` can re-enable them (#3381)", { timeout: 60_000, }, async () => { - const repoRoot = path.join(import.meta.dirname, ".."); const tmpDir = fs.mkdtempSync( path.join(os.tmpdir(), "nemoclaw-onboard-disabled-channels-preserve-"), ); @@ -824,7 +820,6 @@ const { createSandbox } = require(${onboardPath}); it("bakes WhatsApp into the sandbox image without bridge providers when no messaging tokens are set", { timeout: 60_000, }, async () => { - const repoRoot = path.join(import.meta.dirname, ".."); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-tokenless-whatsapp-")); try { const fakeBin = path.join(tmpDir, "bin"); @@ -977,7 +972,6 @@ const { createSandbox } = require(${onboardPath}); it("drops WhatsApp from the rebuilt image when the registry marks it disabled", { timeout: 60_000, }, async () => { - const repoRoot = path.join(import.meta.dirname, ".."); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-disabled-whatsapp-")); try { const fakeBin = path.join(tmpDir, "bin"); @@ -1136,7 +1130,6 @@ const { createSandbox } = require(${onboardPath}); }); it("aborts onboard when a messaging provider upsert fails", { timeout: 60_000 }, async () => { - const repoRoot = path.join(import.meta.dirname, ".."); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-provider-fail-")); const fakeBin = path.join(tmpDir, "bin"); const scriptPath = path.join(tmpDir, "provider-upsert-fail.js"); @@ -1219,7 +1212,6 @@ const { createSandbox } = require(${onboardPath}); it("reuses sandbox when messaging providers already exist in gateway", { timeout: 60_000, }, async () => { - const repoRoot = path.join(import.meta.dirname, ".."); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-reuse-providers-")); const fakeBin = path.join(tmpDir, "bin"); const scriptPath = path.join(tmpDir, "reuse-with-providers.js"); @@ -1319,7 +1311,6 @@ const { createSandbox } = require(${onboardPath}); it("filters messaging providers to only enabledChannels when provided", { timeout: 60_000, }, async () => { - const repoRoot = path.join(import.meta.dirname, ".."); const tmpDir = fs.mkdtempSync( path.join(os.tmpdir(), "nemoclaw-onboard-enabled-channels-filter-"), ); @@ -1460,7 +1451,6 @@ const { createSandbox } = require(${onboardPath}); it("creates no messaging providers when enabledChannels is empty", { timeout: 60_000, }, async () => { - const repoRoot = path.join(import.meta.dirname, ".."); const tmpDir = fs.mkdtempSync( path.join(os.tmpdir(), "nemoclaw-onboard-enabled-channels-empty-"), ); @@ -1589,7 +1579,6 @@ const { createSandbox } = require(${onboardPath}); it("non-interactive setupMessagingChannels returns channels with tokens", { timeout: 60_000, }, async () => { - const repoRoot = path.join(import.meta.dirname, ".."); const tmpDir = fs.mkdtempSync( path.join(os.tmpdir(), "nemoclaw-onboard-messaging-noninteractive-"), ); @@ -1659,7 +1648,6 @@ const { setupMessagingChannels } = require(${onboardPath}); it("non-interactive setupMessagingChannels drops Slack when live Slack API validation rejects the token", { timeout: 60_000, }, async () => { - const repoRoot = path.join(import.meta.dirname, ".."); const tmpDir = fs.mkdtempSync( path.join(os.tmpdir(), "nemoclaw-onboard-messaging-slack-live-reject-"), ); @@ -1744,7 +1732,6 @@ const { setupMessagingChannels } = require(${onboardPath}); it("non-interactive setupMessagingChannels returns empty array when no tokens set", { timeout: 60_000, }, async () => { - const repoRoot = path.join(import.meta.dirname, ".."); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-messaging-no-tokens-")); const fakeBin = path.join(tmpDir, "bin"); const scriptPath = path.join(tmpDir, "messaging-no-tokens.js"); @@ -1804,7 +1791,6 @@ const { setupMessagingChannels } = require(${onboardPath}); it("interactive setupMessagingChannels drops slack when prompted token fails tokenFormat check (#1912)", { timeout: 60_000, }, async () => { - const repoRoot = path.join(import.meta.dirname, ".."); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-slack-format-reject-")); const fakeBin = path.join(tmpDir, "bin"); const scriptPath = path.join(tmpDir, "slack-format-reject.js"); @@ -1913,7 +1899,6 @@ const { setupMessagingChannels, MESSAGING_CHANNELS } = require(${onboardPath}); it("interactive setupMessagingChannels drops slack when app token fails appTokenFormat check (#1912)", { timeout: 60_000, }, async () => { - const repoRoot = path.join(import.meta.dirname, ".."); const tmpDir = fs.mkdtempSync( path.join(os.tmpdir(), "nemoclaw-onboard-slack-app-format-reject-"), ); @@ -2021,7 +2006,6 @@ const { setupMessagingChannels, MESSAGING_CHANNELS } = require(${onboardPath}); }); it("Slack bot token format regex rejects obvious bogus tokens and accepts valid ones (#1912)", async () => { - const repoRoot = path.join(import.meta.dirname, ".."); const onboardPath = path.join(repoRoot, "dist", "lib", "onboard.js"); // Cache-bust the dynamic import so repeated test runs pick up rebuilds. const onboardUrl = `${pathToFileURL(onboardPath).href}?update=${Date.now()}`; From 6dc742e326b3edd818b916e36326868dd32aee40 Mon Sep 17 00:00:00 2001 From: San Dang Date: Wed, 10 Jun 2026 10:22:04 +0530 Subject: [PATCH 15/23] chore: apply static hook formatting --- .../channels/discord/template-resolver.ts | 3 +- .../channels/template-resolver-utils.ts | 4 +- .../compiler/engines/agent-render-engine.ts | 5 +- .../compiler/workflow-planner.test.ts | 2 +- src/lib/onboard/dockerfile-patch.ts | 5 +- test/generate-hermes-config.test.ts | 9 +- test/generate-openclaw-config.test.ts | 9 +- test/messaging-build-applier.test.ts | 125 ++++++++++++++---- test/messaging-plan-test-helper.ts | 53 +++----- test/sandbox-build-context.test.ts | 22 ++- test/sandbox-provisioning.test.ts | 19 ++- 11 files changed, 171 insertions(+), 85 deletions(-) diff --git a/src/lib/messaging/channels/discord/template-resolver.ts b/src/lib/messaging/channels/discord/template-resolver.ts index ba0d94b22d..e866a4e3b2 100644 --- a/src/lib/messaging/channels/discord/template-resolver.ts +++ b/src/lib/messaging/channels/discord/template-resolver.ts @@ -42,8 +42,7 @@ export const resolveDiscordTemplateReference: BuiltInRenderTemplateResolver = ( ); case "discord.allowAllUsers": return resolvedRenderTemplateReference( - Object.keys(discordGuilds(context)).length > 0 && - discordAllowedUsers(context).length === 0 + Object.keys(discordGuilds(context)).length > 0 && discordAllowedUsers(context).length === 0 ? true : undefined, ); diff --git a/src/lib/messaging/channels/template-resolver-utils.ts b/src/lib/messaging/channels/template-resolver-utils.ts index a956ee1654..3bdbd0837f 100644 --- a/src/lib/messaging/channels/template-resolver-utils.ts +++ b/src/lib/messaging/channels/template-resolver-utils.ts @@ -49,7 +49,9 @@ export function nonEmptyString(value: unknown): string | undefined { } export function cleanString(value: unknown): string { - return String(value ?? "").replace(/\r/g, "").trim(); + return String(value ?? "") + .replace(/\r/g, "") + .trim(); } export function nonEmptyArray(values: readonly string[]): string[] | undefined { diff --git a/src/lib/messaging/compiler/engines/agent-render-engine.ts b/src/lib/messaging/compiler/engines/agent-render-engine.ts index 2d6971ff38..2a6197b336 100644 --- a/src/lib/messaging/compiler/engines/agent-render-engine.ts +++ b/src/lib/messaging/compiler/engines/agent-render-engine.ts @@ -1,10 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { - createBuiltInMessagingHookRegistry, - runMessagingHook, -} from "../../hooks"; +import { createBuiltInMessagingHookRegistry, runMessagingHook } from "../../hooks"; import { COMMON_STATIC_OUTPUTS_HOOK_HANDLER_ID } from "../../hooks/common/static-outputs"; import type { ChannelHookSpec, diff --git a/src/lib/messaging/compiler/workflow-planner.test.ts b/src/lib/messaging/compiler/workflow-planner.test.ts index f49a57cc3c..3c8ab1230f 100644 --- a/src/lib/messaging/compiler/workflow-planner.test.ts +++ b/src/lib/messaging/compiler/workflow-planner.test.ts @@ -290,7 +290,7 @@ describe("MessagingWorkflowPlanner", () => { createBuiltInChannelManifestRegistry(), hooks, createBuiltInRenderTemplateResolver(), - ).buildPlan({ + ).buildPlan({ sandboxName: "demo", agent: "openclaw", workflow: "onboard", diff --git a/src/lib/onboard/dockerfile-patch.ts b/src/lib/onboard/dockerfile-patch.ts index 8e85c50a9f..929e268184 100644 --- a/src/lib/onboard/dockerfile-patch.ts +++ b/src/lib/onboard/dockerfile-patch.ts @@ -11,7 +11,6 @@ const SANDBOX_BASE_IMAGE = "ghcr.io/nvidia/nemoclaw/sandbox-base"; const PROXY_HOST_RE = /^[A-Za-z0-9._-]+$/; const POSITIVE_INT_RE = /^[1-9][0-9]*$/; - export function encodeDockerJsonArg(value: unknown): string { return Buffer.from(JSON.stringify(value ?? {}), "utf8").toString("base64"); } @@ -227,7 +226,9 @@ export function patchStagedDockerfile( if (messagingPlan) { const messagingPlanArgPattern = /^ARG NEMOCLAW_MESSAGING_PLAN_B64=.*$/m; if (!messagingPlanArgPattern.test(dockerfile)) { - throw new Error("Dockerfile is missing ARG NEMOCLAW_MESSAGING_PLAN_B64; cannot apply messaging plan."); + throw new Error( + "Dockerfile is missing ARG NEMOCLAW_MESSAGING_PLAN_B64; cannot apply messaging plan.", + ); } dockerfile = dockerfile.replace( messagingPlanArgPattern, diff --git a/test/generate-hermes-config.test.ts b/test/generate-hermes-config.test.ts index 25f463fc13..3e0eafbbba 100644 --- a/test/generate-hermes-config.test.ts +++ b/test/generate-hermes-config.test.ts @@ -87,7 +87,14 @@ stderr: ${result.stderr}`, const applierResult = spawnSync( process.execPath, - ["--experimental-strip-types", APPLIER_PATH, "--agent", "hermes", "--phase", "post-agent-install"], + [ + "--experimental-strip-types", + APPLIER_PATH, + "--agent", + "hermes", + "--phase", + "post-agent-install", + ], { encoding: "utf-8", env, diff --git a/test/generate-openclaw-config.test.ts b/test/generate-openclaw-config.test.ts index fd7ba5a0c7..2508e10226 100644 --- a/test/generate-openclaw-config.test.ts +++ b/test/generate-openclaw-config.test.ts @@ -102,7 +102,14 @@ function withConfigEnv(envOverrides: Record, fn: () => T): T function runMessagingPostInstall(env: Record): void { const result = spawnSync( "node", - ["--experimental-strip-types", APPLIER_PATH, "--agent", "openclaw", "--phase", "post-agent-install"], + [ + "--experimental-strip-types", + APPLIER_PATH, + "--agent", + "openclaw", + "--phase", + "post-agent-install", + ], { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], diff --git a/test/messaging-build-applier.test.ts b/test/messaging-build-applier.test.ts index fe0b8be123..b49571da5c 100644 --- a/test/messaging-build-applier.test.ts +++ b/test/messaging-build-applier.test.ts @@ -56,12 +56,24 @@ function runDryRun(envOverrides: Record = {}) { }, "openclaw", ); - return spawnSync("node", ["--experimental-strip-types", SCRIPT_PATH, "--agent", "openclaw", "--phase", "agent-install", "--dry-run"], { - encoding: "utf-8", - stdio: ["pipe", "pipe", "pipe"], - env, - timeout: 10_000, - }); + return spawnSync( + "node", + [ + "--experimental-strip-types", + SCRIPT_PATH, + "--agent", + "openclaw", + "--phase", + "agent-install", + "--dry-run", + ], + { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + env, + timeout: 10_000, + }, + ); } function parseDryRun(envOverrides: Record = {}) { @@ -172,12 +184,23 @@ describe("messaging-build-applier.mts: agent-install", () => { }, "openclaw", ); - const result = spawnSync("node", ["--experimental-strip-types", SCRIPT_PATH, "--agent", "openclaw", "--phase", "agent-install"], { - encoding: "utf-8", - stdio: ["pipe", "pipe", "pipe"], - env: planEnv, - timeout: 10_000, - }); + const result = spawnSync( + "node", + [ + "--experimental-strip-types", + SCRIPT_PATH, + "--agent", + "openclaw", + "--phase", + "agent-install", + ], + { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + env: planEnv, + timeout: 10_000, + }, + ); expect(result.status, result.stderr).toBe(0); expect(fs.readFileSync(tracePath, "utf-8").trim().split("\n")).toEqual([ @@ -249,7 +272,14 @@ describe("messaging-build-applier.mts: agent-install", () => { }; const pluginResult = spawnSync( "node", - ["--experimental-strip-types", SCRIPT_PATH, "--agent", "openclaw", "--phase", "agent-install"], + [ + "--experimental-strip-types", + SCRIPT_PATH, + "--agent", + "openclaw", + "--phase", + "agent-install", + ], { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], @@ -261,7 +291,14 @@ describe("messaging-build-applier.mts: agent-install", () => { const postInstallResult = spawnSync( "node", - ["--experimental-strip-types", SCRIPT_PATH, "--agent", "openclaw", "--phase", "post-agent-install"], + [ + "--experimental-strip-types", + SCRIPT_PATH, + "--agent", + "openclaw", + "--phase", + "post-agent-install", + ], { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], @@ -309,19 +346,32 @@ describe("messaging-build-applier.mts: agent-install", () => { const fakeOpenclaw = path.join(tmp, "openclaw"); fs.writeFileSync(fakeOpenclaw, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); - const postInstallResult = spawnSync("node", ["--experimental-strip-types", SCRIPT_PATH, "--agent", "openclaw", "--phase", "post-agent-install"], { - encoding: "utf-8", - stdio: ["pipe", "pipe", "pipe"], - env: { - PATH: `${tmp}:${process.env.PATH || "/usr/bin:/bin"}`, - HOME: tmp, - NEMOCLAW_MESSAGING_PLAN_B64: generatorEnv.NEMOCLAW_MESSAGING_PLAN_B64, + const postInstallResult = spawnSync( + "node", + [ + "--experimental-strip-types", + SCRIPT_PATH, + "--agent", + "openclaw", + "--phase", + "post-agent-install", + ], + { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + env: { + PATH: `${tmp}:${process.env.PATH || "/usr/bin:/bin"}`, + HOME: tmp, + NEMOCLAW_MESSAGING_PLAN_B64: generatorEnv.NEMOCLAW_MESSAGING_PLAN_B64, + }, + timeout: 10_000, }, - timeout: 10_000, - }); + ); expect(postInstallResult.status, postInstallResult.stderr).toBe(0); - const config = JSON.parse(fs.readFileSync(path.join(tmp, ".openclaw", "openclaw.json"), "utf-8")); + const config = JSON.parse( + fs.readFileSync(path.join(tmp, ".openclaw", "openclaw.json"), "utf-8"), + ); expect(config.plugins?.installs?.["openclaw-weixin"]).toEqual({ source: "npm", spec: "@tencent-weixin/openclaw-weixin@2.4.3", @@ -334,7 +384,10 @@ describe("messaging-build-applier.mts: agent-install", () => { expect(config.channels?.wechat).toBeUndefined(); const account = JSON.parse( - fs.readFileSync(path.join(tmp, ".openclaw", "openclaw-weixin", "accounts", "primary.json"), "utf-8"), + fs.readFileSync( + path.join(tmp, ".openclaw", "openclaw-weixin", "accounts", "primary.json"), + "utf-8", + ), ); expect(account).toMatchObject({ token: "openshell:resolve:env:WECHAT_BOT_TOKEN", @@ -342,7 +395,9 @@ describe("messaging-build-applier.mts: agent-install", () => { userId: "u1", }); expect( - JSON.parse(fs.readFileSync(path.join(tmp, ".openclaw", "openclaw-weixin", "accounts.json"), "utf-8")), + JSON.parse( + fs.readFileSync(path.join(tmp, ".openclaw", "openclaw-weixin", "accounts.json"), "utf-8"), + ), ).toEqual(["primary"]); } finally { fs.rmSync(tmp, { recursive: true, force: true }); @@ -373,7 +428,14 @@ describe("messaging-build-applier.mts: agent-install", () => { try { const result = spawnSync( "node", - ["--experimental-strip-types", SCRIPT_PATH, "--agent", "openclaw", "--phase", "post-agent-install"], + [ + "--experimental-strip-types", + SCRIPT_PATH, + "--agent", + "openclaw", + "--phase", + "post-agent-install", + ], { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], @@ -427,7 +489,14 @@ describe("messaging-build-applier.mts: agent-install", () => { const result = spawnSync( "node", - ["--experimental-strip-types", SCRIPT_PATH, "--agent", "hermes", "--phase", "post-agent-install"], + [ + "--experimental-strip-types", + SCRIPT_PATH, + "--agent", + "hermes", + "--phase", + "post-agent-install", + ], { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], diff --git a/test/messaging-plan-test-helper.ts b/test/messaging-plan-test-helper.ts index 88060aedc1..05e5eb3de2 100644 --- a/test/messaging-plan-test-helper.ts +++ b/test/messaging-plan-test-helper.ts @@ -79,24 +79,20 @@ export function buildMessagingPlanB64( agent: MessagingPlanAgent, channels: readonly string[], ): string { - const result = spawnSync( - "npx", - ["tsx", "-e", PLAN_BUILDER], - { - cwd: REPO_ROOT, - encoding: "utf-8", - env: { - PATH: process.env.PATH || "/usr/bin:/bin", - ...env, - NEMOCLAW_TEST_MESSAGING_PLAN_AGENT: agent, - NEMOCLAW_TEST_MESSAGING_PLAN_CHANNELS_JSON: JSON.stringify([...new Set(channels)]), - NEMOCLAW_TEST_MESSAGING_CREDENTIAL_AVAILABILITY_JSON: JSON.stringify( - credentialAvailability(), - ), - }, - timeout: 10_000, + const result = spawnSync("npx", ["tsx", "-e", PLAN_BUILDER], { + cwd: REPO_ROOT, + encoding: "utf-8", + env: { + PATH: process.env.PATH || "/usr/bin:/bin", + ...env, + NEMOCLAW_TEST_MESSAGING_PLAN_AGENT: agent, + NEMOCLAW_TEST_MESSAGING_PLAN_CHANNELS_JSON: JSON.stringify([...new Set(channels)]), + NEMOCLAW_TEST_MESSAGING_CREDENTIAL_AVAILABILITY_JSON: JSON.stringify( + credentialAvailability(), + ), }, - ); + timeout: 10_000, + }); if (result.status !== 0) { throw new Error( `Failed to build ${agent} messaging test plan (exit ${result.status}):\nstdout: ${result.stdout}\nstderr: ${result.stderr}`, @@ -140,11 +136,7 @@ function legacyMessagingConfigEnv(env: Record): Record>( - env, - "NEMOCLAW_SLACK_CONFIG_B64", - {}, - ); + const slackConfig = decodeJsonEnv>(env, "NEMOCLAW_SLACK_CONFIG_B64", {}); assignCsv(next, "SLACK_ALLOWED_CHANNELS", slackConfig.allowedChannels); return next; @@ -160,9 +152,7 @@ function assignDiscordConfig( const users = uniqueStrings([ ...stringList(allowedUsers), - ...Object.values(guilds).flatMap((entry) => - isRecord(entry) ? stringList(entry.users) : [], - ), + ...Object.values(guilds).flatMap((entry) => (isRecord(entry) ? stringList(entry.users) : [])), ]); assignCsv(target, "DISCORD_USER_ID", users); @@ -176,11 +166,7 @@ function assignDiscordConfig( } } -function assignMentionMode( - target: Record, - key: string, - value: unknown, -): void { +function assignMentionMode(target: Record, key: string, value: unknown): void { if (typeof value === "boolean") { target[key] = value ? "1" : "0"; return; @@ -206,7 +192,12 @@ function stringList(value: unknown): string[] { return uniqueStrings(value.map((entry) => String(entry).trim()).filter(Boolean)); } if (typeof value === "string") { - return uniqueStrings(value.split(",").map((entry) => entry.trim()).filter(Boolean)); + return uniqueStrings( + value + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean), + ); } if (typeof value === "number" || typeof value === "boolean") { return [String(value)]; diff --git a/test/sandbox-build-context.test.ts b/test/sandbox-build-context.test.ts index 22ef37dcf2..61fe374596 100644 --- a/test/sandbox-build-context.test.ts +++ b/test/sandbox-build-context.test.ts @@ -76,8 +76,12 @@ describe("sandbox build context staging", () => { writeFixture(path.join("scripts", "lib", "sandbox-init.sh")); writeFixture(path.join("scripts", "lib", "openclaw_device_approval_policy.py")); writeFixture(path.join("scripts", "lib", "clean_runtime_shell_env_shim.py")); - writeFixture(path.join("src", "lib", "messaging", "applier", "build", "messaging-build-applier.mts")); - writeFixture(path.join("src", "lib", "messaging", "channels", "fixture", "hooks", "example.ts")); + writeFixture( + path.join("src", "lib", "messaging", "applier", "build", "messaging-build-applier.mts"), + ); + writeFixture( + path.join("src", "lib", "messaging", "channels", "fixture", "hooks", "example.ts"), + ); writeFixture(path.join("scripts", "patch-openclaw-tool-catalog.js")); writeFixture(path.join("scripts", "patch-openclaw-chat-send.js")); writeFixture(path.join("scripts", "patch-openclaw-slack-deny-feedback.mts")); @@ -260,7 +264,9 @@ describe("sandbox build context staging", () => { ).toBe(true); expect(fs.existsSync(path.join(buildCtx, "scripts", "nemoclaw-start.sh"))).toBe(true); expect(fs.existsSync(path.join(buildCtx, "scripts", "codex-acp-wrapper.sh"))).toBe(true); - expect(fs.existsSync(path.join(buildCtx, "scripts", "generate-openclaw-config.mts"))).toBe(true); + expect(fs.existsSync(path.join(buildCtx, "scripts", "generate-openclaw-config.mts"))).toBe( + true, + ); expect( fs.existsSync( path.join( @@ -276,15 +282,7 @@ describe("sandbox build context staging", () => { ).toBe(true); expect( fs.existsSync( - path.join( - buildCtx, - "src", - "lib", - "messaging", - "hooks", - "common", - "static-outputs.ts", - ), + path.join(buildCtx, "src", "lib", "messaging", "hooks", "common", "static-outputs.ts"), ), ).toBe(true); expect( diff --git a/test/sandbox-provisioning.test.ts b/test/sandbox-provisioning.test.ts index c965e315fa..bdf7c9891a 100644 --- a/test/sandbox-provisioning.test.ts +++ b/test/sandbox-provisioning.test.ts @@ -841,8 +841,23 @@ describe("sandbox provisioning: copied OpenClaw helper permissions (#2861)", () const localSrc = path.join(tmp, "src"); const localScripts = path.join(tmp, "scripts"); const generatorPath = path.join(localScripts, "generate-openclaw-config.mts"); - const applierPath = path.join(localSrc, "lib", "messaging", "applier", "build", "messaging-build-applier.mts"); - const messagingHookPath = path.join(localSrc, "lib", "messaging", "channels", "fixture", "hooks", "example.ts"); + const applierPath = path.join( + localSrc, + "lib", + "messaging", + "applier", + "build", + "messaging-build-applier.mts", + ); + const messagingHookPath = path.join( + localSrc, + "lib", + "messaging", + "channels", + "fixture", + "hooks", + "example.ts", + ); const pluginDir = path.join(localShare, "openclaw-plugins", "kimi-inference-compat"); const pluginFile = path.join(pluginDir, "index.js"); const nestedPluginDir = path.join(pluginDir, "lib"); From fa66236cc61e9b577a8b37f4f95dbaa68cd90c89 Mon Sep 17 00:00:00 2001 From: San Dang Date: Wed, 10 Jun 2026 10:23:59 +0530 Subject: [PATCH 16/23] test: sync openclaw config size budget --- ci/test-file-size-budget.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ci/test-file-size-budget.json b/ci/test-file-size-budget.json index 01b7768956..5aba0211e3 100644 --- a/ci/test-file-size-budget.json +++ b/ci/test-file-size-budget.json @@ -6,7 +6,7 @@ "src/lib/inference/nim.test.ts": 2068, "src/lib/onboard/preflight.test.ts": 1905, "test/channels-add-preset.test.ts": 1872, - "test/generate-openclaw-config.test.ts": 1985, + "test/generate-openclaw-config.test.ts": 1992, "test/install-preflight.test.ts": 4396, "test/nemoclaw-start.test.ts": 5289, "test/onboard-messaging.test.ts": 2094, From 1f2f26305c10019fe1f41808d6208c18b6bcd906 Mon Sep 17 00:00:00 2001 From: San Dang Date: Wed, 10 Jun 2026 10:46:36 +0530 Subject: [PATCH 17/23] fix(messaging): reapply manifest after openclaw doctor --- .../applier/build/messaging-build-applier.mts | 8 +- test/messaging-build-applier.test.ts | 90 +++++++++++++++++++ 2 files changed, 95 insertions(+), 3 deletions(-) diff --git a/src/lib/messaging/applier/build/messaging-build-applier.mts b/src/lib/messaging/applier/build/messaging-build-applier.mts index 17387322fb..b76dd17392 100755 --- a/src/lib/messaging/applier/build/messaging-build-applier.mts +++ b/src/lib/messaging/applier/build/messaging-build-applier.mts @@ -930,14 +930,16 @@ export function applyMessagingBuildPhase( installMessagingPackages(plan, env); return []; } - const appliedTargets = uniqueStrings([ + const applyPostAgentInstallOutputs = (): readonly string[] => [ ...applyMessagingAgentRenderToLocalFiles(plan), ...applyPostAgentInstallBuildFilesToLocalFiles(plan), - ]); + ]; + const appliedTargets = applyPostAgentInstallOutputs(); if (plan?.agent === "openclaw") { runOpenClawMessagingDoctor(plan, env); + return uniqueStrings([...appliedTargets, ...applyPostAgentInstallOutputs()]); } - return appliedTargets; + return uniqueStrings(appliedTargets); } export function installMessagingPackages(plan: MessagingBuildPlan | null, env: Env): void { diff --git a/test/messaging-build-applier.test.ts b/test/messaging-build-applier.test.ts index b49571da5c..234c4ade38 100644 --- a/test/messaging-build-applier.test.ts +++ b/test/messaging-build-applier.test.ts @@ -317,6 +317,96 @@ describe("messaging-build-applier.mts: agent-install", () => { } }); + it("reapplies OpenClaw messaging render after doctor rewrites config", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-doctor-rewrite-")); + const tracePath = path.join(tmp, "openclaw.trace"); + const fakeOpenclaw = path.join(tmp, "openclaw"); + const channels = channelsB64(["discord", "slack", "wechat"]); + const wechatConfig = Buffer.from( + JSON.stringify({ accountId: "primary", baseUrl: "https://example", userId: "u1" }), + ).toString("base64"); + + fs.writeFileSync( + fakeOpenclaw, + [ + "#!/usr/bin/env node", + 'const fs = require("fs");', + 'const path = require("path");', + "const args = process.argv.slice(2);", + 'fs.appendFileSync(process.env.OPENCLAW_TRACE, args.join("|") + String.fromCharCode(10));', + 'if (args[0] !== "doctor" || args[1] !== "--fix" || args[2] !== "--non-interactive") process.exit(46);', + 'const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json");', + 'const config = JSON.parse(fs.readFileSync(configPath, "utf8"));', + "if (config.channels?.discord?.enabled !== true) process.exit(40);", + "if (config.plugins?.entries?.discord?.enabled !== true) process.exit(41);", + "if (config.plugins?.entries?.slack?.enabled !== true) process.exit(42);", + 'if (config.channels?.["openclaw-weixin"]?.accounts?.primary?.enabled !== true) process.exit(43);', + "fs.writeFileSync(configPath, JSON.stringify({ channels: { defaults: {} }, plugins: { entries: {} } }, null, 2) + String.fromCharCode(10));", + "process.exit(0);", + "", + ].join("\n"), + { mode: 0o755 }, + ); + + try { + const generatorEnv = withLegacyMessagingPlanEnv( + { + PATH: `${tmp}:${process.env.PATH || "/usr/bin:/bin"}`, + HOME: tmp, + ...BASE_GENERATOR_ENV, + NEMOCLAW_MESSAGING_CHANNELS_B64: channels, + NEMOCLAW_WECHAT_CONFIG_B64: wechatConfig, + NEMOCLAW_OPENCLAW_MANAGED_PROXY: "0", + }, + "openclaw", + ); + const generatorResult = spawnSync("node", ["--experimental-strip-types", GENERATOR_PATH], { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + env: generatorEnv, + timeout: 10_000, + }); + expect(generatorResult.status, generatorResult.stderr).toBe(0); + + const postInstallResult = spawnSync( + "node", + [ + "--experimental-strip-types", + SCRIPT_PATH, + "--agent", + "openclaw", + "--phase", + "post-agent-install", + ], + { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + env: { + PATH: `${tmp}:${process.env.PATH || "/usr/bin:/bin"}`, + HOME: tmp, + OPENCLAW_TRACE: tracePath, + NEMOCLAW_MESSAGING_PLAN_B64: generatorEnv.NEMOCLAW_MESSAGING_PLAN_B64, + }, + timeout: 10_000, + }, + ); + expect(postInstallResult.status, postInstallResult.stderr).toBe(0); + expect(fs.readFileSync(tracePath, "utf-8").trim()).toBe("doctor|--fix|--non-interactive"); + + const config = JSON.parse( + fs.readFileSync(path.join(tmp, ".openclaw", "openclaw.json"), "utf-8"), + ); + expect(config.channels?.discord?.enabled).toBe(true); + expect(config.plugins?.entries?.discord).toEqual({ enabled: true }); + expect(config.channels?.slack?.enabled).toBe(true); + expect(config.plugins?.entries?.slack).toEqual({ enabled: true }); + expect(config.channels?.["openclaw-weixin"]?.accounts?.primary).toEqual({ enabled: true }); + expect(config.channels?.wechat).toBeUndefined(); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + it("applies post-agent-install WeChat build files from the compiled messaging plan", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-post-agent-install-")); const channels = channelsB64(["wechat"]); From acd6becc0a70def4d5138bc027221cfeebd27bcf Mon Sep 17 00:00:00 2001 From: San Dang Date: Wed, 10 Jun 2026 11:23:34 +0530 Subject: [PATCH 18/23] fix(messaging): preserve runtime placeholders on reapply --- src/lib/messaging/applier/agent-config.ts | 96 +++++++++++++++- .../applier/build/messaging-build-applier.mts | 107 ++++++++++++++++-- .../messaging/applier/setup-applier.test.ts | 40 +++++++ test/messaging-build-applier.test.ts | 21 ++-- 4 files changed, 244 insertions(+), 20 deletions(-) diff --git a/src/lib/messaging/applier/agent-config.ts b/src/lib/messaging/applier/agent-config.ts index 2204caba17..bd893b2747 100644 --- a/src/lib/messaging/applier/agent-config.ts +++ b/src/lib/messaging/applier/agent-config.ts @@ -6,6 +6,7 @@ import { posix as path } from "node:path"; import YAML from "yaml"; import { redact } from "../../security/redact"; +import type { MessagingHookOutputMap } from "../hooks"; import type { ChannelHookPhase, MessagingAgentId, @@ -16,13 +17,12 @@ import type { SandboxMessagingJsonRenderPlan, SandboxMessagingPlan, } from "../manifest"; -import type { MessagingHookOutputMap } from "../hooks"; +import { enabledPlanChannels, filterEnabledPlanEntries } from "./plan-filter"; import type { MessagingHookApplyRequest, MessagingHookApplyRunner, MessagingOpenShellRunner, } from "./types"; -import { enabledPlanChannels, filterEnabledPlanEntries } from "./plan-filter"; const AGENT_CONFIG_HOOK_PHASES = new Set(["apply", "post-agent-install"]); @@ -74,7 +74,7 @@ export async function applyAgentConfigAtOpenShell( const existing = readSandboxFile(plan.sandboxName, resolvedTarget, options.runOpenshell); const contents = kind === "json-fragment" - ? applyJsonFragments(existing, render.filter(isJsonRender), resolvedTarget) + ? applyJsonFragments(plan, existing, render.filter(isJsonRender), resolvedTarget) : applyEnvLines(existing, render.filter(isEnvLinesRender)); writeSandboxFile(plan.sandboxName, resolvedTarget, contents, options.runOpenshell); appliedTargets.push(resolvedTarget); @@ -198,16 +198,22 @@ function isEnvLinesRender( } function applyJsonFragments( + plan: SandboxMessagingPlan, existing: string | undefined, render: readonly SandboxMessagingJsonRenderPlan[], target: string, ): string { const format = target.endsWith(".yaml") || target.endsWith(".yml") ? "yaml" : "json"; const root = parseStructuredConfig(existing, target, format); + const rules = credentialPlaceholderRules(plan); for (const entry of render) { - setJsonPath(root, entry.path, entry.value); + setJsonPath( + root, + entry.path, + preserveCredentialPlaceholders(entry.value, getJsonPath(root, entry.path), rules), + ); } - return format === "yaml" ? YAML.stringify(root) : `${JSON.stringify(root, null, 2)}\n`; + return format === "yaml" ? YAML.stringify(root) : JSON.stringify(root, null, 2) + "\n"; } function parseStructuredConfig( @@ -223,6 +229,86 @@ function parseStructuredConfig( return parsed as Record; } +type CredentialPlaceholderRule = { + readonly envKey: string; + readonly placeholder: string; +}; + +function credentialPlaceholderRules(plan: SandboxMessagingPlan): CredentialPlaceholderRule[] { + const active = new Set(enabledPlanChannels(plan).map((channel) => channel.channelId)); + return plan.credentialBindings.flatMap((binding) => { + if (!active.has(binding.channelId)) return []; + if (typeof binding.providerEnvKey !== "string" || typeof binding.placeholder !== "string") { + return []; + } + return [{ envKey: binding.providerEnvKey, placeholder: binding.placeholder }]; + }); +} + +function preserveCredentialPlaceholders( + desired: MessagingSerializableValue, + existing: unknown, + rules: readonly CredentialPlaceholderRule[], +): MessagingSerializableValue { + if (typeof desired === "string") { + const rule = rules.find((candidate) => candidate.placeholder === desired); + if ( + rule && + typeof existing === "string" && + isProviderPlaceholderForEnvKey(existing, rule.envKey) + ) { + return existing; + } + return desired; + } + if (Array.isArray(desired)) { + return desired.map((entry, index) => + preserveCredentialPlaceholders( + entry, + Array.isArray(existing) ? existing[index] : undefined, + rules, + ), + ); + } + if (isObject(desired)) { + const existingObject = isObject(existing) ? existing : {}; + return Object.fromEntries( + Object.entries(desired).map(([key, value]) => [ + key, + preserveCredentialPlaceholders(value, existingObject[key], rules), + ]), + ); + } + return desired; +} + +function getJsonPath(root: Record, pathValue: string): unknown { + let cursor: unknown = root; + for (const segment of pathValue.split(".").filter(Boolean)) { + if (!isObject(cursor)) return undefined; + cursor = cursor[segment]; + } + return cursor; +} + +function isProviderPlaceholderForEnvKey(value: string, envKey: string): boolean { + const openShellPrefix = "openshell:resolve:env:"; + if (value.startsWith(openShellPrefix)) { + return placeholderSuffixMatchesEnvKey(value.slice(openShellPrefix.length), envKey); + } + const aliasPrefix = "-OPENSHELL-RESOLVE-ENV-"; + const aliasIndex = value.indexOf(aliasPrefix); + return aliasIndex > 0 + ? placeholderSuffixMatchesEnvKey(value.slice(aliasIndex + aliasPrefix.length), envKey) + : false; +} + +function placeholderSuffixMatchesEnvKey(suffix: string, envKey: string): boolean { + if (suffix === envKey) return true; + const revisionPrefix = suffix.match(/^v[0-9]+_/); + return revisionPrefix ? suffix.slice(revisionPrefix[0].length) === envKey : false; +} + function setJsonPath( root: Record, path: string, diff --git a/src/lib/messaging/applier/build/messaging-build-applier.mts b/src/lib/messaging/applier/build/messaging-build-applier.mts index b76dd17392..6684b59eb8 100755 --- a/src/lib/messaging/applier/build/messaging-build-applier.mts +++ b/src/lib/messaging/applier/build/messaging-build-applier.mts @@ -144,6 +144,7 @@ export function applyMessagingAgentRenderToObject( target: string, ): void { if (!plan) return; + const rules = credentialPlaceholderRules(plan); for (const render of enabledAgentRender(plan)) { if ( render.kind !== "json-fragment" || @@ -152,7 +153,12 @@ export function applyMessagingAgentRenderToObject( ) { continue; } - setJsonPath(config, render.path, requiredSerializableValue(render.value, "render value")); + const value = preserveCredentialPlaceholders( + requiredSerializableValue(render.value, "render value"), + getJsonPath(config, render.path), + rules, + ); + setJsonPath(config, render.path, value); } } @@ -194,7 +200,7 @@ export function applyMessagingAgentRenderToLocalFiles( throw new MessagingBuildApplierError(`Cannot apply mixed messaging render kinds to ${target}.`); } if (kinds[0] === "json-fragment") { - appliedTargets.push(applyJsonRenderEntriesToLocalFile(plan.agent, target, renderEntries, options)); + appliedTargets.push(applyJsonRenderEntriesToLocalFile(plan, target, renderEntries, options)); } else { appliedTargets.push(applyEnvRenderEntriesToLocalFile(plan.agent, target, renderEntries, options)); } @@ -300,17 +306,17 @@ export function applyPostAgentInstallBuildFilesToLocalFiles( } function applyJsonRenderEntriesToLocalFile( - agent: MessagingAgentId, + plan: MessagingBuildPlan, target: string, renderEntries: readonly MessagingRenderEntry[], options: { readonly homeDir?: string }, ): string { - const targetPath = resolveAgentRenderTarget(agent, target, options); + const targetPath = resolveAgentRenderTarget(plan.agent, target, options); const config = targetPath.endsWith(".yaml") ? parseGeneratedYamlObject(readTextIfExists(targetPath), targetPath) : parseJsonObject(readTextIfExists(targetPath), targetPath); - applyMessagingRenderEntriesToObject(config, renderEntries, target); - if (agent === "hermes" && target === "~/.hermes/config.yaml") { + applyMessagingRenderEntriesToObject(config, renderEntries, target, plan); + if (plan.agent === "hermes" && target === "~/.hermes/config.yaml") { finalizeHermesRenderedPlatformToolsets(config); } mkdirSync(dirname(targetPath), { recursive: true }); @@ -348,12 +354,19 @@ function applyMessagingRenderEntriesToObject( config: JsonObject, renderEntries: readonly MessagingRenderEntry[], target: string, + plan: MessagingBuildPlan, ): void { + const rules = credentialPlaceholderRules(plan); for (const render of renderEntries) { if (render.kind !== "json-fragment" || typeof render.path !== "string") { throw new MessagingBuildApplierError(`Messaging render for ${target} must be a JSON fragment with a path.`); } - setJsonPath(config, render.path, requiredSerializableValue(render.value, "render value")); + const value = preserveCredentialPlaceholders( + requiredSerializableValue(render.value, "render value"), + getJsonPath(config, render.path), + rules, + ); + setJsonPath(config, render.path, value); } } @@ -618,6 +631,86 @@ function runCommand(args: readonly string[], env: Env): void { } } +type CredentialPlaceholderRule = { + readonly envKey: string; + readonly placeholder: string; +}; + +function credentialPlaceholderRules( + plan: MessagingBuildPlan | null | undefined, +): CredentialPlaceholderRule[] { + if (!plan) return []; + const active = new Set(activeChannels(plan)); + return plan.credentialBindings.flatMap((binding) => { + if (!active.has(binding.channelId)) return []; + if (typeof binding.providerEnvKey !== "string" || typeof binding.placeholder !== "string") { + return []; + } + return [{ envKey: binding.providerEnvKey, placeholder: binding.placeholder }]; + }); +} + +function preserveCredentialPlaceholders( + desired: MessagingSerializableValue, + existing: unknown, + rules: readonly CredentialPlaceholderRule[], +): MessagingSerializableValue { + if (typeof desired === "string") { + const rule = rules.find((candidate) => candidate.placeholder === desired); + if ( + rule && + typeof existing === "string" && + isProviderPlaceholderForEnvKey(existing, rule.envKey) + ) { + return existing; + } + return desired; + } + if (Array.isArray(desired)) { + return desired.map((entry, index) => + preserveCredentialPlaceholders( + entry, + Array.isArray(existing) ? existing[index] : undefined, + rules, + ), + ); + } + if (isObject(desired)) { + const existingObject = isObject(existing) ? existing : {}; + return Object.fromEntries( + Object.entries(desired).map(([key, value]) => [ + key, + preserveCredentialPlaceholders(value, existingObject[key], rules), + ]), + ); + } + return desired; +} + +function getJsonPath(root: JsonObject, pathValue: string): unknown { + let cursor: unknown = root; + for (const segment of pathValue.split(".").filter(Boolean)) { + if (!isObject(cursor)) return undefined; + cursor = cursor[segment]; + } + return cursor; +} + +function isProviderPlaceholderForEnvKey(value: string, envKey: string): boolean { + const openShellPrefix = "openshell:resolve:env:"; + if (value.startsWith(openShellPrefix)) { + return placeholderSuffixMatchesEnvKey(value.slice(openShellPrefix.length), envKey); + } + const aliasMatch = value.match(/^[A-Za-z0-9]+-OPENSHELL-RESOLVE-ENV-(.+)$/); + return aliasMatch ? placeholderSuffixMatchesEnvKey(aliasMatch[1] as string, envKey) : false; +} + +function placeholderSuffixMatchesEnvKey(suffix: string, envKey: string): boolean { + if (suffix === envKey) return true; + const revisionMatch = suffix.match(/^v[0-9]+_(.+)$/); + return revisionMatch?.[1] === envKey; +} + function setJsonPath(root: JsonObject, pathValue: string, value: MessagingSerializableValue): void { const segments = pathValue.split(".").filter(Boolean); if (segments.length === 0) { diff --git a/src/lib/messaging/applier/setup-applier.test.ts b/src/lib/messaging/applier/setup-applier.test.ts index 4c6461128f..f65fca0a31 100644 --- a/src/lib/messaging/applier/setup-applier.test.ts +++ b/src/lib/messaging/applier/setup-applier.test.ts @@ -353,6 +353,46 @@ describe("MessagingSetupApplier", () => { expect(result.unresolvedTemplateRefs).toEqual([]); }); + it("preserves runtime-scoped credential placeholders when reapplying render plans", async () => { + const plan = await buildOnboardPlan({ TELEGRAM_BOT_TOKEN: "123456:telegram-token" }, [ + "telegram", + ]); + const scoped = "openshell:resolve:env:v42_TELEGRAM_BOT_TOKEN"; + const files: Record = { + "/sandbox/.openclaw/openclaw.json": JSON.stringify({ + channels: { + telegram: { + accounts: { + default: { + botToken: scoped, + }, + }, + }, + }, + }), + }; + const runOpenshell: MessagingOpenShellRunner = (args, options) => { + const target = String(args.at(-1)); + if (args.includes("cat") && !options?.input) { + return { status: files[target] === undefined ? 1 : 0, stdout: files[target] ?? "" }; + } + if (options?.input !== undefined) { + files[target] = options.input; + return { status: 0 }; + } + return { status: 1 }; + }; + + await MessagingSetupApplier.applyAgentConfigAtOpenShell(plan, { runOpenshell }); + + const openclawConfig = JSON.parse(files["/sandbox/.openclaw/openclaw.json"] ?? "{}"); + expect(openclawConfig.channels.telegram.accounts.default).toMatchObject({ + botToken: scoped, + enabled: true, + groupPolicy: "open", + }); + }); + it("excludes disabled channels at the applier boundary", async () => { const plan = await withEnv( { diff --git a/test/messaging-build-applier.test.ts b/test/messaging-build-applier.test.ts index 234c4ade38..e5d6ef7f1b 100644 --- a/test/messaging-build-applier.test.ts +++ b/test/messaging-build-applier.test.ts @@ -4,11 +4,11 @@ // // Functional tests for src/lib/messaging/applier/build/messaging-build-applier.mts. -import { describe, it, expect } from "vitest"; +import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { spawnSync } from "node:child_process"; +import { describe, expect, it } from "vitest"; import { withLegacyMessagingPlanEnv } from "./messaging-plan-test-helper"; const SCRIPT_PATH = path.join( @@ -321,7 +321,7 @@ describe("messaging-build-applier.mts: agent-install", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-doctor-rewrite-")); const tracePath = path.join(tmp, "openclaw.trace"); const fakeOpenclaw = path.join(tmp, "openclaw"); - const channels = channelsB64(["discord", "slack", "wechat"]); + const channels = channelsB64(["telegram", "discord", "slack", "wechat"]); const wechatConfig = Buffer.from( JSON.stringify({ accountId: "primary", baseUrl: "https://example", userId: "u1" }), ).toString("base64"); @@ -337,11 +337,12 @@ describe("messaging-build-applier.mts: agent-install", () => { 'if (args[0] !== "doctor" || args[1] !== "--fix" || args[2] !== "--non-interactive") process.exit(46);', 'const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json");', 'const config = JSON.parse(fs.readFileSync(configPath, "utf8"));', - "if (config.channels?.discord?.enabled !== true) process.exit(40);", - "if (config.plugins?.entries?.discord?.enabled !== true) process.exit(41);", - "if (config.plugins?.entries?.slack?.enabled !== true) process.exit(42);", - 'if (config.channels?.["openclaw-weixin"]?.accounts?.primary?.enabled !== true) process.exit(43);', - "fs.writeFileSync(configPath, JSON.stringify({ channels: { defaults: {} }, plugins: { entries: {} } }, null, 2) + String.fromCharCode(10));", + 'if (config.channels?.telegram?.accounts?.default?.botToken !== "openshell:resolve:env:TELEGRAM_BOT_TOKEN") process.exit(40);', + "if (config.channels?.discord?.enabled !== true) process.exit(41);", + "if (config.plugins?.entries?.discord?.enabled !== true) process.exit(42);", + "if (config.plugins?.entries?.slack?.enabled !== true) process.exit(43);", + 'if (config.channels?.["openclaw-weixin"]?.accounts?.primary?.enabled !== true) process.exit(44);', + 'fs.writeFileSync(configPath, JSON.stringify({ channels: { telegram: { accounts: { default: { botToken: "openshell:resolve:env:v42_TELEGRAM_BOT_TOKEN" } } } }, plugins: { entries: {} } }, null, 2) + String.fromCharCode(10));', "process.exit(0);", "", ].join("\n"), @@ -396,6 +397,10 @@ describe("messaging-build-applier.mts: agent-install", () => { const config = JSON.parse( fs.readFileSync(path.join(tmp, ".openclaw", "openclaw.json"), "utf-8"), ); + expect(config.channels?.telegram?.accounts?.default).toMatchObject({ + botToken: "openshell:resolve:env:v42_TELEGRAM_BOT_TOKEN", + enabled: true, + }); expect(config.channels?.discord?.enabled).toBe(true); expect(config.plugins?.entries?.discord).toEqual({ enabled: true }); expect(config.channels?.slack?.enabled).toBe(true); From e5ef86c2d9abfd688e8b1ea69bbdf01b20ed059e Mon Sep 17 00:00:00 2001 From: San Dang Date: Wed, 10 Jun 2026 13:08:22 +0530 Subject: [PATCH 19/23] fix(messaging): preserve hermes legacy rebuild config --- src/lib/actions/sandbox/rebuild.ts | 2 + src/lib/messaging-channel-config.test.ts | 21 ++++++ src/lib/messaging-channel-config.ts | 15 +++- .../compiler/workflow-planner.test.ts | 71 ++++++++++++++++++- .../messaging/compiler/workflow-planner.ts | 63 +++++++++++++--- test/e2e/test-hermes-discord-e2e.sh | 12 ++-- 6 files changed, 166 insertions(+), 18 deletions(-) diff --git a/src/lib/actions/sandbox/rebuild.ts b/src/lib/actions/sandbox/rebuild.ts index 966c127d5e..e53c1dc426 100644 --- a/src/lib/actions/sandbox/rebuild.ts +++ b/src/lib/actions/sandbox/rebuild.ts @@ -55,6 +55,7 @@ import { MessagingWorkflowPlanner, toMessagingAgentId, } from "../../messaging"; +import { hydrateMessagingChannelConfig } from "../../messaging-channel-config"; import { pruneDisabledMessagingPolicyPresets } from "../../onboard/messaging-policy-presets"; import { captureSandboxListWithGatewayRecovery, @@ -199,6 +200,7 @@ async function stageMessagingManifestPlanForRebuild( ): Promise { const agent = loadAgent(rebuildAgent || "openclaw"); const planner = new MessagingWorkflowPlanner(createBuiltInChannelManifestRegistry()); + hydrateMessagingChannelConfig(sandboxEntry.messagingChannelConfig); const plan = await planner.buildRebuildPlanFromSandboxEntry({ sandboxName, agent: toMessagingAgentId(agent), diff --git a/src/lib/messaging-channel-config.test.ts b/src/lib/messaging-channel-config.test.ts index 9f34712b75..50a98af1fc 100644 --- a/src/lib/messaging-channel-config.test.ts +++ b/src/lib/messaging-channel-config.test.ts @@ -58,6 +58,27 @@ describe("messaging channel config", () => { ).toBeNull(); }); + it("normalizes Discord compatibility aliases to canonical channel config", () => { + const env: NodeJS.ProcessEnv = { + DISCORD_SERVER_IDS: "1491590992753590594", + DISCORD_ALLOWED_IDS: "1005536447329222676", + DISCORD_REQUIRE_MENTION: "0", + }; + + expect(readMessagingChannelConfigFromEnv(env)).toEqual({ + DISCORD_SERVER_ID: "1491590992753590594", + DISCORD_USER_ID: "1005536447329222676", + DISCORD_REQUIRE_MENTION: "0", + }); + expect(hydrateMessagingChannelConfig(null, env)).toEqual({ + DISCORD_SERVER_ID: "1491590992753590594", + DISCORD_USER_ID: "1005536447329222676", + DISCORD_REQUIRE_MENTION: "0", + }); + expect(env.DISCORD_SERVER_ID).toBe("1491590992753590594"); + expect(env.DISCORD_USER_ID).toBe("1005536447329222676"); + }); + it("hydrates missing env values but preserves explicit env overrides", () => { const env: NodeJS.ProcessEnv = { TELEGRAM_ALLOWED_IDS: "env-user", diff --git a/src/lib/messaging-channel-config.ts b/src/lib/messaging-channel-config.ts index d6aee11e74..3c221a0ef6 100644 --- a/src/lib/messaging-channel-config.ts +++ b/src/lib/messaging-channel-config.ts @@ -12,6 +12,17 @@ const requireMentionKeys = new Set( .filter((key): key is string => typeof key === "string" && key.length > 0), ); +const configKeyAliases: Readonly> = { + DISCORD_SERVER_ID: ["DISCORD_SERVER_IDS"], + DISCORD_USER_ID: ["DISCORD_ALLOWED_IDS"], +}; + +const aliasToCanonical = new Map( + Object.entries(configKeyAliases).flatMap(([canonical, aliases]) => + aliases.map((alias) => [alias, canonical] as const), + ), +); + export const MESSAGING_CHANNEL_CONFIG_ENV_KEYS: readonly string[] = [ ...new Set( channels.flatMap((channel) => @@ -40,13 +51,13 @@ function normalizeValue(value: unknown): string | null { } export function getCanonicalMessagingChannelConfigKey(key: string): string | null { - return knownConfigKeys.has(key) ? key : null; + return knownConfigKeys.has(key) ? key : (aliasToCanonical.get(key) ?? null); } export function getMessagingChannelConfigEnvKeys(key: string): readonly string[] { const canonical = getCanonicalMessagingChannelConfigKey(key); if (!canonical) return []; - return [canonical]; + return [canonical, ...(configKeyAliases[canonical] ?? [])]; } export function normalizeMessagingChannelConfigValue(key: string, value: unknown): string | null { diff --git a/src/lib/messaging/compiler/workflow-planner.test.ts b/src/lib/messaging/compiler/workflow-planner.test.ts index 3c8ab1230f..f987a5224b 100644 --- a/src/lib/messaging/compiler/workflow-planner.test.ts +++ b/src/lib/messaging/compiler/workflow-planner.test.ts @@ -677,13 +677,80 @@ describe("MessagingWorkflowPlanner", () => { ); }); - it("does not compile a rebuild plan when the sandbox entry has no stored plan", async () => { + it("rebuilds legacy registry entries from messaging channels and provider credential hashes", async () => { + await withEnv( + { + DISCORD_BOT_TOKEN: undefined, + DISCORD_SERVER_ID: "1491590992753590594", + DISCORD_REQUIRE_MENTION: "0", + DISCORD_USER_ID: "1005536447329222676", + }, + async () => { + const rebuilt = await planner().buildRebuildPlanFromSandboxEntry({ + sandboxName: "demo", + agent: "hermes", + sandboxEntry: { + name: "demo", + messagingChannels: ["discord"], + providerCredentialHashes: { + DISCORD_BOT_TOKEN: "sha256-test-discord-token", + }, + }, + }); + + const discordChannel = rebuilt?.channels.find((channel) => channel.channelId === "discord"); + const discordEnv = rebuilt?.agentRender.find( + (render) => render.channelId === "discord" && render.kind === "env-lines", + ); + const discordConfig = rebuilt?.agentRender.find( + (render) => + render.channelId === "discord" && + render.kind === "json-fragment" && + render.path === "discord", + ); + + expect(rebuilt?.workflow).toBe("rebuild"); + expect(discordChannel).toMatchObject({ + active: true, + disabled: false, + configured: true, + }); + expect( + rebuilt?.credentialBindings.find( + (binding) => + binding.channelId === "discord" && binding.providerEnvKey === "DISCORD_BOT_TOKEN", + ), + ).toMatchObject({ credentialAvailable: true }); + expect(discordEnv).toMatchObject({ + lines: [ + "DISCORD_BOT_TOKEN=openshell:resolve:env:DISCORD_BOT_TOKEN", + "NEMOCLAW_DISCORD_GUILD_IDS=1491590992753590594", + "DISCORD_ALLOWED_USERS=1005536447329222676", + ], + }); + expect(discordConfig).toMatchObject({ + value: { + require_mention: false, + }, + }); + expect( + rebuilt?.agentRender.find( + (render) => + render.channelId === "discord" && + render.kind === "json-fragment" && + render.path === "platforms.discord", + ), + ).toMatchObject({ value: { enabled: true } }); + }, + ); + }); + + it("does not compile a rebuild plan when the sandbox entry has no stored plan or channels", async () => { const rebuilt = await planner().buildRebuildPlanFromSandboxEntry({ sandboxName: "demo", agent: "openclaw", sandboxEntry: { name: "demo", - messagingChannels: ["telegram"], }, }); diff --git a/src/lib/messaging/compiler/workflow-planner.ts b/src/lib/messaging/compiler/workflow-planner.ts index 93edb6655e..3957f5ebda 100644 --- a/src/lib/messaging/compiler/workflow-planner.ts +++ b/src/lib/messaging/compiler/workflow-planner.ts @@ -100,12 +100,33 @@ export class MessagingWorkflowPlanner { context: MessagingWorkflowPlannerSandboxRebuildContext, ): Promise { const existingPlan = readSandboxEntryPlan(context); - if (!existingPlan) return null; - return setPlanDisabledChannels( - existingPlan, - disabledChannelsFromSandboxEntry(context.sandboxEntry, existingPlan), - "rebuild", - ); + if (existingPlan) { + return setPlanDisabledChannels( + existingPlan, + disabledChannelsFromSandboxEntry(context.sandboxEntry, existingPlan), + "rebuild", + ); + } + + const configuredChannels = uniqueChannels(context.sandboxEntry?.messagingChannels); + if (configuredChannels.length === 0) return null; + + return this.buildPlan({ + sandboxName: context.sandboxName, + agent: context.agent, + workflow: "rebuild", + isInteractive: false, + configuredChannels, + disabledChannels: disabledChannelsFromSandboxEntry(context.sandboxEntry, null), + supportedChannelIds: context.supportedChannelIds, + credentialAvailability: mergeAvailability( + this.credentialAvailabilityFromProviderCredentialHashes( + context.sandboxEntry, + configuredChannels, + ), + context.credentialAvailability, + ), + }); } private assertSupportedChannels( @@ -165,9 +186,32 @@ export class MessagingWorkflowPlanner { ); if (!binding?.credentialAvailable) continue; availability[credential.sourceInput] = true; - availability[`${manifest.id}.${credential.sourceInput}`] = true; + availability[manifest.id + "." + credential.sourceInput] = true; + availability[credential.id] = true; + availability[manifest.id + "." + credential.id] = true; + availability[credential.providerEnvKey] = true; + } + } + return Object.keys(availability).length > 0 ? availability : undefined; + } + + private credentialAvailabilityFromProviderCredentialHashes( + sandboxEntry: MessagingWorkflowPlannerSandboxEntry | null | undefined, + channelIds: readonly MessagingChannelId[], + ): MessagingCompilerCredentialAvailability | undefined { + const hashes = sandboxEntry?.providerCredentialHashes; + if (!hashes) return undefined; + + const availability: Record = {}; + for (const channelId of channelIds) { + const manifest = this.registry.get(channelId); + if (!manifest) continue; + for (const credential of manifest.credentials) { + if (!hashes[credential.providerEnvKey]) continue; + availability[credential.sourceInput] = true; + availability[manifest.id + "." + credential.sourceInput] = true; availability[credential.id] = true; - availability[`${manifest.id}.${credential.id}`] = true; + availability[manifest.id + "." + credential.id] = true; availability[credential.providerEnvKey] = true; } } @@ -180,6 +224,7 @@ export interface MessagingWorkflowPlannerSandboxEntry { readonly agent?: string | null; readonly messagingChannels?: readonly MessagingChannelId[] | null; readonly disabledChannels?: readonly MessagingChannelId[] | null; + readonly providerCredentialHashes?: Readonly> | null; readonly messaging?: { readonly schemaVersion: 1; readonly plan: SandboxMessagingPlan; @@ -208,7 +253,7 @@ export interface MessagingWorkflowPlannerChannelMutationContext export type MessagingWorkflowPlannerSandboxRebuildContext = MessagingWorkflowPlannerSandboxContext; function uniqueChannels( - channelIds: readonly MessagingChannelId[] | undefined, + channelIds: readonly MessagingChannelId[] | null | undefined, ): MessagingChannelId[] { return [...new Set(channelIds ?? [])]; } diff --git a/test/e2e/test-hermes-discord-e2e.sh b/test/e2e/test-hermes-discord-e2e.sh index 6a11df602b..e565f9f2e3 100755 --- a/test/e2e/test-hermes-discord-e2e.sh +++ b/test/e2e/test-hermes-discord-e2e.sh @@ -365,10 +365,12 @@ else: platforms = cfg.get("platforms") if not isinstance(platforms, dict): errors.append("missing platforms") -elif "discord" in platforms: - errors.append("platforms.discord present") -elif not isinstance(platforms.get("api_server"), dict): - errors.append("platforms.api_server missing") +else: + discord_platform = platforms.get("discord") + if discord_platform != {"enabled": True}: + errors.append(f"platforms.discord={discord_platform!r} expected enabled true") + if not isinstance(platforms.get("api_server"), dict): + errors.append("platforms.api_server missing") if "DISCORD_BOT_TOKEN" in text: errors.append("config.yaml contains DISCORD_BOT_TOKEN") if errors: @@ -379,7 +381,7 @@ PY ) if [ "$config_probe" = "OK" ]; then - pass "config.yaml uses top-level discord and no platforms.discord" + pass "config.yaml uses top-level discord and platforms.discord" else fail "config.yaml schema check failed: ${config_probe:0:400}" fi From 515d1d188b2863deddcddac5669b74314b364632 Mon Sep 17 00:00:00 2001 From: San Dang Date: Wed, 10 Jun 2026 14:47:05 +0530 Subject: [PATCH 20/23] fix(messaging): harden plugin installs and env renders --- Dockerfile.base | 8 - docs/manage-sandboxes/messaging-channels.mdx | 2 +- scripts/generate-openclaw-config.mts | 1 - src/ext/wechat/qr.ts | 2 +- src/lib/messaging-channel-config.ts | 5 +- .../applier/build/messaging-build-applier.mts | 63 ++++++- .../messaging/applier/setup-applier.test.ts | 4 +- .../channels/template-resolver-utils.ts | 8 +- .../channels/wechat/hooks/ilink-login.ts | 14 +- .../wechat/hooks/implementations.test.ts | 76 +++++++- .../wechat/hooks/seed-openclaw-account.ts | 4 +- .../channels/wechat/ilink-base-url.ts | 41 +++++ src/lib/messaging/channels/wechat/manifest.ts | 20 +++ .../channels/wechat/template-resolver.ts | 6 + .../compiler/engines/agent-render-engine.ts | 19 ++ .../compiler/manifest-compiler.test.ts | 106 ++++++++++- .../messaging/compiler/manifest-compiler.ts | 5 +- .../compiler/workflow-planner.test.ts | 2 +- .../onboard/messaging-channel-setup.test.ts | 4 +- src/lib/state/sandbox.ts | 2 +- test/e2e/test-channels-stop-start.sh | 2 +- test/e2e/test-messaging-providers.sh | 4 +- test/generate-openclaw-config.test.ts | 20 +-- test/messaging-build-applier.test.ts | 169 +++++++++++++++++- 24 files changed, 529 insertions(+), 58 deletions(-) create mode 100644 src/lib/messaging/channels/wechat/ilink-base-url.ts diff --git a/Dockerfile.base b/Dockerfile.base index 335d7c5a80..17e3f9eb49 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -218,14 +218,6 @@ RUN --mount=type=bind,source=nemoclaw-blueprint/blueprint.yaml,target=/tmp/bluep npm install -g "openclaw@${OPENCLAW_VERSION}" \ && pip3 install --no-cache-dir --break-system-packages "pyyaml==6.0.3" -USER sandbox -WORKDIR /sandbox -# hadolint ignore=DL3059,DL4006 -RUN openclaw plugins install '@tencent-weixin/openclaw-weixin@2.4.3' --pin \ - && openclaw config set plugins.entries.openclaw-weixin.enabled true -# hadolint ignore=DL3002 -USER root -WORKDIR / # Baseline health check. The base image runs no service, so this only # verifies the Node.js runtime is functional. Child images that expose diff --git a/docs/manage-sandboxes/messaging-channels.mdx b/docs/manage-sandboxes/messaging-channels.mdx index 322291d1f0..ddb665a706 100644 --- a/docs/manage-sandboxes/messaging-channels.mdx +++ b/docs/manage-sandboxes/messaging-channels.mdx @@ -87,7 +87,7 @@ Channel messages still require an explicit bot mention. When a Slack channel `@mention` is denied by these allowlists, NemoClaw sends a denial notice back to the sender instead of dropping the message silently. During sandbox startup, NemoClaw normalizes OpenShell credential placeholders into the environment shape expected by the Slack runtime, so post-rebuild Slack starts use the gateway-managed tokens instead of literal placeholder strings. -WeChat (experimental) delivers messages over Tencent's iLink gateway through the upstream `@tencent-weixin/openclaw-weixin` plugin baked into the sandbox base image and the built-in Hermes iLink WeChat adapter. +WeChat (experimental) delivers messages over Tencent's iLink gateway through the upstream `@tencent-weixin/openclaw-weixin` plugin installed into WeChat-enabled OpenClaw sandbox images and the built-in Hermes iLink WeChat adapter. The supported mode in this release is **personal WeChat** (`bot_type=3`). WeChat Official Account and WeCom/Enterprise WeChat are not wired up. diff --git a/scripts/generate-openclaw-config.mts b/scripts/generate-openclaw-config.mts index 4709a288b3..59f8c86649 100755 --- a/scripts/generate-openclaw-config.mts +++ b/scripts/generate-openclaw-config.mts @@ -874,7 +874,6 @@ export function buildConfig(env: Env = process.env): JsonObject { acpx: { enabled: false }, bonjour: { enabled: false }, qqbot: { enabled: false }, - "openclaw-weixin": { enabled: true }, }; const bundledProviderPlugins: Record> = { "amazon-bedrock": new Set(["amazon-bedrock", "bedrock"]), diff --git a/src/ext/wechat/qr.ts b/src/ext/wechat/qr.ts index f7867f360c..093a704ae0 100644 --- a/src/ext/wechat/qr.ts +++ b/src/ext/wechat/qr.ts @@ -37,7 +37,7 @@ export const WECHAT_ILINK_APP_ID = "bot"; * Pinned in lockstep with the @tencent-weixin/openclaw-weixin version * installed in the sandbox image, so the iLink gateway sees the same * client version from both the host login and the in-sandbox plugin. - * Bump together with the version pinned in the Dockerfile. */ + * Bump together with WECHAT_PLUGIN_SPEC in the messaging WeChat hook. */ export const WECHAT_ILINK_CLIENT_VERSION = encodeIlinkClientVersion("2.4.3"); /** Client-side ceiling for a single status long-poll. 35s keeps us within diff --git a/src/lib/messaging-channel-config.ts b/src/lib/messaging-channel-config.ts index 3c221a0ef6..6225fb5bfe 100644 --- a/src/lib/messaging-channel-config.ts +++ b/src/lib/messaging-channel-config.ts @@ -46,7 +46,10 @@ export type MessagingChannelConfigEnvResolution = { function normalizeValue(value: unknown): string | null { if (typeof value !== "string") return null; - const normalized = value.replace(/[\r\n]/g, "").trim(); + if (/[\r\n]/.test(value)) { + throw new Error("Messaging channel config values must not contain line breaks."); + } + const normalized = value.trim(); return normalized || null; } diff --git a/src/lib/messaging/applier/build/messaging-build-applier.mts b/src/lib/messaging/applier/build/messaging-build-applier.mts index 6684b59eb8..755682cdbb 100755 --- a/src/lib/messaging/applier/build/messaging-build-applier.mts +++ b/src/lib/messaging/applier/build/messaging-build-applier.mts @@ -105,6 +105,16 @@ export type BuildCommandResult = { export class MessagingBuildApplierError extends Error {} +const OPENCLAW_VERSIONED_MESSAGING_PLUGIN_PACKAGES: Readonly> = { + discord: "@openclaw/discord", + slack: "@openclaw/slack", + whatsapp: "@openclaw/whatsapp", +}; + +const OPENCLAW_FIXED_MESSAGING_PLUGIN_INSTALL_SPECS: Readonly> = { + wechat: "npm:@tencent-weixin/openclaw-weixin@2.4.3", +}; + export function readMessagingBuildPlanFromEnv( env: Env, agent: MessagingAgentId, @@ -175,7 +185,7 @@ export function applyMessagingAgentRenderToEnvLines( `Messaging env render '${render.renderId ?? render.channelId}' is missing lines.`, ); } - mergeEnvLines(envLines, render.lines); + mergeEnvLines(envLines, readEnvRenderLines(render)); } } @@ -240,7 +250,9 @@ export function collectOpenClawMessagingPluginInstallSpecs( continue; } const install = readOpenClawPackageInstall(step.value, step.outputId); - specs.push(resolveOpenClawPackageSpec(install.spec, env)); + const resolvedSpec = resolveOpenClawPackageSpec(install.spec, env); + assertAllowedOpenClawPackageSpec(step.channelId, resolvedSpec, env); + specs.push(resolvedSpec); } return uniqueStrings(specs); } @@ -342,7 +354,7 @@ function applyEnvRenderEntriesToLocalFile( `Messaging env render '${render.renderId ?? render.channelId}' is missing lines.`, ); } - mergeEnvLines(envLines, render.lines); + mergeEnvLines(envLines, readEnvRenderLines(render)); } mkdirSync(dirname(targetPath), { recursive: true }); writeFileSync(targetPath, envLines.length > 0 ? `${envLines.join("\n")}\n` : ""); @@ -370,6 +382,22 @@ function applyMessagingRenderEntriesToObject( } } +function readEnvRenderLines(render: MessagingRenderEntry): readonly string[] { + if (!Array.isArray(render.lines)) { + throw new MessagingBuildApplierError( + "Messaging env render '" + (render.renderId ?? render.channelId) + "' is missing lines.", + ); + } + for (const line of render.lines) { + if (/[\r\n]/.test(line)) { + throw new MessagingBuildApplierError( + "Messaging env render '" + (render.renderId ?? render.channelId) + "' must not contain line breaks.", + ); + } + } + return render.lines; +} + function finalizeHermesRenderedPlatformToolsets(config: JsonObject): void { const platforms = config.platforms; const platformToolsets = config.platform_toolsets; @@ -617,6 +645,35 @@ function resolveOpenClawPackageSpec(spec: string, env: Env): string { return resolved; } +function assertAllowedOpenClawPackageSpec(channelId: string, resolvedSpec: string, env: Env): void { + const allowedSpecs = allowedOpenClawPackageSpecsForChannel(channelId, env); + if (!allowedSpecs.includes(resolvedSpec)) { + throw new MessagingBuildApplierError( + `Messaging package-install spec for ${channelId} is not allowed: ${resolvedSpec}`, + ); + } +} + +function allowedOpenClawPackageSpecsForChannel(channelId: string, env: Env): readonly string[] { + const versionedPackage = OPENCLAW_VERSIONED_MESSAGING_PLUGIN_PACKAGES[channelId]; + if (versionedPackage) { + return ["npm:" + versionedPackage + "@" + requiredOpenClawVersion(env)]; + } + + const fixedSpec = OPENCLAW_FIXED_MESSAGING_PLUGIN_INSTALL_SPECS[channelId]; + return fixedSpec ? [fixedSpec] : []; +} + +function requiredOpenClawVersion(env: Env): string { + const version = (env.OPENCLAW_VERSION || "").trim(); + if (!version) { + throw new MessagingBuildApplierError( + "OPENCLAW_VERSION is required when OpenClaw package install hooks are active", + ); + } + return version; +} + function runCommand(args: readonly string[], env: Env): void { console.log(`+ ${args.join(" ")}`); const result = spawnSync(args[0] as string, args.slice(1), { diff --git a/src/lib/messaging/applier/setup-applier.test.ts b/src/lib/messaging/applier/setup-applier.test.ts index f65fca0a31..bc862028a0 100644 --- a/src/lib/messaging/applier/setup-applier.test.ts +++ b/src/lib/messaging/applier/setup-applier.test.ts @@ -488,7 +488,7 @@ describe("MessagingSetupApplier", () => { { WECHAT_BOT_TOKEN: "wechat-token", WECHAT_ACCOUNT_ID: "wechat-account", - WECHAT_BASE_URL: "https://ilinkai.wechat.example", + WECHAT_BASE_URL: "https://ilinkai.wechat.com", WECHAT_USER_ID: "wechat-user", }, ["wechat"], @@ -551,7 +551,7 @@ describe("MessagingSetupApplier", () => { JSON.parse(files["/sandbox/.openclaw/openclaw-weixin/accounts/wechat-account.json"] ?? "{}"), ).toMatchObject({ token: "openshell:resolve:env:WECHAT_BOT_TOKEN", - baseUrl: "https://ilinkai.wechat.example", + baseUrl: "https://ilinkai.wechat.com", userId: "wechat-user", }); const openclawConfig = JSON.parse(files["/sandbox/.openclaw/openclaw.json"] ?? "{}"); diff --git a/src/lib/messaging/channels/template-resolver-utils.ts b/src/lib/messaging/channels/template-resolver-utils.ts index 3bdbd0837f..509e453924 100644 --- a/src/lib/messaging/channels/template-resolver-utils.ts +++ b/src/lib/messaging/channels/template-resolver-utils.ts @@ -49,9 +49,11 @@ export function nonEmptyString(value: unknown): string | undefined { } export function cleanString(value: unknown): string { - return String(value ?? "") - .replace(/\r/g, "") - .trim(); + const text = String(value ?? ""); + if (/[\r\n]/.test(text)) { + throw new Error("Messaging template values must not contain line breaks."); + } + return text.trim(); } export function nonEmptyArray(values: readonly string[]): string[] | undefined { diff --git a/src/lib/messaging/channels/wechat/hooks/ilink-login.ts b/src/lib/messaging/channels/wechat/hooks/ilink-login.ts index 73e15ed3a4..e648baf436 100644 --- a/src/lib/messaging/channels/wechat/hooks/ilink-login.ts +++ b/src/lib/messaging/channels/wechat/hooks/ilink-login.ts @@ -7,6 +7,7 @@ import type { MessagingHookRegistration, } from "../../../hooks/types"; import type { MessagingSerializableValue } from "../../../manifest"; +import { normalizeWechatIlinkBaseUrl } from "../ilink-base-url"; export interface WechatLoginCredentials { readonly token: string; @@ -67,11 +68,12 @@ export function createWechatIlinkLoginHook( ); } const { token, accountId, baseUrl, userId } = result.credentials; + const normalizedBaseUrl = normalizeWechatIlinkBaseUrl(baseUrl); saveCredential("WECHAT_BOT_TOKEN", token); env.WECHAT_BOT_TOKEN = token; env.WECHAT_ACCOUNT_ID = accountId; - if (baseUrl) env.WECHAT_BASE_URL = baseUrl; + if (normalizedBaseUrl) env.WECHAT_BASE_URL = normalizedBaseUrl; if (userId) env.WECHAT_USER_ID = userId; const suffix = result.summary ? ` (${result.summary})` : ""; (options.log ?? console.log)(` ✓ ${context.channelId} token saved${suffix}`); @@ -87,10 +89,10 @@ export function createWechatIlinkLoginHook( }, }; - if (baseUrl) { + if (normalizedBaseUrl) { outputs.baseUrl = { kind: "config", - value: baseUrl, + value: normalizedBaseUrl, }; } if (userId) { @@ -156,5 +158,9 @@ function normalizeCsvValues(value: string): string[] { } function normalizeCredentialValue(value: string): string { - return value.replace(/\r/g, "").trim(); + const normalized = value.trim(); + if (/[\r\n]/.test(normalized)) { + throw new Error("WeChat config values must not contain line breaks."); + } + return normalized; } diff --git a/src/lib/messaging/channels/wechat/hooks/implementations.test.ts b/src/lib/messaging/channels/wechat/hooks/implementations.test.ts index ef4e1abd5c..fc053c6b00 100644 --- a/src/lib/messaging/channels/wechat/hooks/implementations.test.ts +++ b/src/lib/messaging/channels/wechat/hooks/implementations.test.ts @@ -22,7 +22,7 @@ describe("WeChat hook implementations", () => { handler: createWechatIlinkLoginHook(), }, ]); - const hook = wechatManifest.hooks[0]; + const hook = wechatManifest.hooks.find((entry) => entry.id === "wechat-host-qr"); if (!hook) throw new Error("missing WeChat host QR hook"); @@ -48,14 +48,14 @@ describe("WeChat hook implementations", () => { credentials: { token: "wechat-token", accountId: "wechat-account", - baseUrl: "https://ilinkai.wechat.example", + baseUrl: "https://ilinkai.wechat.com", userId: "wechat-user", }, }), }), }, ]); - const hook = wechatManifest.hooks[0]; + const hook = wechatManifest.hooks.find((entry) => entry.id === "wechat-host-qr"); if (!hook) throw new Error("missing WeChat host QR hook"); @@ -87,12 +87,57 @@ describe("WeChat hook implementations", () => { expect(env).toMatchObject({ WECHAT_BOT_TOKEN: "wechat-token", WECHAT_ACCOUNT_ID: "wechat-account", - WECHAT_BASE_URL: "https://ilinkai.wechat.example", + WECHAT_BASE_URL: "https://ilinkai.wechat.com", WECHAT_USER_ID: "wechat-user", WECHAT_ALLOWED_IDS: "friend-one,wechat-user", }); }); + it("rejects invalid iLink baseUrl values before writing QR credentials", async () => { + for (const baseUrl of [ + "http://ilinkai.wechat.com", + "https://example.com", + "https://ilinkai.wechat.com/path", + "https://ilinkai.wechat.com\nEVIL=1", + ] as const) { + const env: NodeJS.ProcessEnv = {}; + const saved: Array<{ readonly key: string; readonly value: string }> = []; + const registry = new MessagingHookRegistry([ + { + id: WECHAT_ILINK_LOGIN_HOOK_ID, + handler: createWechatIlinkLoginHook({ + env, + log: () => {}, + saveCredential: (key, value) => saved.push({ key, value }), + runLogin: async () => ({ + kind: "ok", + credentials: { + token: "wechat-token", + accountId: "wechat-account", + baseUrl, + userId: "wechat-user", + }, + }), + }), + }, + ]); + const hook = wechatManifest.hooks.find((entry) => entry.id === "wechat-host-qr"); + + if (!hook) throw new Error("missing WeChat host QR hook"); + + await expect( + runMessagingHook(hook, registry, { + channelId: "wechat", + inputs: { + allowedIds: "friend-one", + }, + }), + ).rejects.toThrow(/WeChat baseUrl/); + expect(saved).toEqual([]); + expect(env).toEqual({}); + } + }); + it("turns QR failures into hook failures without writing credentials", async () => { const env: NodeJS.ProcessEnv = {}; const saved: Array<{ readonly key: string; readonly value: string }> = []; @@ -107,7 +152,7 @@ describe("WeChat hook implementations", () => { }), }, ]); - const hook = wechatManifest.hooks[0]; + const hook = wechatManifest.hooks.find((entry) => entry.id === "wechat-host-qr"); if (!hook) throw new Error("missing WeChat host QR hook"); @@ -130,6 +175,23 @@ describe("WeChat hook implementations", () => { } }); + it("rejects invalid iLink baseUrl values before writing OpenClaw seed files", () => { + for (const baseUrl of [ + "http://ilinkai.wechat.com", + "https://example.com", + "https://ilinkai.wechat.com/path", + "https://ilinkai.wechat.com\nEVIL=1", + ] as const) { + expect(() => + buildWechatSeedOpenClawAccountOutputs({ + "wechatConfig.accountId": "wechat-account", + "wechatConfig.baseUrl": baseUrl, + "credential.wechatBotToken.placeholder": "openshell:resolve:env:WECHAT_BOT_TOKEN", + }), + ).toThrow(/WeChat baseUrl/); + } + }); + it("declares a health-check hook that requires captured account metadata", async () => { const hook = wechatManifest.hooks.find((entry) => entry.id === "wechat-health-check"); const registry = new MessagingHookRegistry([ @@ -177,7 +239,7 @@ describe("WeChat hook implementations", () => { channelId: "wechat", inputs: { "wechatConfig.accountId": "wechat-account", - "wechatConfig.baseUrl": "https://ilinkai.wechat.example", + "wechatConfig.baseUrl": "https://ilinkai.wechat.com", "wechatConfig.userId": "wechat-user", "credential.wechatBotToken.placeholder": "openshell:resolve:env:WECHAT_BOT_TOKEN", }, @@ -192,7 +254,7 @@ describe("WeChat hook implementations", () => { content: { token: "openshell:resolve:env:WECHAT_BOT_TOKEN", savedAt: "2026-05-25T00:00:00.000Z", - baseUrl: "https://ilinkai.wechat.example", + baseUrl: "https://ilinkai.wechat.com", userId: "wechat-user", }, }, diff --git a/src/lib/messaging/channels/wechat/hooks/seed-openclaw-account.ts b/src/lib/messaging/channels/wechat/hooks/seed-openclaw-account.ts index 3012df7ba3..77416b6023 100644 --- a/src/lib/messaging/channels/wechat/hooks/seed-openclaw-account.ts +++ b/src/lib/messaging/channels/wechat/hooks/seed-openclaw-account.ts @@ -7,12 +7,14 @@ import type { MessagingHookOutputMap, MessagingHookRegistration, } from "../../../hooks/types"; +import { normalizeWechatIlinkBaseUrl } from "../ilink-base-url"; export const WECHAT_SEED_OPENCLAW_ACCOUNT_HOOK_ID = "wechat.seedOpenClawAccount"; export const WECHAT_TOKEN_PLACEHOLDER = "openshell:resolve:env:WECHAT_BOT_TOKEN"; export const WECHAT_PLUGIN_ID = "openclaw-weixin"; export const WECHAT_PLUGIN_INSTALL_PATH = "/sandbox/.openclaw/extensions/openclaw-weixin"; export const WECHAT_PLUGIN_SPEC = "@tencent-weixin/openclaw-weixin@2.4.3"; +export const WECHAT_PLUGIN_INSTALL_SPEC = `npm:${WECHAT_PLUGIN_SPEC}`; export interface WechatSeedOpenClawAccountHookOptions { readonly now?: () => Date | string; @@ -43,7 +45,7 @@ export function buildWechatSeedOpenClawAccountOutputs( ): MessagingHookOutputMap { const accountId = requiredInputString(inputs, "wechatConfig.accountId"); assertSafeWechatAccountId(accountId); - const baseUrl = optionalInputString(inputs, "wechatConfig.baseUrl"); + const baseUrl = normalizeWechatIlinkBaseUrl(optionalInputString(inputs, "wechatConfig.baseUrl")); const userId = optionalInputString(inputs, "wechatConfig.userId"); const token = optionalInputString(inputs, "credential.wechatBotToken.placeholder") || diff --git a/src/lib/messaging/channels/wechat/ilink-base-url.ts b/src/lib/messaging/channels/wechat/ilink-base-url.ts new file mode 100644 index 0000000000..2738dee832 --- /dev/null +++ b/src/lib/messaging/channels/wechat/ilink-base-url.ts @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +const WECHAT_ILINK_HOSTS = new Set(["ilinkai.weixin.qq.com", "ilinkai.wechat.com"]); +const WECHAT_ILINK_IDC_HOST_PATTERN = /^idc-[0-9]+[.]weixin[.]qq[.]com$/; + +export function normalizeWechatIlinkBaseUrl(value: unknown): string | undefined { + const raw = String(value ?? ""); + if (/[\r\n]/.test(raw)) { + throw new Error("WeChat baseUrl must not contain line breaks."); + } + const text = raw.trim(); + if (!text) return undefined; + + let url: URL; + try { + url = new URL(text); + } catch { + throw new Error("WeChat baseUrl must be a valid URL."); + } + + if (url.protocol !== "https:") { + throw new Error("WeChat baseUrl must use HTTPS."); + } + if (url.username || url.password) { + throw new Error("WeChat baseUrl must not include credentials."); + } + if (!isWechatIlinkHost(url.hostname)) { + throw new Error("WeChat baseUrl must use an expected iLink host."); + } + if ((url.pathname && url.pathname !== "/") || url.search || url.hash) { + throw new Error("WeChat baseUrl must be an iLink origin URL."); + } + + return url.origin; +} + +export function isWechatIlinkHost(hostname: string): boolean { + const normalized = hostname.toLowerCase(); + return WECHAT_ILINK_HOSTS.has(normalized) || WECHAT_ILINK_IDC_HOST_PATTERN.test(normalized); +} diff --git a/src/lib/messaging/channels/wechat/manifest.ts b/src/lib/messaging/channels/wechat/manifest.ts index de466bea7b..06fbd4a231 100644 --- a/src/lib/messaging/channels/wechat/manifest.ts +++ b/src/lib/messaging/channels/wechat/manifest.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import type { ChannelManifest } from "../../manifest"; +import { WECHAT_PLUGIN_INSTALL_SPEC } from "./hooks/seed-openclaw-account"; export const wechatManifest = { schemaVersion: 1, @@ -132,6 +133,25 @@ export const wechatManifest = { ], }, hooks: [ + { + id: "wechat-openclaw-package-install", + phase: "agent-install", + handler: "common.staticOutputs", + agents: ["openclaw"], + outputs: [ + { + id: "openclawPluginPackage", + kind: "package-install", + required: true, + value: { + manager: "openclaw-plugin", + spec: WECHAT_PLUGIN_INSTALL_SPEC, + pin: true, + }, + }, + ], + onFailure: "abort", + }, { id: "wechat-host-qr", phase: "enroll", diff --git a/src/lib/messaging/channels/wechat/template-resolver.ts b/src/lib/messaging/channels/wechat/template-resolver.ts index c97820ad0d..056273a7bd 100644 --- a/src/lib/messaging/channels/wechat/template-resolver.ts +++ b/src/lib/messaging/channels/wechat/template-resolver.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import type { RenderTemplateContext } from "../../compiler/engines/template"; +import { normalizeWechatIlinkBaseUrl } from "./ilink-base-url"; import { allowedIds, type BuiltInRenderTemplateResolver, @@ -18,6 +19,11 @@ export const resolveWechatTemplateReference: BuiltInRenderTemplateResolver = ( ) => { const wechatConfig = reference.match(/^wechatConfig[.](accountId|baseUrl|userId)$/); if (wechatConfig?.[1]) { + if (wechatConfig[1] === "baseUrl") { + return resolvedRenderTemplateReference( + normalizeWechatIlinkBaseUrl(stateValue(context, "wechatConfig.baseUrl")), + ); + } return resolvedRenderTemplateReference( nonEmptyString(stateValue(context, "wechatConfig." + wechatConfig[1])), ); diff --git a/src/lib/messaging/compiler/engines/agent-render-engine.ts b/src/lib/messaging/compiler/engines/agent-render-engine.ts index 2a6197b336..2ea695f56c 100644 --- a/src/lib/messaging/compiler/engines/agent-render-engine.ts +++ b/src/lib/messaging/compiler/engines/agent-render-engine.ts @@ -74,6 +74,7 @@ export async function planAgentRender( ); const lines = resolveRenderTemplatesInLines(credentialResolved, templateContext); if (lines.length === 0) continue; + assertSingleLineEnvRenderLines(manifest.id, hookOutput.id ?? result.hookId, lines); plans.push({ channelId: manifest.id, renderId: hookOutput.id, @@ -124,3 +125,21 @@ function isChannelRenderSpec(value: unknown): value is ChannelRenderSpec { function isObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } + +function assertSingleLineEnvRenderLines( + channelId: string, + renderId: string, + lines: readonly string[], +): void { + for (const line of lines) { + if (/[\r\n]/.test(line)) { + throw new Error( + "Messaging env render '" + + renderId + + "' for " + + channelId + + " must not contain line breaks.", + ); + } + } +} diff --git a/src/lib/messaging/compiler/manifest-compiler.test.ts b/src/lib/messaging/compiler/manifest-compiler.test.ts index 6b1ac580c4..e6ff851170 100644 --- a/src/lib/messaging/compiler/manifest-compiler.test.ts +++ b/src/lib/messaging/compiler/manifest-compiler.test.ts @@ -26,7 +26,7 @@ const TEST_CREDENTIALS: Readonly> = { const TEST_WECHAT_LOGIN = { token: "test-wechat-token", accountId: "test-wechat-account", - baseUrl: "https://ilinkai.wechat.example", + baseUrl: "https://ilinkai.wechat.com", userId: "test-wechat-user", } as const; @@ -210,6 +210,14 @@ describe("ManifestCompiler", () => { outputId: "openclawPluginPackage", required: true, }, + { + channelId: "wechat", + kind: "package-install", + hookId: "wechat-openclaw-package-install", + handler: "common.staticOutputs", + outputId: "openclawPluginPackage", + required: true, + }, { channelId: "wechat", kind: "build-file", @@ -261,6 +269,15 @@ describe("ManifestCompiler", () => { pin: true, }, }), + expect.objectContaining({ + channelId: "wechat", + kind: "package-install", + value: { + manager: "openclaw-plugin", + spec: "npm:@tencent-weixin/openclaw-weixin@2.4.3", + pin: true, + }, + }), ]), ); expect(plan.buildSteps.every((step) => step.value !== undefined)).toBe(true); @@ -339,6 +356,91 @@ describe("ManifestCompiler", () => { }); }); + it("rejects line feeds in Slack Hermes env render values", async () => { + for (const [envKey, value] of [ + ["SLACK_ALLOWED_USERS", "U123\nEVIL=1"], + ["SLACK_ALLOWED_CHANNELS", "C123\nEVIL=1"], + ] as const) { + await expect( + withEnv( + { + SLACK_BOT_TOKEN: "xoxb-test-slack-token", + SLACK_APP_TOKEN: "xapp-test-slack-token", + [envKey]: value, + }, + () => + compiler().compile({ + sandboxName: "demo", + agent: "hermes", + workflow: "rebuild", + isInteractive: false, + configuredChannels: ["slack"], + credentialAvailability: { + SLACK_BOT_TOKEN: true, + SLACK_APP_TOKEN: true, + }, + }), + ), + ).rejects.toThrow(/line breaks/); + } + }); + + it("rejects unsafe WeChat Hermes env render values", async () => { + const cases: Array = [ + ["WECHAT_ACCOUNT_ID", "wechat-account\nEVIL=1"], + ["WECHAT_BASE_URL", "https://ilinkai.wechat.com\nEVIL=1"], + ["WECHAT_ALLOWED_IDS", "friend-one\nEVIL=1"], + ]; + + for (const [envKey, value] of cases) { + await expect( + withEnv( + { + WECHAT_ACCOUNT_ID: "wechat-account", + WECHAT_BASE_URL: "https://ilinkai.wechat.com", + WECHAT_ALLOWED_IDS: "friend-one", + [envKey]: value, + }, + () => + compiler().compile({ + sandboxName: "demo", + agent: "hermes", + workflow: "rebuild", + isInteractive: false, + configuredChannels: ["wechat"], + credentialAvailability: { + WECHAT_BOT_TOKEN: true, + }, + }), + ), + ).rejects.toThrow(/line breaks/); + } + }); + + it("rejects non-HTTPS or non-iLink WeChat baseUrl values", async () => { + for (const baseUrl of ["http://ilinkai.wechat.com", "https://example.com"] as const) { + await expect( + withEnv( + { + WECHAT_ACCOUNT_ID: "wechat-account", + WECHAT_BASE_URL: baseUrl, + }, + () => + compiler().compile({ + sandboxName: "demo", + agent: "hermes", + workflow: "rebuild", + isInteractive: false, + configuredChannels: ["wechat"], + credentialAvailability: { + WECHAT_BOT_TOKEN: true, + }, + }), + ), + ).rejects.toThrow(/WeChat baseUrl/); + } + }); + it("does not activate a requested channel while any required manifest input is missing", async () => { const plan = await withEnv( { @@ -394,7 +496,7 @@ describe("ManifestCompiler", () => { }); expect(wechat?.inputs.find((input) => input.inputId === "baseUrl")).toMatchObject({ kind: "config", - value: "https://ilinkai.wechat.example", + value: "https://ilinkai.wechat.com", }); }); diff --git a/src/lib/messaging/compiler/manifest-compiler.ts b/src/lib/messaging/compiler/manifest-compiler.ts index 8b8ef4c7f6..303b69f16b 100644 --- a/src/lib/messaging/compiler/manifest-compiler.ts +++ b/src/lib/messaging/compiler/manifest-compiler.ts @@ -324,7 +324,10 @@ function inputReferenceBase( function readInputEnvValue(input: ChannelInputSpec): MessagingSerializableValue | undefined { const normalize = (raw: string | null | undefined): string | undefined => { - const normalized = raw?.replace(/\r/g, "").trim(); + if (raw && /[\r\n]/.test(raw)) { + throw new Error("Messaging input values must not contain line breaks."); + } + const normalized = raw?.trim(); if (!normalized || normalized.length === 0) return undefined; if (input.validValues && !input.validValues.includes(normalized)) return undefined; return normalized; diff --git a/src/lib/messaging/compiler/workflow-planner.test.ts b/src/lib/messaging/compiler/workflow-planner.test.ts index f987a5224b..467ca1bb5f 100644 --- a/src/lib/messaging/compiler/workflow-planner.test.ts +++ b/src/lib/messaging/compiler/workflow-planner.test.ts @@ -20,7 +20,7 @@ const TEST_CREDENTIALS: Readonly> = { const TEST_WECHAT_LOGIN = { token: "test-wechat-token", accountId: "test-wechat-account", - baseUrl: "https://ilinkai.wechat.example", + baseUrl: "https://ilinkai.wechat.com", userId: "test-wechat-user", } as const; diff --git a/src/lib/onboard/messaging-channel-setup.test.ts b/src/lib/onboard/messaging-channel-setup.test.ts index c09418a6bd..eeb7a9b803 100644 --- a/src/lib/onboard/messaging-channel-setup.test.ts +++ b/src/lib/onboard/messaging-channel-setup.test.ts @@ -262,7 +262,7 @@ describe("setupSelectedMessagingChannels", () => { token: "wechat-token", extraEnv: { WECHAT_ACCOUNT_ID: "wechat-account", - WECHAT_BASE_URL: "https://ilinkai.wechat.example", + WECHAT_BASE_URL: "https://ilinkai.wechat.com", WECHAT_USER_ID: "wechat-user", }, defaultUserId: "wechat-user", @@ -281,7 +281,7 @@ describe("setupSelectedMessagingChannels", () => { expect(saveCredential).toHaveBeenCalledWith("WECHAT_BOT_TOKEN", "wechat-token"); expect(process.env.WECHAT_ACCOUNT_ID).toBe("wechat-account"); - expect(process.env.WECHAT_BASE_URL).toBe("https://ilinkai.wechat.example"); + expect(process.env.WECHAT_BASE_URL).toBe("https://ilinkai.wechat.com"); expect(process.env.WECHAT_USER_ID).toBe("wechat-user"); expect(process.env.WECHAT_ALLOWED_IDS).toBe("wechat-user"); expect(plan?.channels[0]).toMatchObject({ channelId: "wechat", active: true }); diff --git a/src/lib/state/sandbox.ts b/src/lib/state/sandbox.ts index e45558aaa0..8dabab89f4 100644 --- a/src/lib/state/sandbox.ts +++ b/src/lib/state/sandbox.ts @@ -537,7 +537,7 @@ function sanitizeBackupDirectory(dirPath: string): void { const _verbose = () => process.env.NEMOCLAW_REBUILD_VERBOSE === "1"; -// Exact symlinks baked into the base image at build time (Dockerfile.base) by +// Exact symlinks baked into OpenClaw messaging images at build time by // `openclaw plugins install`. Source paths are relative to the agent state-dir // root (e.g. for OpenClaw, /sandbox/.openclaw); targets are matched exactly // against the value of `readlink(source)`. Source-only matching is unsafe: a diff --git a/test/e2e/test-channels-stop-start.sh b/test/e2e/test-channels-stop-start.sh index eb6aa6ac08..7d86bd2c4a 100755 --- a/test/e2e/test-channels-stop-start.sh +++ b/test/e2e/test-channels-stop-start.sh @@ -511,7 +511,7 @@ export_fake_channel_env() { export WECHAT_BOT_TOKEN="${ORIG_WECHAT_BOT_TOKEN:-test-fake-wechat-token-${suffix}}" export WECHAT_ACCOUNT_ID="${ORIG_WECHAT_ACCOUNT_ID:-e2e-fake-account-${suffix}}" - export WECHAT_BASE_URL="${ORIG_WECHAT_BASE_URL:-https://ilinkai-fake-${suffix}.wechat.com}" + export WECHAT_BASE_URL="${ORIG_WECHAT_BASE_URL:-https://ilinkai.wechat.com}" export WECHAT_USER_ID="${ORIG_WECHAT_USER_ID:-wxid_${suffix}_operator}" export WECHAT_ALLOWED_IDS="${ORIG_WECHAT_ALLOWED_IDS:-${WECHAT_USER_ID}}" } diff --git a/test/e2e/test-messaging-providers.sh b/test/e2e/test-messaging-providers.sh index d7b8d89559..a5b1aa6360 100755 --- a/test/e2e/test-messaging-providers.sh +++ b/test/e2e/test-messaging-providers.sh @@ -436,7 +436,7 @@ SLACK_IDS="${SLACK_ALLOWED_USERS-U0AR85ATALW,U09E2ESLACK}" # made because no token exchange happens at build time. WECHAT_TOKEN="${WECHAT_BOT_TOKEN:-test-fake-wechat-token-e2e}" WECHAT_ACCOUNT="${WECHAT_ACCOUNT_ID:-e2e-fake-account-12345}" -WECHAT_BASE="${WECHAT_BASE_URL:-https://ilinkai-fake-e2e.wechat.com}" +WECHAT_BASE="${WECHAT_BASE_URL:-https://ilinkai.wechat.com}" WECHAT_USER="${WECHAT_USER_ID:-wxid_e2efakeoperator}" WECHAT_IDS="${WECHAT_ALLOWED_IDS:-${WECHAT_USER}}" # WhatsApp is QR-only, but seed host-side decoys to prove they are ignored. @@ -2138,7 +2138,7 @@ print(','.join(bad)) # concrete semver. The upstream plugin loader needs this install metadata # after OpenClaw config rewrites (plugins.entries alone is not enough), # and a floating spec (e.g. "@latest") would silently bypass the - # installer-trust pinning enforced in Dockerfile.base and + # installer-trust pinning enforced by the WeChat package-install allowlist and # wechat.seedOpenClawAccount manifest hook (WECHAT_PLUGIN_SPEC=@2.4.3). wechat_plugins_json=$(sandbox_exec "python3 -c \" import json diff --git a/test/generate-openclaw-config.test.ts b/test/generate-openclaw-config.test.ts index 2508e10226..02826f6f80 100644 --- a/test/generate-openclaw-config.test.ts +++ b/test/generate-openclaw-config.test.ts @@ -431,11 +431,11 @@ describe("generate-openclaw-config.mts: config generation", () => { it("does not crash on malformed port in CHAT_UI_URL", () => { const config = runConfigScript({ - CHAT_UI_URL: "https://example.com:abc", + CHAT_UI_URL: "https://ilinkai.wechat.com.com:abc", }); const origins = config.gateway.controlUi.allowedOrigins; expect(origins).toContain("http://127.0.0.1:18789"); - expect(origins).not.toContain("https://example.com"); + expect(origins).not.toContain("https://ilinkai.wechat.com.com"); }); it("leaves messaging render to the messaging build applier", () => { @@ -530,7 +530,7 @@ describe("generate-openclaw-config.mts: config generation", () => { it("applies WeChat post-agent-install build-file outputs through the messaging applier", () => { const channels = Buffer.from(JSON.stringify(["wechat"])).toString("base64"); const wechatConfig = Buffer.from( - JSON.stringify({ accountId: "primary", baseUrl: "https://example", userId: "u1" }), + JSON.stringify({ accountId: "primary", baseUrl: "https://ilinkai.wechat.com", userId: "u1" }), ).toString("base64"); const config = runConfigScript({ NEMOCLAW_MESSAGING_CHANNELS_B64: channels, @@ -545,6 +545,7 @@ describe("generate-openclaw-config.mts: config generation", () => { expect(config.plugins?.load?.paths ?? []).not.toContain( "/sandbox/.openclaw/extensions/openclaw-weixin", ); + expect(config.plugins?.entries?.["openclaw-weixin"]?.enabled).toBe(true); expect(config.channels?.["openclaw-weixin"]?.accounts?.primary).toEqual({ enabled: true }); expect(config.channels?.wechat).toBeUndefined(); }); @@ -558,15 +559,12 @@ describe("generate-openclaw-config.mts: config generation", () => { expect(config.channels?.wechat).toBeUndefined(); }); - it("enables the openclaw-weixin plugin entry unconditionally", () => { - // The plugin ships in the base image, so we activate the entry on every - // build. With no seeded account, the upstream auth/accounts.ts no-ops - // and the bridge never starts. + it("omits the openclaw-weixin plugin entry until WeChat is active", () => { const config = runConfigScript({}); - expect(config.plugins?.entries?.["openclaw-weixin"]?.enabled).toBe(true); + expect(config.plugins?.entries?.["openclaw-weixin"]).toBeUndefined(); }); - it("preserves base-image plugin install registry entries", () => { + it("preserves existing plugin install registry entries without enabling WeChat", () => { const configPath = path.join(tmpDir, ".openclaw", "openclaw.json"); fs.mkdirSync(path.dirname(configPath), { recursive: true }); const installEntry = { @@ -581,7 +579,7 @@ describe("generate-openclaw-config.mts: config generation", () => { const config = runConfigScript({}); expect(config.plugins?.installs?.["openclaw-weixin"]).toEqual(installEntry); - expect(config.plugins?.entries?.["openclaw-weixin"]?.enabled).toBe(true); + expect(config.plugins?.entries?.["openclaw-weixin"]).toBeUndefined(); }); it("ignores malformed existing plugin install registries while regenerating config", () => { @@ -591,7 +589,7 @@ describe("generate-openclaw-config.mts: config generation", () => { for (const existing of [null, { plugins: null }, { plugins: { installs: {} } }]) { fs.writeFileSync(configPath, JSON.stringify(existing)); const config = runConfigScript(); - expect(config.plugins?.entries?.["openclaw-weixin"]?.enabled).toBe(true); + expect(config.plugins?.entries?.["openclaw-weixin"]).toBeUndefined(); expect(config.plugins?.installs).toBeUndefined(); } }); diff --git a/test/messaging-build-applier.test.ts b/test/messaging-build-applier.test.ts index e5d6ef7f1b..84619ce01d 100644 --- a/test/messaging-build-applier.test.ts +++ b/test/messaging-build-applier.test.ts @@ -48,6 +48,17 @@ function channelsB64(channels: string[]): string { return Buffer.from(JSON.stringify(channels)).toString("base64"); } +function wechatConfigB64(overrides: Record = {}): string { + return Buffer.from( + JSON.stringify({ + accountId: "primary", + baseUrl: "https://ilinkai.wechat.com", + userId: "u1", + ...overrides, + }), + ).toString("base64"); +} + function runDryRun(envOverrides: Record = {}) { const env = withLegacyMessagingPlanEnv( { @@ -83,14 +94,22 @@ function parseDryRun(envOverrides: Record = {}) { } describe("messaging-build-applier.mts: agent-install", () => { - it("pins selected external messaging plugins to OPENCLAW_VERSION", () => { + it("collects selected messaging plugin install specs", () => { const payload = parseDryRun({ OPENCLAW_VERSION: "2026.5.22", - NEMOCLAW_MESSAGING_CHANNELS_B64: channelsB64(["telegram", "discord", "slack", "whatsapp"]), + NEMOCLAW_MESSAGING_CHANNELS_B64: channelsB64([ + "telegram", + "discord", + "slack", + "whatsapp", + "wechat", + ]), + NEMOCLAW_WECHAT_CONFIG_B64: wechatConfigB64(), }); expect(payload.installSpecs).toEqual([ "npm:@openclaw/discord@2026.5.22", + "npm:@tencent-weixin/openclaw-weixin@2.4.3", "npm:@openclaw/slack@2026.5.22", "npm:@openclaw/whatsapp@2026.5.22", ]); @@ -99,6 +118,7 @@ describe("messaging-build-applier.mts: agent-install", () => { SLACK_APP_TOKEN: "xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN", SLACK_BOT_TOKEN: "xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN", TELEGRAM_BOT_TOKEN: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", + WECHAT_BOT_TOKEN: "openshell:resolve:env:WECHAT_BOT_TOKEN", }); }); @@ -126,6 +146,18 @@ describe("messaging-build-applier.mts: agent-install", () => { }); }); + it("installs the fixed WeChat OpenClaw plugin without OPENCLAW_VERSION", () => { + const payload = parseDryRun({ + NEMOCLAW_MESSAGING_CHANNELS_B64: channelsB64(["wechat"]), + NEMOCLAW_WECHAT_CONFIG_B64: wechatConfigB64(), + }); + + expect(payload.installSpecs).toEqual(["npm:@tencent-weixin/openclaw-weixin@2.4.3"]); + expect(payload.doctorEnv).toEqual({ + WECHAT_BOT_TOKEN: "openshell:resolve:env:WECHAT_BOT_TOKEN", + }); + }); + it("forces WhatsApp to the OpenClaw runtime version on 2026.5.18 sandboxes", () => { const payload = parseDryRun({ OPENCLAW_VERSION: "2026.5.18", @@ -154,6 +186,75 @@ describe("messaging-build-applier.mts: agent-install", () => { expect(result.stderr).toContain("NEMOCLAW_MESSAGING_PLAN_B64"); }); + it("rejects tampered package-install specs before invoking OpenClaw", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-package-allowlist-")); + const tracePath = path.join(tmp, "openclaw.trace"); + const fakeOpenclaw = path.join(tmp, "openclaw"); + fs.writeFileSync( + fakeOpenclaw, + [ + "#!/usr/bin/env node", + "require('node:fs').appendFileSync(process.env.OPENCLAW_TRACE, 'invoked\\n');", + "process.exit(0);", + "", + ].join("\n"), + { mode: 0o755 }, + ); + + const plan = { + schemaVersion: 1, + sandboxName: "test-sandbox", + agent: "openclaw", + channels: [{ channelId: "discord", active: true }], + credentialBindings: [], + agentRender: [], + buildSteps: [ + { + channelId: "discord", + kind: "package-install", + outputId: "openclawPluginPackage", + required: true, + value: { + manager: "openclaw-plugin", + spec: "npm:@evil/plugin@1.0.0", + pin: true, + }, + }, + ], + }; + + try { + const result = spawnSync( + "node", + [ + "--experimental-strip-types", + SCRIPT_PATH, + "--agent", + "openclaw", + "--phase", + "agent-install", + ], + { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + env: { + PATH: tmp + ":" + (process.env.PATH || "/usr/bin:/bin"), + OPENCLAW_TRACE: tracePath, + OPENCLAW_VERSION: "2026.5.22", + NEMOCLAW_MESSAGING_PLAN_B64: Buffer.from(JSON.stringify(plan)).toString("base64"), + }, + timeout: 10_000, + }, + ); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("not allowed"); + expect(fs.existsSync(tracePath)).toBe(false); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + it("runs pinned installs during agent-install without doctor env injection", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-message-plugins-")); const tracePath = path.join(tmp, "openclaw.trace"); @@ -180,7 +281,9 @@ describe("messaging-build-applier.mts: agent-install", () => { "discord", "slack", "whatsapp", + "wechat", ]), + NEMOCLAW_WECHAT_CONFIG_B64: wechatConfigB64(), }, "openclaw", ); @@ -205,6 +308,7 @@ describe("messaging-build-applier.mts: agent-install", () => { expect(result.status, result.stderr).toBe(0); expect(fs.readFileSync(tracePath, "utf-8").trim().split("\n")).toEqual([ "plugins|install|npm:@openclaw/discord@2026.5.22|--pin|||", + "plugins|install|npm:@tencent-weixin/openclaw-weixin@2.4.3|--pin|||", "plugins|install|npm:@openclaw/slack@2026.5.22|--pin|||", "plugins|install|npm:@openclaw/whatsapp@2026.5.22|--pin|||", ]); @@ -323,7 +427,7 @@ describe("messaging-build-applier.mts: agent-install", () => { const fakeOpenclaw = path.join(tmp, "openclaw"); const channels = channelsB64(["telegram", "discord", "slack", "wechat"]); const wechatConfig = Buffer.from( - JSON.stringify({ accountId: "primary", baseUrl: "https://example", userId: "u1" }), + JSON.stringify({ accountId: "primary", baseUrl: "https://ilinkai.wechat.com", userId: "u1" }), ).toString("base64"); fs.writeFileSync( @@ -416,7 +520,7 @@ describe("messaging-build-applier.mts: agent-install", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-post-agent-install-")); const channels = channelsB64(["wechat"]); const wechatConfig = Buffer.from( - JSON.stringify({ accountId: "primary", baseUrl: "https://example", userId: "u1" }), + JSON.stringify({ accountId: "primary", baseUrl: "https://ilinkai.wechat.com", userId: "u1" }), ).toString("base64"); try { @@ -486,7 +590,7 @@ describe("messaging-build-applier.mts: agent-install", () => { ); expect(account).toMatchObject({ token: "openshell:resolve:env:WECHAT_BOT_TOKEN", - baseUrl: "https://example", + baseUrl: "https://ilinkai.wechat.com", userId: "u1", }); expect( @@ -551,6 +655,61 @@ describe("messaging-build-applier.mts: agent-install", () => { } }); + it("rejects multiline env render lines from serialized plans", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-env-line-injection-")); + const plan = { + schemaVersion: 1, + sandboxName: "test-sandbox", + agent: "hermes", + channels: [{ channelId: "slack", active: true }], + credentialBindings: [], + agentRender: [ + { + channelId: "slack", + agent: "hermes", + target: "~/.hermes/.env", + kind: "env-lines", + renderId: "slack-hermes-env", + lines: ["SLACK_ALLOWED_USERS=U123\nEVIL=1"], + }, + ], + buildSteps: [], + }; + + try { + const result = spawnSync( + "node", + [ + "--experimental-strip-types", + SCRIPT_PATH, + "--agent", + "hermes", + "--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"), + }, + timeout: 10_000, + }, + ); + + expect(result.status).toBe(2); + expect(result.stderr).toContain("line breaks"); + const envPath = path.join(tmp, ".hermes", ".env"); + expect(fs.existsSync(envPath) ? fs.readFileSync(envPath, "utf-8") : "").not.toContain( + "EVIL=1", + ); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + it("applies Hermes messaging render to config.yaml and .env in post-agent-install", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hermes-render-")); try { From 7484aeb34876f486d8eb445f673ef42c68776ac8 Mon Sep 17 00:00:00 2001 From: San Dang Date: Wed, 10 Jun 2026 14:56:23 +0530 Subject: [PATCH 21/23] test(messaging): expect wechat package install hook --- src/lib/messaging/channels/manifests.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/lib/messaging/channels/manifests.test.ts b/src/lib/messaging/channels/manifests.test.ts index bad7b2906d..c5770328f4 100644 --- a/src/lib/messaging/channels/manifests.test.ts +++ b/src/lib/messaging/channels/manifests.test.ts @@ -414,13 +414,17 @@ describe("built-in channel manifests", () => { expect(renderJson(wechatManifest)).toContain("WEIXIN_TOKEN"); expect(renderJson(wechatManifest)).toContain("credential.wechatBotToken.placeholder"); expect(wechatManifest.hooks.map((hook) => hook.handler)).toEqual([ + "common.staticOutputs", "wechat.ilinkLogin", "common.configPrompt", "wechat.seedOpenClawAccount", "wechat.healthCheck", ]); expectConfigPromptEnrollHook(wechatManifest, ["allowedIds"]); - expect(wechatManifest.hooks[2]?.outputs).toEqual( + const seedHook = wechatManifest.hooks.find( + (hook) => hook.id === "wechat-seed-openclaw-account", + ); + expect(seedHook?.outputs).toEqual( expect.arrayContaining([ expect.objectContaining({ id: "openclawWeixinAccountFile", @@ -432,7 +436,7 @@ describe("built-in channel manifests", () => { }), ]), ); - expect(wechatManifest.hooks[3]).toMatchObject({ + expect(wechatManifest.hooks.find((hook) => hook.id === "wechat-health-check")).toMatchObject({ id: "wechat-health-check", phase: "health-check", handler: "wechat.healthCheck", From 86c228740a2cee5b5169b65285bf3cc1f68e3cdd Mon Sep 17 00:00:00 2001 From: San Dang Date: Wed, 10 Jun 2026 15:17:38 +0530 Subject: [PATCH 22/23] test(cli): stabilize dispatch timeouts under load --- test/cli/list-inference.test.ts | 101 +++++++++++---------- test/cli/list-share-live-inference.test.ts | 2 +- test/cli/status-gateway-lifecycle.test.ts | 10 +- 3 files changed, 59 insertions(+), 54 deletions(-) diff --git a/test/cli/list-inference.test.ts b/test/cli/list-inference.test.ts index e75f2aa2d9..79bfaff386 100644 --- a/test/cli/list-inference.test.ts +++ b/test/cli/list-inference.test.ts @@ -15,59 +15,64 @@ import { readCliErrorOutput, run, runWithEnv, + testTimeout, } from "./helpers"; describe("CLI dispatch", () => { - it("redirects `inference set` to openshell when provider or model is missing", () => { - for (const argv of [ - "inference set 2>&1", - "inference set --provider nvidia-prod 2>&1", - "inference set --model nvidia/model 2>&1", - ]) { - const r = run(argv); - expect(r.code, `nemoclaw ${argv}`).toBe(1); - expect(r.out, `nemoclaw ${argv}`).toContain("Unknown nemoclaw command: inference set"); - expect(r.out, `nemoclaw ${argv}`).toContain("This operation belongs to OpenShell."); - expect(r.out, `nemoclaw ${argv}`).toContain( - "Run: openshell inference set -g nemoclaw --model --provider ", - ); - expect(r.out, `nemoclaw ${argv}`).not.toContain("Missing required flag"); - expect(r.out, `nemoclaw ${argv}`).not.toContain("FailedFlagValidationError"); - expect(r.out, `nemoclaw ${argv}`).not.toContain("node_modules/@oclif/core"); - } + it( + "redirects `inference set` to openshell when provider or model is missing", + () => { + for (const argv of [ + "inference set 2>&1", + "inference set --provider nvidia-prod 2>&1", + "inference set --model nvidia/model 2>&1", + ]) { + const r = run(argv); + expect(r.code, `nemoclaw ${argv}`).toBe(1); + expect(r.out, `nemoclaw ${argv}`).toContain("Unknown nemoclaw command: inference set"); + expect(r.out, `nemoclaw ${argv}`).toContain("This operation belongs to OpenShell."); + expect(r.out, `nemoclaw ${argv}`).toContain( + "Run: openshell inference set -g nemoclaw --model --provider ", + ); + expect(r.out, `nemoclaw ${argv}`).not.toContain("Missing required flag"); + expect(r.out, `nemoclaw ${argv}`).not.toContain("FailedFlagValidationError"); + expect(r.out, `nemoclaw ${argv}`).not.toContain("node_modules/@oclif/core"); + } - let hermesOut = ""; - let hermesCode = 0; - try { - hermesOut = execSync(`node "${HERMES_CLI}" inference set 2>&1`, { - encoding: "utf-8", - stdio: "pipe", - timeout: execTimeout(), - env: { - ...process.env, - HOME: fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-test-")), - }, - }); - } catch (err) { - const result = readCliErrorOutput( - isCliErrorCandidate(err) - ? { - status: typeof err.status === "number" ? err.status : undefined, - stdout: readBufferOrStringProperty(err, "stdout"), - stderr: readBufferOrStringProperty(err, "stderr"), - } - : String(err), + let hermesOut = ""; + let hermesCode = 0; + try { + hermesOut = execSync(`node "${HERMES_CLI}" inference set 2>&1`, { + encoding: "utf-8", + stdio: "pipe", + timeout: execTimeout(), + env: { + ...process.env, + HOME: fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-test-")), + }, + }); + } catch (err) { + const result = readCliErrorOutput( + isCliErrorCandidate(err) + ? { + status: typeof err.status === "number" ? err.status : undefined, + stdout: readBufferOrStringProperty(err, "stdout"), + stderr: readBufferOrStringProperty(err, "stderr"), + } + : String(err), + ); + hermesOut = result.out; + hermesCode = result.code; + } + expect(hermesCode).toBe(1); + expect(hermesOut).toContain("Unknown nemohermes command: inference set"); + expect(hermesOut).toContain("This operation belongs to OpenShell."); + expect(hermesOut).toContain( + "Run: openshell inference set -g nemoclaw --model --provider ", ); - hermesOut = result.out; - hermesCode = result.code; - } - expect(hermesCode).toBe(1); - expect(hermesOut).toContain("Unknown nemohermes command: inference set"); - expect(hermesOut).toContain("This operation belongs to OpenShell."); - expect(hermesOut).toContain( - "Run: openshell inference set -g nemoclaw --model --provider ", - ); - }); + }, + testTimeout(15_000), + ); it("list exits 0", () => { const r = run("list"); diff --git a/test/cli/list-share-live-inference.test.ts b/test/cli/list-share-live-inference.test.ts index a5e632af91..fb57482502 100644 --- a/test/cli/list-share-live-inference.test.ts +++ b/test/cli/list-share-live-inference.test.ts @@ -422,7 +422,7 @@ describe("list shows live gateway inference", () => { expect(r.out).toContain("status"); }); - it("share help uses native oclif usage", () => { + it("share help uses native oclif usage", testTimeoutOptions(15_000), () => { const env = createShareTestEnv("nemoclaw-cli-share-help-"); const parent = runWithEnv("alpha share --help", env); diff --git a/test/cli/status-gateway-lifecycle.test.ts b/test/cli/status-gateway-lifecycle.test.ts index 54ab539ccc..f469f2e97a 100644 --- a/test/cli/status-gateway-lifecycle.test.ts +++ b/test/cli/status-gateway-lifecycle.test.ts @@ -59,7 +59,7 @@ describe("CLI dispatch", () => { HOME: home, PATH: `${localBin}:${process.env.PATH || ""}`, }, - execTimeout(), + execTimeout(20_000), ); expect(r.code).toBe(1); @@ -68,7 +68,7 @@ describe("CLI dispatch", () => { const saved = JSON.parse(fs.readFileSync(path.join(registryDir, "sandboxes.json"), "utf8")); expect(saved.sandboxes.alpha).toBeTruthy(); }, - testTimeout(10_000), + testTimeout(20_000), ); it( @@ -116,15 +116,15 @@ describe("CLI dispatch", () => { PATH: `${localBin}:${process.env.PATH || ""}`, NEMOCLAW_STATUS_PROBE_TIMEOUT_MS: "100", }, - 10000, + execTimeout(20_000), ); - expect(Date.now() - started).toBeLessThan(7000); + expect(Date.now() - started).toBeLessThan(execTimeout(12_000)); expect(r.code).toBe(1); expect(r.out).toContain("Model: test-model"); expect(r.out).toContain("Live sandbox status probe timed out"); }, - testTimeout(10_000), + testTimeout(20_000), ); it("recovers status after gateway runtime is reattached", () => { From 27ed07d7effa1490e7f87317e5b09351b30e0611 Mon Sep 17 00:00:00 2001 From: San Dang Date: Wed, 10 Jun 2026 16:10:20 +0530 Subject: [PATCH 23/23] ci: fix channels stop-start wechat fixture --- .github/workflows/nightly-e2e.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/nightly-e2e.yaml b/.github/workflows/nightly-e2e.yaml index 66615e41e6..b9ecdf8826 100644 --- a/.github/workflows/nightly-e2e.yaml +++ b/.github/workflows/nightly-e2e.yaml @@ -739,7 +739,7 @@ jobs: /tmp/nemoclaw-e2e-install.log /tmp/nemoclaw-e2e-channels-*-install.log /tmp/nc-channels-*.log - env_json: '{"DISCORD_ALLOWED_IDS":"1005536447329222676","DISCORD_BOT_TOKEN":"test-fake-discord-token-stop-start-e2e","DISCORD_REQUIRE_MENTION":"0","DISCORD_SERVER_ID":"1491590992753590594","NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE":"1","NEMOCLAW_NON_INTERACTIVE":"1","NEMOCLAW_POLICY_TIER":"open","NEMOCLAW_SANDBOX_NAME":"e2e-channels-stop-start","SLACK_ALLOWED_USERS":"U0123456789,U09ABCDEFGH","SLACK_APP_TOKEN":"xapp-fake-slack-app-token-stop-start-e2e","SLACK_BOT_TOKEN":"xoxb-fake-slack-token-stop-start-e2e","TELEGRAM_ALLOWED_IDS":"123456789","TELEGRAM_BOT_TOKEN":"test-fake-telegram-token-stop-start-e2e","WECHAT_ACCOUNT_ID":"e2e-fake-account-stop-start","WECHAT_ALLOWED_IDS":"wxid_stopstart_operator","WECHAT_BASE_URL":"https://ilinkai-fake-stop-start.wechat.com","WECHAT_BOT_TOKEN":"test-fake-wechat-token-stop-start-e2e","WECHAT_USER_ID":"wxid_stopstart_operator"}' + env_json: '{"DISCORD_ALLOWED_IDS":"1005536447329222676","DISCORD_BOT_TOKEN":"test-fake-discord-token-stop-start-e2e","DISCORD_REQUIRE_MENTION":"0","DISCORD_SERVER_ID":"1491590992753590594","NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE":"1","NEMOCLAW_NON_INTERACTIVE":"1","NEMOCLAW_POLICY_TIER":"open","NEMOCLAW_SANDBOX_NAME":"e2e-channels-stop-start","SLACK_ALLOWED_USERS":"U0123456789,U09ABCDEFGH","SLACK_APP_TOKEN":"xapp-fake-slack-app-token-stop-start-e2e","SLACK_BOT_TOKEN":"xoxb-fake-slack-token-stop-start-e2e","TELEGRAM_ALLOWED_IDS":"123456789","TELEGRAM_BOT_TOKEN":"test-fake-telegram-token-stop-start-e2e","WECHAT_ACCOUNT_ID":"e2e-fake-account-stop-start","WECHAT_ALLOWED_IDS":"wxid_stopstart_operator","WECHAT_BASE_URL":"https://ilinkai.wechat.com","WECHAT_BOT_TOKEN":"test-fake-wechat-token-stop-start-e2e","WECHAT_USER_ID":"wxid_stopstart_operator"}' nvidia_api_key: true github_token: true secrets: *nightly-e2e-default-secrets