From 59c63094c3f51aeedc97c333b6083550f152f798 Mon Sep 17 00:00:00 2001 From: San Dang Date: Mon, 8 Jun 2026 15:50:09 +0530 Subject: [PATCH 1/8] 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 2/8] 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 3/8] 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 4/8] 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 5/8] 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 6/8] 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 7/8] 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 8/8] 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; + } +};