From bb83755c2c95763da65110466bedb4cb35bfc4b7 Mon Sep 17 00:00:00 2001 From: San Dang Date: Fri, 22 May 2026 10:03:19 +0700 Subject: [PATCH 01/20] feat(messaging): add hook scaffold and channel manifests Signed-off-by: San Dang --- .../messaging/channels/discord/manifest.ts | 154 ++++++++++ src/lib/messaging/channels/index.ts | 25 ++ src/lib/messaging/channels/manifests.test.ts | 261 +++++++++++++++++ src/lib/messaging/channels/slack/manifest.ts | 113 ++++++++ .../messaging/channels/telegram/manifest.ts | 135 +++++++++ .../messaging/channels/whatsapp/manifest.ts | 44 +++ src/lib/messaging/hooks/hook-runner.test.ts | 266 ++++++++++++++++++ src/lib/messaging/hooks/hook-runner.ts | 105 +++++++ src/lib/messaging/hooks/index.ts | 6 + src/lib/messaging/hooks/registry.ts | 50 ++++ src/lib/messaging/hooks/types.ts | 60 ++++ src/lib/messaging/index.ts | 2 + src/lib/messaging/manifest/registry.test.ts | 3 +- src/lib/messaging/manifest/types.test.ts | 6 +- 14 files changed, 1227 insertions(+), 3 deletions(-) create mode 100644 src/lib/messaging/channels/discord/manifest.ts create mode 100644 src/lib/messaging/channels/index.ts create mode 100644 src/lib/messaging/channels/manifests.test.ts create mode 100644 src/lib/messaging/channels/slack/manifest.ts create mode 100644 src/lib/messaging/channels/telegram/manifest.ts create mode 100644 src/lib/messaging/channels/whatsapp/manifest.ts create mode 100644 src/lib/messaging/hooks/hook-runner.test.ts create mode 100644 src/lib/messaging/hooks/hook-runner.ts create mode 100644 src/lib/messaging/hooks/index.ts create mode 100644 src/lib/messaging/hooks/registry.ts create mode 100644 src/lib/messaging/hooks/types.ts diff --git a/src/lib/messaging/channels/discord/manifest.ts b/src/lib/messaging/channels/discord/manifest.ts new file mode 100644 index 0000000000..7822b5ee91 --- /dev/null +++ b/src/lib/messaging/channels/discord/manifest.ts @@ -0,0 +1,154 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { ChannelManifest } from "../../manifest"; + +export const discordManifest = { + schemaVersion: 1, + id: "discord", + displayName: "Discord", + description: "Discord bot messaging", + supportedAgents: ["openclaw", "hermes"], + auth: { + mode: "token-paste", + }, + inputs: [ + { + id: "botToken", + kind: "secret", + required: true, + envKey: "DISCORD_BOT_TOKEN", + prompt: { + label: "Discord Bot Token", + help: "Discord Developer Portal → Applications → Bot → Reset/Copy Token.", + }, + }, + { + id: "serverId", + kind: "config", + required: false, + envKey: "DISCORD_SERVER_ID", + statePath: "discordGuilds.serverId", + prompt: { + label: "Discord Server ID (for guild workspace access)", + help: "Enable Developer Mode in Discord, then right-click your server and copy the Server ID.", + }, + }, + { + id: "requireMention", + kind: "config", + required: false, + envKey: "DISCORD_REQUIRE_MENTION", + statePath: "discordGuilds.requireMention", + validValues: ["0", "1"], + prompt: { + label: "Discord mention mode", + help: "Choose whether the bot should reply only when @mentioned or to all messages in this server.", + }, + }, + { + id: "userId", + kind: "config", + required: false, + envKey: "DISCORD_USER_ID", + statePath: "discordGuilds.userIds", + prompt: { + label: "Discord User ID (optional guild allowlist)", + help: "Optional: enable Developer Mode in Discord, then right-click your user/avatar and copy the User ID. Leave blank to allow any member of the configured server to message the bot.", + }, + }, + ], + credentials: [ + { + id: "discordBotToken", + sourceInput: "botToken", + providerName: "{sandboxName}-discord-bridge", + providerEnvKey: "DISCORD_BOT_TOKEN", + placeholder: "openshell:resolve:env:DISCORD_BOT_TOKEN", + }, + ], + policyPresets: ["discord"], + render: [ + { + id: "discord-openclaw-account", + kind: "json-fragment", + agent: "openclaw", + target: "openclaw.json", + fragment: { + path: "channels.discord.accounts.default", + value: { + token: "{{credential.discordBotToken.placeholder}}", + enabled: true, + healthMonitor: { + enabled: false, + }, + proxy: "{{discordProxyUrl}}", + dmPolicy: "{{discord.allowedUsers.dmPolicy}}", + allowFrom: "{{discord.allowedUsers.values}}", + }, + }, + }, + { + id: "discord-openclaw-guilds", + kind: "json-fragment", + agent: "openclaw", + target: "openclaw.json", + fragment: { + path: "channels.discord", + value: { + groupPolicy: "allowlist", + guilds: "{{discord.guilds}}", + }, + }, + }, + { + id: "discord-hermes-env", + kind: "env-lines", + agent: "hermes", + target: "~/.hermes/.env", + lines: [ + "DISCORD_BOT_TOKEN={{credential.discordBotToken.placeholder}}", + "NEMOCLAW_DISCORD_GUILD_IDS={{discord.guildIds.csv}}", + "DISCORD_ALLOWED_USERS={{discord.allowedUsers.csv}}", + "DISCORD_ALLOW_ALL_USERS={{discord.allowAllUsers}}", + ], + }, + { + id: "discord-hermes-config", + kind: "json-fragment", + agent: "hermes", + target: "~/.hermes/config.yaml", + fragment: { + path: "discord", + value: { + require_mention: "{{discord.requireMention}}", + free_response_channels: "", + allowed_channels: "", + auto_thread: true, + reactions: true, + channel_prompts: {}, + }, + }, + }, + ], + state: { + persist: { + discordGuilds: ["serverId", "requireMention", "userId"], + }, + rebuildHydration: [ + { + statePath: "discordGuilds.serverId", + env: "DISCORD_SERVER_ID", + }, + { + statePath: "discordGuilds.requireMention", + env: "DISCORD_REQUIRE_MENTION", + }, + { + statePath: "discordGuilds.userIds", + env: "DISCORD_USER_ID", + }, + ], + }, + hooks: [], +} as const satisfies ChannelManifest; diff --git a/src/lib/messaging/channels/index.ts b/src/lib/messaging/channels/index.ts new file mode 100644 index 0000000000..9aef3f2d75 --- /dev/null +++ b/src/lib/messaging/channels/index.ts @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { ChannelManifestRegistry } from "../manifest"; +import { createChannelManifestRegistry } from "../manifest"; +import { discordManifest } from "./discord/manifest"; +import { slackManifest } from "./slack/manifest"; +import { telegramManifest } from "./telegram/manifest"; +import { whatsappManifest } from "./whatsapp/manifest"; + +export { discordManifest } from "./discord/manifest"; +export { slackManifest } from "./slack/manifest"; +export { telegramManifest } from "./telegram/manifest"; +export { whatsappManifest } from "./whatsapp/manifest"; + +export const BUILT_IN_CHANNEL_MANIFESTS = [ + telegramManifest, + discordManifest, + slackManifest, + whatsappManifest, +] as const; + +export function createBuiltInChannelManifestRegistry(): ChannelManifestRegistry { + return createChannelManifestRegistry(BUILT_IN_CHANNEL_MANIFESTS); +} diff --git a/src/lib/messaging/channels/manifests.test.ts b/src/lib/messaging/channels/manifests.test.ts new file mode 100644 index 0000000000..cc640e9cb8 --- /dev/null +++ b/src/lib/messaging/channels/manifests.test.ts @@ -0,0 +1,261 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { + buildDiscordConfig, + buildMessagingEnvLines, +} from "../../../../agents/hermes/config/messaging-config.ts"; +import { getChannelTokenKeys, KNOWN_CHANNELS } from "../../sandbox/channels"; +import type { ChannelInputSpec, ChannelManifest, ChannelRenderSpec } from "../manifest"; +import { + BUILT_IN_CHANNEL_MANIFESTS, + createBuiltInChannelManifestRegistry, + discordManifest, + slackManifest, + telegramManifest, + whatsappManifest, +} from "./index"; + +function findInput(manifest: ChannelManifest, inputId: string): ChannelInputSpec { + const input = manifest.inputs.find((entry) => entry.id === inputId); + if (!input) throw new Error(`missing input ${manifest.id}.${inputId}`); + return input; +} + +function findRender(manifest: ChannelManifest, renderId: string): ChannelRenderSpec { + const render = manifest.render.find((entry) => entry.id === renderId); + if (!render) throw new Error(`missing render ${manifest.id}.${renderId}`); + return render; +} + +function renderJson(manifest: ChannelManifest): string { + return JSON.stringify(manifest.render); +} + +describe("built-in channel manifests", () => { + it("registers the phase-1 built-in manifests without consuming them in workflows", () => { + const registry = createBuiltInChannelManifestRegistry(); + + expect(BUILT_IN_CHANNEL_MANIFESTS.map((manifest) => manifest.id)).toEqual([ + "telegram", + "discord", + "slack", + "whatsapp", + ]); + expect(registry.list().map((manifest) => manifest.id)).toEqual([ + "telegram", + "discord", + "slack", + "whatsapp", + ]); + expect(registry.listAvailable({ agent: "openclaw" }).map((manifest) => manifest.id)).toEqual([ + "telegram", + "discord", + "slack", + "whatsapp", + ]); + expect(registry.listAvailable({ agent: "hermes" }).map((manifest) => manifest.id)).toEqual([ + "telegram", + "discord", + "slack", + "whatsapp", + ]); + }); + + it("matches current sandbox channel metadata for prompts, auth, and policy presets", () => { + const manifests = { + telegram: telegramManifest, + discord: discordManifest, + slack: slackManifest, + whatsapp: whatsappManifest, + }; + + for (const [channelId, manifest] of Object.entries(manifests)) { + const legacy = KNOWN_CHANNELS[channelId]; + expect(manifest.description).toBe(legacy.description); + expect(manifest.policyPresets).toEqual([channelId]); + expect(manifest.supportedAgents).toEqual(["openclaw", "hermes"]); + expect(manifest.auth.mode).toBe(legacy.loginMethod ?? "token-paste"); + } + + expect(findInput(telegramManifest, "botToken").prompt).toEqual({ + label: KNOWN_CHANNELS.telegram.label, + help: KNOWN_CHANNELS.telegram.help, + }); + expect(findInput(discordManifest, "botToken").prompt).toEqual({ + label: KNOWN_CHANNELS.discord.label, + help: KNOWN_CHANNELS.discord.help, + }); + expect(findInput(slackManifest, "botToken").prompt).toMatchObject({ + label: KNOWN_CHANNELS.slack.label, + help: KNOWN_CHANNELS.slack.help, + placeholder: "xoxb-...", + }); + expect(findInput(slackManifest, "appToken").prompt).toMatchObject({ + label: KNOWN_CHANNELS.slack.appTokenLabel, + help: KNOWN_CHANNELS.slack.appTokenHelp, + placeholder: "xapp-...", + }); + }); + + it("declares Telegram env keys, policy, and OpenClaw/Hermes render intent", () => { + 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"); + expect(requireMention.envKey).toBe("TELEGRAM_REQUIRE_MENTION"); + expect(KNOWN_CHANNELS.telegram.allowIdsMode).toBe("dm"); + expect(telegramManifest.credentials).toEqual([ + { + id: "telegramBotToken", + sourceInput: "botToken", + providerName: "{sandboxName}-telegram-bridge", + providerEnvKey: "TELEGRAM_BOT_TOKEN", + 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"); + expect(renderJson(telegramManifest)).toContain("groupPolicy"); + expect(renderJson(telegramManifest)).toContain("channels.telegram.groups"); + expect(renderJson(telegramManifest)).toContain("telegramConfig.requireMention"); + }); + + it("declares Discord guild and allowlist render intent for both agents", () => { + const botToken = findInput(discordManifest, "botToken"); + 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"); + expect(requireMention.envKey).toBe("DISCORD_REQUIRE_MENTION"); + expect(userId.envKey).toBe("DISCORD_USER_ID"); + expect(KNOWN_CHANNELS.discord.allowIdsMode).toBe("guild"); + expect(discordManifest.credentials).toEqual([ + { + id: "discordBotToken", + sourceInput: "botToken", + providerName: "{sandboxName}-discord-bridge", + providerEnvKey: "DISCORD_BOT_TOKEN", + 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("channels.discord"); + expect(renderJson(discordManifest)).toContain("discord.guilds"); + expect(renderJson(discordManifest)).toContain("require_mention"); + }); + + it("declares Slack Bolt-compatible placeholders and allowlist render intent", () => { + const botToken = findInput(slackManifest, "botToken"); + const appToken = findInput(slackManifest, "appToken"); + const allowedUsers = findInput(slackManifest, "allowedUsers"); + const hermesLines = buildMessagingEnvLines( + new Set(["slack"]), + { slack: ["U0123456789"] }, + {}, + {}, + ); + + expect(getChannelTokenKeys(KNOWN_CHANNELS.slack)).toEqual([ + "SLACK_BOT_TOKEN", + "SLACK_APP_TOKEN", + ]); + expect(botToken.envKey).toBe("SLACK_BOT_TOKEN"); + expect(appToken.envKey).toBe("SLACK_APP_TOKEN"); + expect(allowedUsers.envKey).toBe("SLACK_ALLOWED_USERS"); + expect(KNOWN_CHANNELS.slack.allowIdsMode).toBe("dm"); + expect(slackManifest.credentials).toEqual([ + { + id: "slackBotToken", + sourceInput: "botToken", + providerName: "{sandboxName}-slack-bridge", + providerEnvKey: "SLACK_BOT_TOKEN", + placeholder: "xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN", + }, + { + id: "slackAppToken", + sourceInput: "appToken", + providerName: "{sandboxName}-slack-app", + providerEnvKey: "SLACK_APP_TOKEN", + 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"); + expect(renderJson(slackManifest)).toContain("allowedIds.slack.channels"); + }); + + it("declares WhatsApp as in-sandbox QR with no host-side token bindings", () => { + const openclawRender = findRender(whatsappManifest, "whatsapp-openclaw-account"); + 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.credentials).toEqual([]); + expect(whatsappManifest.policyPresets).toEqual(["whatsapp"]); + expect(openclawRender).toMatchObject({ + kind: "json-fragment", + agent: "openclaw", + target: "openclaw.json", + }); + expect(JSON.stringify(openclawRender)).toContain("channels.whatsapp.accounts.default"); + expect(hermesRender).toMatchObject({ + kind: "env-lines", + agent: "hermes", + target: "~/.hermes/.env", + }); + expect(hermesLines).toContain("WHATSAPP_ENABLED=true"); + expect(hermesLines).toContain("WHATSAPP_MODE=bot"); + 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 new file mode 100644 index 0000000000..4d4c026edf --- /dev/null +++ b/src/lib/messaging/channels/slack/manifest.ts @@ -0,0 +1,113 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { ChannelManifest } from "../../manifest"; + +export const slackManifest = { + schemaVersion: 1, + id: "slack", + displayName: "Slack", + description: "Slack bot messaging", + supportedAgents: ["openclaw", "hermes"], + auth: { + mode: "token-paste", + }, + inputs: [ + { + id: "botToken", + kind: "secret", + required: true, + envKey: "SLACK_BOT_TOKEN", + prompt: { + label: "Slack Bot Token", + help: "Slack API → Your Apps → OAuth & Permissions → Bot User OAuth Token (xoxb-...).", + placeholder: "xoxb-...", + }, + }, + { + id: "appToken", + kind: "secret", + required: true, + envKey: "SLACK_APP_TOKEN", + prompt: { + label: "Slack App Token (Socket Mode)", + help: "Slack API → Your Apps → Basic Information → App-Level Tokens (xapp-...).", + placeholder: "xapp-...", + }, + }, + { + id: "allowedUsers", + kind: "config", + required: false, + envKey: "SLACK_ALLOWED_USERS", + statePath: "allowedIds.slack", + prompt: { + label: "Slack Member IDs (comma-separated allowlist)", + help: "In Slack, open each allowed human user's profile -> More -> Copy member ID. Enter one or more comma-separated member IDs, not the app or bot user ID. Member IDs look like U01ABC2DEF3.", + }, + }, + ], + credentials: [ + { + id: "slackBotToken", + sourceInput: "botToken", + providerName: "{sandboxName}-slack-bridge", + providerEnvKey: "SLACK_BOT_TOKEN", + placeholder: "xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN", + }, + { + id: "slackAppToken", + sourceInput: "appToken", + providerName: "{sandboxName}-slack-app", + providerEnvKey: "SLACK_APP_TOKEN", + placeholder: "xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN", + }, + ], + policyPresets: ["slack"], + render: [ + { + id: "slack-openclaw-account", + kind: "json-fragment", + agent: "openclaw", + target: "openclaw.json", + fragment: { + path: "channels.slack.accounts.default", + value: { + 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}}", + }, + }, + }, + { + id: "slack-hermes-env", + kind: "env-lines", + agent: "hermes", + target: "~/.hermes/.env", + lines: [ + "SLACK_BOT_TOKEN={{credential.slackBotToken.placeholder}}", + "SLACK_APP_TOKEN={{credential.slackAppToken.placeholder}}", + "SLACK_ALLOWED_USERS={{allowedIds.slack.csv}}", + ], + }, + ], + state: { + persist: { + allowedIds: ["allowedUsers"], + }, + rebuildHydration: [ + { + statePath: "allowedIds.slack", + env: "SLACK_ALLOWED_USERS", + }, + ], + }, + hooks: [], +} as const satisfies ChannelManifest; diff --git a/src/lib/messaging/channels/telegram/manifest.ts b/src/lib/messaging/channels/telegram/manifest.ts new file mode 100644 index 0000000000..57f5480c69 --- /dev/null +++ b/src/lib/messaging/channels/telegram/manifest.ts @@ -0,0 +1,135 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { ChannelManifest } from "../../manifest"; + +export const telegramManifest = { + schemaVersion: 1, + id: "telegram", + displayName: "Telegram", + description: "Telegram bot messaging", + supportedAgents: ["openclaw", "hermes"], + auth: { + mode: "token-paste", + }, + inputs: [ + { + id: "botToken", + kind: "secret", + required: true, + envKey: "TELEGRAM_BOT_TOKEN", + prompt: { + label: "Telegram Bot Token", + help: "Create a bot via @BotFather on Telegram, then copy the token.", + }, + }, + { + id: "allowedIds", + kind: "config", + required: false, + envKey: "TELEGRAM_ALLOWED_IDS", + statePath: "allowedIds.telegram", + prompt: { + label: "Telegram User ID (for DM access)", + help: "Send /start to @userinfobot on Telegram to get your numeric user ID.", + }, + }, + { + id: "requireMention", + kind: "config", + required: false, + envKey: "TELEGRAM_REQUIRE_MENTION", + statePath: "telegramConfig.requireMention", + validValues: ["0", "1"], + prompt: { + label: "Telegram group mention mode", + help: "Controls Telegram group-chat behavior only — reply only when @mentioned vs. to all group messages. Direct messages are unaffected by this setting and remain subject to pairing and TELEGRAM_ALLOWED_IDS.", + }, + }, + ], + credentials: [ + { + id: "telegramBotToken", + sourceInput: "botToken", + providerName: "{sandboxName}-telegram-bridge", + providerEnvKey: "TELEGRAM_BOT_TOKEN", + placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", + }, + ], + policyPresets: ["telegram"], + render: [ + { + id: "telegram-openclaw-account", + kind: "json-fragment", + agent: "openclaw", + target: "openclaw.json", + fragment: { + path: "channels.telegram.accounts.default", + value: { + botToken: "{{credential.telegramBotToken.placeholder}}", + enabled: true, + healthMonitor: { + enabled: false, + }, + proxy: "{{proxyUrl}}", + groupPolicy: "open", + dmPolicy: "{{allowedIds.telegram.dmPolicy}}", + allowFrom: "{{allowedIds.telegram.values}}", + }, + }, + }, + { + id: "telegram-openclaw-groups", + kind: "json-fragment", + agent: "openclaw", + target: "openclaw.json", + fragment: { + path: "channels.telegram.groups", + value: { + "*": { + requireMention: "{{telegramConfig.requireMention}}", + }, + }, + }, + }, + { + id: "telegram-hermes-env", + kind: "env-lines", + agent: "hermes", + target: "~/.hermes/.env", + lines: [ + "TELEGRAM_BOT_TOKEN={{credential.telegramBotToken.placeholder}}", + "TELEGRAM_ALLOWED_USERS={{allowedIds.telegram.csv}}", + ], + }, + { + id: "telegram-hermes-config", + kind: "json-fragment", + agent: "hermes", + target: "~/.hermes/config.yaml", + fragment: { + path: "telegram", + value: { + require_mention: "{{telegramConfig.requireMention}}", + }, + }, + }, + ], + state: { + persist: { + allowedIds: ["allowedIds"], + telegramConfig: ["requireMention"], + }, + rebuildHydration: [ + { + statePath: "allowedIds.telegram", + env: "TELEGRAM_ALLOWED_IDS", + }, + { + statePath: "telegramConfig.requireMention", + env: "TELEGRAM_REQUIRE_MENTION", + }, + ], + }, + hooks: [], +} as const satisfies ChannelManifest; diff --git a/src/lib/messaging/channels/whatsapp/manifest.ts b/src/lib/messaging/channels/whatsapp/manifest.ts new file mode 100644 index 0000000000..906399f4a2 --- /dev/null +++ b/src/lib/messaging/channels/whatsapp/manifest.ts @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { ChannelManifest } from "../../manifest"; + +export const whatsappManifest = { + schemaVersion: 1, + id: "whatsapp", + displayName: "WhatsApp", + description: "WhatsApp Web messaging (QR pairing)", + supportedAgents: ["openclaw", "hermes"], + auth: { + mode: "in-sandbox-qr", + }, + inputs: [], + credentials: [], + policyPresets: ["whatsapp"], + render: [ + { + id: "whatsapp-openclaw-account", + kind: "json-fragment", + agent: "openclaw", + target: "openclaw.json", + fragment: { + path: "channels.whatsapp.accounts.default", + value: { + enabled: true, + healthMonitor: { + enabled: false, + }, + }, + }, + }, + { + id: "whatsapp-hermes-env", + kind: "env-lines", + agent: "hermes", + target: "~/.hermes/.env", + lines: ["WHATSAPP_ENABLED=true", "WHATSAPP_MODE=bot"], + }, + ], + state: {}, + hooks: [], +} as const satisfies ChannelManifest; diff --git a/src/lib/messaging/hooks/hook-runner.test.ts b/src/lib/messaging/hooks/hook-runner.test.ts new file mode 100644 index 0000000000..59dae75994 --- /dev/null +++ b/src/lib/messaging/hooks/hook-runner.test.ts @@ -0,0 +1,266 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import type { ChannelHookSpec } from "../manifest"; +import { MessagingHookRegistry, runMessagingHook } from "./index"; + +const HOST_QR_HOOK = { + id: "wechat-host-qr", + phase: "enroll", + handler: "wechat.ilinkLogin", + outputs: [ + { + id: "botToken", + kind: "secret", + required: true, + }, + { + id: "accountId", + kind: "config", + required: true, + }, + ], +} as const satisfies ChannelHookSpec; + +describe("MessagingHookRegistry", () => { + it("registers fake handlers by stable handler id", async () => { + const registry = new MessagingHookRegistry([ + { + id: "wechat.ilinkLogin", + handler: (context) => ({ + outputs: { + botToken: { + kind: "secret", + value: `token-for-${context.channelId}`, + }, + accountId: { + kind: "config", + value: "wxid_demo", + }, + }, + }), + }, + ]); + + const result = await runMessagingHook(HOST_QR_HOOK, registry, { + channelId: "wechat", + }); + + expect(result).toEqual({ + hookId: "wechat-host-qr", + handlerId: "wechat.ilinkLogin", + phase: "enroll", + outputs: { + botToken: { + kind: "secret", + value: "token-for-wechat", + }, + accountId: { + kind: "config", + value: "wxid_demo", + }, + }, + }); + }); + + it("passes hook metadata, phase, and serializable inputs to fake handlers", async () => { + const calls: unknown[] = []; + const hook = { + id: "wechat-seed-openclaw-account", + phase: "post-agent-install", + handler: "wechat.seedOpenClawAccount", + inputs: ["wechatConfig.accountId"], + outputs: [ + { + id: "accountFile", + kind: "build-file", + required: true, + }, + ], + onFailure: "abort", + } as const satisfies ChannelHookSpec; + const registry = new MessagingHookRegistry([ + { + id: "wechat.seedOpenClawAccount", + handler: (context) => { + calls.push(context); + return { + outputs: { + accountFile: { + kind: "build-file", + value: { + path: "accounts/default.json", + mode: "seed", + }, + }, + }, + }; + }, + }, + ]); + + const result = await runMessagingHook(hook, registry, { + channelId: "wechat", + inputs: { + "wechatConfig.accountId": "ilink-bot-42", + }, + }); + + expect(calls).toEqual([ + { + channelId: "wechat", + hookId: "wechat-seed-openclaw-account", + phase: "post-agent-install", + inputs: { + "wechatConfig.accountId": "ilink-bot-42", + }, + }, + ]); + expect(result.outputs.accountFile).toEqual({ + kind: "build-file", + value: { + path: "accounts/default.json", + mode: "seed", + }, + }); + }); + + it("rejects duplicate handler ids", () => { + const handler = () => ({}); + + expect( + () => + new MessagingHookRegistry([ + { id: "wechat.ilinkLogin", handler }, + { id: "wechat.ilinkLogin", handler }, + ]), + ).toThrow("Duplicate messaging hook handler id 'wechat.ilinkLogin'"); + }); + + it("reports missing handlers deterministically", async () => { + await expect( + runMessagingHook(HOST_QR_HOOK, new MessagingHookRegistry(), { + channelId: "wechat", + }), + ).rejects.toThrow("Missing messaging hook handler 'wechat.ilinkLogin'"); + }); + + it("checks required declared outputs", async () => { + const registry = new MessagingHookRegistry([ + { + id: "wechat.ilinkLogin", + handler: () => ({ + outputs: { + botToken: { + kind: "secret", + value: "token", + }, + }, + }), + }, + ]); + + await expect( + runMessagingHook(HOST_QR_HOOK, registry, { + channelId: "wechat", + }), + ).rejects.toThrow("Hook 'wechat-host-qr' missing required output 'accountId'"); + }); + + it("checks output ids, kinds, and serializable values", async () => { + const circular: Record = {}; + circular.self = circular; + const registry = new MessagingHookRegistry([ + { + id: "wechat.kindMismatch", + handler: () => ({ + outputs: { + botToken: { + kind: "config", + value: "token", + }, + accountId: { + kind: "config", + value: "wxid_demo", + }, + }, + }), + }, + { + id: "wechat.extraOutput", + handler: () => ({ + outputs: { + botToken: { + kind: "secret", + value: "token", + }, + accountId: { + kind: "config", + value: "wxid_demo", + }, + buildPath: { + kind: "build-file", + value: "accounts.json", + }, + }, + }), + }, + { + id: "wechat.badValue", + handler: () => ({ + outputs: { + botToken: { + kind: "secret", + value: circular as never, + }, + accountId: { + kind: "config", + value: "wxid_demo", + }, + }, + }), + }, + ]); + + await expect( + runMessagingHook( + { + ...HOST_QR_HOOK, + handler: "wechat.kindMismatch", + }, + registry, + { + channelId: "wechat", + }, + ), + ).rejects.toThrow( + "Hook 'wechat-host-qr' output 'botToken' kind 'config' does not match declared kind 'secret'", + ); + await expect( + runMessagingHook( + { + ...HOST_QR_HOOK, + handler: "wechat.extraOutput", + }, + registry, + { + channelId: "wechat", + }, + ), + ).rejects.toThrow("Hook 'wechat-host-qr' returned undeclared output 'buildPath'"); + await expect( + runMessagingHook( + { + ...HOST_QR_HOOK, + handler: "wechat.badValue", + }, + registry, + { + channelId: "wechat", + }, + ), + ).rejects.toThrow("Hook 'wechat-host-qr' output 'botToken' is not serializable"); + }); +}); diff --git a/src/lib/messaging/hooks/hook-runner.ts b/src/lib/messaging/hooks/hook-runner.ts new file mode 100644 index 0000000000..803732a4d1 --- /dev/null +++ b/src/lib/messaging/hooks/hook-runner.ts @@ -0,0 +1,105 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { + ChannelHookOutputSpec, + ChannelHookSpec, + MessagingSerializableValue, +} from "../manifest"; +import { MessagingHookRegistry } from "./registry"; +import type { + MessagingHookOutputMap, + MessagingHookOutputValue, + MessagingHookRunContext, + MessagingHookRunResult, +} from "./types"; + +const EMPTY_OUTPUTS: MessagingHookOutputMap = Object.freeze({}); + +export async function runMessagingHook( + hook: ChannelHookSpec, + registry: MessagingHookRegistry, + context: MessagingHookRunContext, +): Promise { + const handler = registry.require(hook.handler); + const result = await handler({ + channelId: context.channelId, + hookId: hook.id, + phase: hook.phase, + inputs: context.inputs, + }); + const outputs = result.outputs ?? EMPTY_OUTPUTS; + + assertHookOutputsMatchDeclaration(hook, outputs); + + return { + hookId: hook.id, + handlerId: hook.handler, + phase: hook.phase, + outputs, + }; +} + +function assertHookOutputsMatchDeclaration( + hook: ChannelHookSpec, + outputs: MessagingHookOutputMap, +): void { + const declarations = new Map((hook.outputs ?? []).map((output) => [output.id, output])); + + for (const declaration of hook.outputs ?? []) { + if (declaration.required && !Object.hasOwn(outputs, declaration.id)) { + throw new Error( + `Hook '${hook.id}' missing required output '${declaration.id}'`, + ); + } + } + + for (const [outputId, output] of Object.entries(outputs)) { + const declaration = declarations.get(outputId); + if (!declaration) { + throw new Error(`Hook '${hook.id}' returned undeclared output '${outputId}'`); + } + assertOutputMatchesDeclaration(hook, outputId, output, declaration); + } +} + +function assertOutputMatchesDeclaration( + hook: ChannelHookSpec, + outputId: string, + output: MessagingHookOutputValue, + declaration: ChannelHookOutputSpec, +): void { + if (output.kind !== declaration.kind) { + throw new Error( + `Hook '${hook.id}' output '${outputId}' kind '${output.kind}' does not match declared kind '${declaration.kind}'`, + ); + } + if (!isMessagingSerializableValue(output.value)) { + throw new Error(`Hook '${hook.id}' output '${outputId}' is not serializable`); + } +} + +function isMessagingSerializableValue( + value: unknown, + seen: WeakSet = new WeakSet(), +): value is MessagingSerializableValue { + if (value === null) return true; + + const valueType = typeof value; + if (valueType === "string" || valueType === "boolean") return true; + if (valueType === "number") return Number.isFinite(value); + if (valueType !== "object") return false; + + const objectValue = value as object; + if (seen.has(objectValue)) return false; + seen.add(objectValue); + + if (Array.isArray(value)) { + return value.every((entry) => isMessagingSerializableValue(entry, seen)); + } + + const prototype = Object.getPrototypeOf(objectValue); + if (prototype !== Object.prototype && prototype !== null) return false; + + return Object.values(objectValue).every((entry) => isMessagingSerializableValue(entry, seen)); +} diff --git a/src/lib/messaging/hooks/index.ts b/src/lib/messaging/hooks/index.ts new file mode 100644 index 0000000000..3374d63971 --- /dev/null +++ b/src/lib/messaging/hooks/index.ts @@ -0,0 +1,6 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export * from "./hook-runner"; +export * from "./registry"; +export type * from "./types"; diff --git a/src/lib/messaging/hooks/registry.ts b/src/lib/messaging/hooks/registry.ts new file mode 100644 index 0000000000..9c2c2efc63 --- /dev/null +++ b/src/lib/messaging/hooks/registry.ts @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { + MessagingHookHandler, + MessagingHookHandlerId, + MessagingHookRegistration, +} from "./types"; + +/** In-memory lookup table for manifest hook handler ids. */ +export class MessagingHookRegistry { + private readonly handlers = new Map(); + + constructor(registrations: readonly MessagingHookRegistration[] = []) { + for (const registration of registrations) { + this.register(registration.id, registration.handler); + } + } + + register(id: MessagingHookHandlerId, handler: MessagingHookHandler): this { + if (this.handlers.has(id)) { + throw new Error(`Duplicate messaging hook handler id '${id}'`); + } + + this.handlers.set(id, handler); + return this; + } + + get(id: MessagingHookHandlerId): MessagingHookHandler | undefined { + return this.handlers.get(id); + } + + require(id: MessagingHookHandlerId): MessagingHookHandler { + const handler = this.get(id); + if (!handler) { + throw new Error(`Missing messaging hook handler '${id}'`); + } + return handler; + } + + listIds(): MessagingHookHandlerId[] { + return Array.from(this.handlers.keys()); + } +} + +export function createMessagingHookRegistry( + registrations: readonly MessagingHookRegistration[] = [], +): MessagingHookRegistry { + return new MessagingHookRegistry(registrations); +} diff --git a/src/lib/messaging/hooks/types.ts b/src/lib/messaging/hooks/types.ts new file mode 100644 index 0000000000..b2cf33aa55 --- /dev/null +++ b/src/lib/messaging/hooks/types.ts @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { + ChannelHookOutputSpec, + ChannelHookPhase, + MessagingChannelId, + MessagingSerializableValue, +} from "../manifest"; + +/** Stable hook handler identifier referenced from a channel manifest. */ +export type MessagingHookHandlerId = string; + +/** Serializable input values passed to a hook handler at execution time. */ +export type MessagingHookInputMap = Readonly>; + +/** Minimal runner context needed to execute one hook for one channel. */ +export interface MessagingHookRunContext { + readonly channelId: MessagingChannelId; + readonly inputs?: MessagingHookInputMap; +} + +/** Context visible to a registered hook handler. */ +export interface MessagingHookContext extends MessagingHookRunContext { + readonly hookId: string; + readonly phase: ChannelHookPhase; +} + +/** One named output emitted by a hook handler. */ +export interface MessagingHookOutputValue { + readonly kind: ChannelHookOutputSpec["kind"]; + readonly value: MessagingSerializableValue; +} + +/** Hook outputs keyed by the ids declared in the manifest hook spec. */ +export type MessagingHookOutputMap = Readonly>; + +/** Serializable data returned by a hook handler after any side effects complete. */ +export interface MessagingHookResult { + readonly outputs?: MessagingHookOutputMap; +} + +/** Function registered under a stable hook handler id. */ +export type MessagingHookHandler = ( + context: MessagingHookContext, +) => MessagingHookResult | Promise; + +/** Constructor entry used to seed a hook registry in tests or later bootstraps. */ +export interface MessagingHookRegistration { + readonly id: MessagingHookHandlerId; + readonly handler: MessagingHookHandler; +} + +/** Serializable runner result for a completed hook. */ +export interface MessagingHookRunResult { + readonly hookId: string; + readonly handlerId: MessagingHookHandlerId; + readonly phase: ChannelHookPhase; + readonly outputs: MessagingHookOutputMap; +} diff --git a/src/lib/messaging/index.ts b/src/lib/messaging/index.ts index 188c2a15fc..ab993fe4c3 100644 --- a/src/lib/messaging/index.ts +++ b/src/lib/messaging/index.ts @@ -1,4 +1,6 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +export * from "./channels"; +export * from "./hooks"; export * from "./manifest"; diff --git a/src/lib/messaging/manifest/registry.test.ts b/src/lib/messaging/manifest/registry.test.ts index cba1cef477..c883188815 100644 --- a/src/lib/messaging/manifest/registry.test.ts +++ b/src/lib/messaging/manifest/registry.test.ts @@ -2,9 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, expect, it } from "vitest"; - -import { ChannelManifestRegistry, createChannelManifestRegistry } from "./index"; import type { ChannelManifest } from "./index"; +import { ChannelManifestRegistry, createChannelManifestRegistry } from "./index"; function makeManifest( id: string, diff --git a/src/lib/messaging/manifest/types.test.ts b/src/lib/messaging/manifest/types.test.ts index 138beddbf8..d25c737428 100644 --- a/src/lib/messaging/manifest/types.test.ts +++ b/src/lib/messaging/manifest/types.test.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { readFileSync, readdirSync, statSync } from "node:fs"; +import { readdirSync, readFileSync, statSync } from "node:fs"; import path from "node:path"; import { describe, expect, it } from "vitest"; @@ -318,9 +318,13 @@ describe("messaging manifest type contracts", () => { "gateway", "state/registry", "credentials", + "ext/wechat", + "host-qr-dispatch", + "host-qr-handlers", "node:fs", "node:child_process", "child_process", + "qrcode-terminal", "adapters/openshell", "src/commands", "lib/actions", From 7f79ccfc7fef43e992cb3efde6eaef01f1d58697 Mon Sep 17 00:00:00 2001 From: San Dang Date: Fri, 22 May 2026 10:30:17 +0700 Subject: [PATCH 02/20] fix(messaging): allow shared hook output values Signed-off-by: San Dang --- src/lib/messaging/hooks/hook-runner.test.ts | 44 +++++++++++++++++++++ src/lib/messaging/hooks/hook-runner.ts | 24 ++++++----- 2 files changed, 59 insertions(+), 9 deletions(-) diff --git a/src/lib/messaging/hooks/hook-runner.test.ts b/src/lib/messaging/hooks/hook-runner.test.ts index 59dae75994..b3e0a62527 100644 --- a/src/lib/messaging/hooks/hook-runner.test.ts +++ b/src/lib/messaging/hooks/hook-runner.test.ts @@ -169,6 +169,50 @@ describe("MessagingHookRegistry", () => { ).rejects.toThrow("Hook 'wechat-host-qr' missing required output 'accountId'"); }); + it("allows shared non-cyclic object references in serializable outputs", async () => { + const shared = { + path: "accounts/default.json", + mode: "seed", + }; + const registry = new MessagingHookRegistry([ + { + id: "wechat.sharedOutput", + handler: () => ({ + outputs: { + botToken: { + kind: "secret", + value: [shared, shared], + }, + accountId: { + kind: "config", + value: "wxid_demo", + }, + }, + }), + }, + ]); + + await expect( + runMessagingHook( + { + ...HOST_QR_HOOK, + handler: "wechat.sharedOutput", + }, + registry, + { + channelId: "wechat", + }, + ), + ).resolves.toMatchObject({ + outputs: { + botToken: { + kind: "secret", + value: [shared, shared], + }, + }, + }); + }); + it("checks output ids, kinds, and serializable values", async () => { const circular: Record = {}; circular.self = circular; diff --git a/src/lib/messaging/hooks/hook-runner.ts b/src/lib/messaging/hooks/hook-runner.ts index 803732a4d1..b7f227656a 100644 --- a/src/lib/messaging/hooks/hook-runner.ts +++ b/src/lib/messaging/hooks/hook-runner.ts @@ -81,7 +81,7 @@ function assertOutputMatchesDeclaration( function isMessagingSerializableValue( value: unknown, - seen: WeakSet = new WeakSet(), + visiting: WeakSet = new WeakSet(), ): value is MessagingSerializableValue { if (value === null) return true; @@ -91,15 +91,21 @@ function isMessagingSerializableValue( if (valueType !== "object") return false; const objectValue = value as object; - if (seen.has(objectValue)) return false; - seen.add(objectValue); + if (visiting.has(objectValue)) return false; + visiting.add(objectValue); - if (Array.isArray(value)) { - return value.every((entry) => isMessagingSerializableValue(entry, seen)); - } + try { + if (Array.isArray(value)) { + return value.every((entry) => isMessagingSerializableValue(entry, visiting)); + } - const prototype = Object.getPrototypeOf(objectValue); - if (prototype !== Object.prototype && prototype !== null) return false; + const prototype = Object.getPrototypeOf(objectValue); + if (prototype !== Object.prototype && prototype !== null) return false; - return Object.values(objectValue).every((entry) => isMessagingSerializableValue(entry, seen)); + return Object.values(objectValue).every((entry) => + isMessagingSerializableValue(entry, visiting), + ); + } finally { + visiting.delete(objectValue); + } } From 3a925038e2e5ad0b1c1cc41ae1d4b83a886ac085 Mon Sep 17 00:00:00 2001 From: San Dang Date: Fri, 22 May 2026 14:58:18 +0700 Subject: [PATCH 03/20] feat(messaging): declare enrollment hooks Signed-off-by: San Dang --- .../messaging/channels/discord/manifest.ts | 16 +- src/lib/messaging/channels/index.ts | 3 + src/lib/messaging/channels/manifests.test.ts | 126 ++++++++++++-- src/lib/messaging/channels/slack/manifest.ts | 21 ++- .../messaging/channels/telegram/manifest.ts | 16 +- .../channels/wechat/hooks/fakes.test.ts | 113 ++++++++++++ .../messaging/channels/wechat/hooks/fakes.ts | 123 +++++++++++++ src/lib/messaging/channels/wechat/manifest.ts | 164 ++++++++++++++++++ src/lib/messaging/hooks/common/index.ts | 4 + .../hooks/common/token-paste.test.ts | 70 ++++++++ src/lib/messaging/hooks/common/token-paste.ts | 31 ++++ src/lib/messaging/hooks/hook-runner.test.ts | 7 + src/lib/messaging/hooks/hook-runner.ts | 1 + src/lib/messaging/hooks/index.ts | 1 + src/lib/messaging/hooks/types.ts | 1 + 15 files changed, 681 insertions(+), 16 deletions(-) create mode 100644 src/lib/messaging/channels/wechat/hooks/fakes.test.ts create mode 100644 src/lib/messaging/channels/wechat/hooks/fakes.ts create mode 100644 src/lib/messaging/channels/wechat/manifest.ts create mode 100644 src/lib/messaging/hooks/common/index.ts create mode 100644 src/lib/messaging/hooks/common/token-paste.test.ts create mode 100644 src/lib/messaging/hooks/common/token-paste.ts diff --git a/src/lib/messaging/channels/discord/manifest.ts b/src/lib/messaging/channels/discord/manifest.ts index 7822b5ee91..2daddfde27 100644 --- a/src/lib/messaging/channels/discord/manifest.ts +++ b/src/lib/messaging/channels/discord/manifest.ts @@ -150,5 +150,19 @@ export const discordManifest = { }, ], }, - hooks: [], + hooks: [ + { + id: "discord-token-paste", + phase: "enroll", + handler: "common.tokenPaste", + outputs: [ + { + id: "botToken", + kind: "secret", + required: true, + }, + ], + onFailure: "skip-channel", + }, + ], } as const satisfies ChannelManifest; diff --git a/src/lib/messaging/channels/index.ts b/src/lib/messaging/channels/index.ts index 9aef3f2d75..441e224122 100644 --- a/src/lib/messaging/channels/index.ts +++ b/src/lib/messaging/channels/index.ts @@ -6,16 +6,19 @@ import { createChannelManifestRegistry } from "../manifest"; import { discordManifest } from "./discord/manifest"; import { slackManifest } from "./slack/manifest"; import { telegramManifest } from "./telegram/manifest"; +import { wechatManifest } from "./wechat/manifest"; import { whatsappManifest } from "./whatsapp/manifest"; export { discordManifest } from "./discord/manifest"; export { slackManifest } from "./slack/manifest"; export { telegramManifest } from "./telegram/manifest"; +export { wechatManifest } from "./wechat/manifest"; export { whatsappManifest } from "./whatsapp/manifest"; export const BUILT_IN_CHANNEL_MANIFESTS = [ telegramManifest, discordManifest, + wechatManifest, slackManifest, whatsappManifest, ] as const; diff --git a/src/lib/messaging/channels/manifests.test.ts b/src/lib/messaging/channels/manifests.test.ts index cc640e9cb8..5704200885 100644 --- a/src/lib/messaging/channels/manifests.test.ts +++ b/src/lib/messaging/channels/manifests.test.ts @@ -7,7 +7,8 @@ import { buildDiscordConfig, buildMessagingEnvLines, } from "../../../../agents/hermes/config/messaging-config.ts"; -import { getChannelTokenKeys, KNOWN_CHANNELS } from "../../sandbox/channels"; +import { getChannelTokenKeys, KNOWN_CHANNELS, knownChannelNames } from "../../sandbox/channels"; +import { COMMON_TOKEN_PASTE_HOOK_HANDLER_ID } from "../hooks/common"; import type { ChannelInputSpec, ChannelManifest, ChannelRenderSpec } from "../manifest"; import { BUILT_IN_CHANNEL_MANIFESTS, @@ -15,6 +16,7 @@ import { discordManifest, slackManifest, telegramManifest, + wechatManifest, whatsappManifest, } from "./index"; @@ -34,31 +36,41 @@ function renderJson(manifest: ChannelManifest): string { return JSON.stringify(manifest.render); } +function expectTokenPasteEnrollHook(manifest: ChannelManifest, outputIds: readonly string[]): void { + expect(manifest.hooks).toEqual([ + { + id: `${manifest.id}-token-paste`, + phase: "enroll", + handler: COMMON_TOKEN_PASTE_HOOK_HANDLER_ID, + outputs: outputIds.map((id) => ({ + id, + kind: "secret", + required: true, + })), + onFailure: "skip-channel", + }, + ]); +} + describe("built-in channel manifests", () => { it("registers the phase-1 built-in manifests without consuming them in workflows", () => { const registry = createBuiltInChannelManifestRegistry(); - expect(BUILT_IN_CHANNEL_MANIFESTS.map((manifest) => manifest.id)).toEqual([ - "telegram", - "discord", - "slack", - "whatsapp", - ]); - expect(registry.list().map((manifest) => manifest.id)).toEqual([ - "telegram", - "discord", - "slack", - "whatsapp", - ]); + expect(BUILT_IN_CHANNEL_MANIFESTS.map((manifest) => manifest.id)).toEqual( + knownChannelNames(), + ); + expect(registry.list().map((manifest) => manifest.id)).toEqual(knownChannelNames()); expect(registry.listAvailable({ agent: "openclaw" }).map((manifest) => manifest.id)).toEqual([ "telegram", "discord", + "wechat", "slack", "whatsapp", ]); expect(registry.listAvailable({ agent: "hermes" }).map((manifest) => manifest.id)).toEqual([ "telegram", "discord", + "wechat", "slack", "whatsapp", ]); @@ -68,6 +80,7 @@ describe("built-in channel manifests", () => { const manifests = { telegram: telegramManifest, discord: discordManifest, + wechat: wechatManifest, slack: slackManifest, whatsapp: whatsappManifest, }; @@ -98,6 +111,10 @@ describe("built-in channel manifests", () => { help: KNOWN_CHANNELS.slack.appTokenHelp, placeholder: "xapp-...", }); + expect(findInput(wechatManifest, "botToken").prompt).toEqual({ + label: KNOWN_CHANNELS.wechat.label, + help: KNOWN_CHANNELS.wechat.help, + }); }); it("declares Telegram env keys, policy, and OpenClaw/Hermes render intent", () => { @@ -133,6 +150,7 @@ describe("built-in channel manifests", () => { expect(renderJson(telegramManifest)).toContain("groupPolicy"); expect(renderJson(telegramManifest)).toContain("channels.telegram.groups"); expect(renderJson(telegramManifest)).toContain("telegramConfig.requireMention"); + expectTokenPasteEnrollHook(telegramManifest, ["botToken"]); }); it("declares Discord guild and allowlist render intent for both agents", () => { @@ -184,6 +202,7 @@ describe("built-in channel manifests", () => { expect(renderJson(discordManifest)).toContain("channels.discord"); expect(renderJson(discordManifest)).toContain("discord.guilds"); expect(renderJson(discordManifest)).toContain("require_mention"); + expectTokenPasteEnrollHook(discordManifest, ["botToken"]); }); it("declares Slack Bolt-compatible placeholders and allowlist render intent", () => { @@ -230,6 +249,87 @@ describe("built-in channel manifests", () => { expect(hermesLines).toContain("SLACK_ALLOWED_USERS=U0123456789"); expect(renderJson(slackManifest)).toContain("channels.slack.accounts.default"); expect(renderJson(slackManifest)).toContain("allowedIds.slack.channels"); + expectTokenPasteEnrollHook(slackManifest, ["botToken", "appToken"]); + }); + + it("declares WeChat host-QR hooks, state hydration, provider binding, and Hermes env intent", () => { + const botToken = findInput(wechatManifest, "botToken"); + const accountId = findInput(wechatManifest, "accountId"); + 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"); + expect(accountId.envKey).toBe("WECHAT_ACCOUNT_ID"); + expect(baseUrl.envKey).toBe("WECHAT_BASE_URL"); + expect(userId.envKey).toBe("WECHAT_USER_ID"); + expect(allowedIds.envKey).toBe("WECHAT_ALLOWED_IDS"); + expect(KNOWN_CHANNELS.wechat.allowIdsMode).toBe("dm"); + expect(wechatManifest.credentials).toEqual([ + { + id: "wechatBotToken", + sourceInput: "botToken", + providerName: "{sandboxName}-wechat-bridge", + providerEnvKey: "WECHAT_BOT_TOKEN", + placeholder: "openshell:resolve:env:WECHAT_BOT_TOKEN", + }, + ]); + expect(wechatManifest.state.persist).toEqual({ + wechatConfig: ["accountId", "baseUrl", "userId"], + allowedIds: ["allowedIds"], + }); + expect(wechatManifest.state.rebuildHydration).toEqual([ + { + statePath: "wechatConfig.accountId", + env: "WECHAT_ACCOUNT_ID", + }, + { + statePath: "wechatConfig.baseUrl", + env: "WECHAT_BASE_URL", + }, + { + statePath: "wechatConfig.userId", + env: "WECHAT_USER_ID", + }, + { + statePath: "allowedIds.wechat", + 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"); + expect(renderJson(wechatManifest)).toContain("WEIXIN_TOKEN"); + expect(renderJson(wechatManifest)).toContain("credential.wechatBotToken.placeholder"); + expect(wechatManifest.hooks.map((hook) => hook.handler)).toEqual([ + "wechat.ilinkLogin", + "wechat.seedOpenClawAccount", + ]); + expect(wechatManifest.hooks[1]?.outputs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "openclawWeixinAccountFile", + kind: "build-file", + }), + expect.objectContaining({ + id: "openclawConfigPatch", + kind: "build-file", + }), + ]), + ); }); it("declares WhatsApp as in-sandbox QR with no host-side token bindings", () => { diff --git a/src/lib/messaging/channels/slack/manifest.ts b/src/lib/messaging/channels/slack/manifest.ts index 4d4c026edf..25033ccecb 100644 --- a/src/lib/messaging/channels/slack/manifest.ts +++ b/src/lib/messaging/channels/slack/manifest.ts @@ -109,5 +109,24 @@ export const slackManifest = { }, ], }, - hooks: [], + hooks: [ + { + id: "slack-token-paste", + phase: "enroll", + handler: "common.tokenPaste", + outputs: [ + { + id: "botToken", + kind: "secret", + required: true, + }, + { + id: "appToken", + kind: "secret", + required: true, + }, + ], + onFailure: "skip-channel", + }, + ], } as const satisfies ChannelManifest; diff --git a/src/lib/messaging/channels/telegram/manifest.ts b/src/lib/messaging/channels/telegram/manifest.ts index 57f5480c69..a322c9a090 100644 --- a/src/lib/messaging/channels/telegram/manifest.ts +++ b/src/lib/messaging/channels/telegram/manifest.ts @@ -131,5 +131,19 @@ export const telegramManifest = { }, ], }, - hooks: [], + hooks: [ + { + id: "telegram-token-paste", + phase: "enroll", + handler: "common.tokenPaste", + outputs: [ + { + id: "botToken", + kind: "secret", + required: true, + }, + ], + onFailure: "skip-channel", + }, + ], } as const satisfies ChannelManifest; diff --git a/src/lib/messaging/channels/wechat/hooks/fakes.test.ts b/src/lib/messaging/channels/wechat/hooks/fakes.test.ts new file mode 100644 index 0000000000..ba25417532 --- /dev/null +++ b/src/lib/messaging/channels/wechat/hooks/fakes.test.ts @@ -0,0 +1,113 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { MessagingHookRegistry, runMessagingHook } from "../../../hooks"; +import { wechatManifest } from "../manifest"; +import { + FAKE_WECHAT_HOOK_REGISTRATIONS, + WECHAT_ILINK_LOGIN_HOOK_ID, + WECHAT_SEED_OPENCLAW_ACCOUNT_HOOK_ID, +} from "./fakes"; + +describe("WeChat fake hook implementations", () => { + it("uses fake registrations with the same handler ids declared by the manifest", () => { + expect(FAKE_WECHAT_HOOK_REGISTRATIONS.map((registration) => registration.id)).toEqual([ + WECHAT_ILINK_LOGIN_HOOK_ID, + WECHAT_SEED_OPENCLAW_ACCOUNT_HOOK_ID, + ]); + expect(wechatManifest.hooks.map((hook) => hook.handler)).toEqual([ + WECHAT_ILINK_LOGIN_HOOK_ID, + WECHAT_SEED_OPENCLAW_ACCOUNT_HOOK_ID, + ]); + }); + + it("shows the host-QR hook output shape without running real QR login", async () => { + const registry = new MessagingHookRegistry(FAKE_WECHAT_HOOK_REGISTRATIONS); + const hostQrHook = wechatManifest.hooks[0]; + + if (!hostQrHook) throw new Error("missing WeChat host-QR hook"); + + await expect( + runMessagingHook(hostQrHook, registry, { + channelId: "wechat", + }), + ).resolves.toMatchObject({ + handlerId: WECHAT_ILINK_LOGIN_HOOK_ID, + outputs: { + botToken: { + kind: "secret", + }, + accountId: { + kind: "config", + value: "fake-wechat-account", + }, + baseUrl: { + kind: "config", + }, + userId: { + kind: "config", + }, + }, + }); + }); + + it("shows the account-seed hook output shape without writing files", async () => { + const registry = new MessagingHookRegistry(FAKE_WECHAT_HOOK_REGISTRATIONS); + const seedHook = wechatManifest.hooks[1]; + + if (!seedHook) throw new Error("missing WeChat seed hook"); + + await expect( + runMessagingHook(seedHook, registry, { + channelId: "wechat", + inputs: { + "wechatConfig.accountId": "fake-wechat-account", + "wechatConfig.baseUrl": "https://ilinkai.wechat.example", + "wechatConfig.userId": "fake-wechat-user", + "credential.wechatBotToken.placeholder": "openshell:resolve:env:WECHAT_BOT_TOKEN", + }, + }), + ).resolves.toMatchObject({ + handlerId: WECHAT_SEED_OPENCLAW_ACCOUNT_HOOK_ID, + outputs: { + openclawWeixinAccountsIndex: { + kind: "build-file", + value: { + path: "openclaw-weixin/accounts.json", + content: ["fake-wechat-account"], + }, + }, + openclawWeixinAccountFile: { + kind: "build-file", + value: { + path: "openclaw-weixin/accounts/fake-wechat-account.json", + content: { + token: "openshell:resolve:env:WECHAT_BOT_TOKEN", + baseUrl: "https://ilinkai.wechat.example", + userId: "fake-wechat-user", + }, + }, + }, + openclawConfigPatch: { + kind: "build-file", + value: { + path: "openclaw.json", + merge: { + channels: { + "openclaw-weixin": { + accounts: { + "fake-wechat-account": { + enabled: true, + }, + }, + }, + }, + }, + }, + }, + }, + }); + }); +}); diff --git a/src/lib/messaging/channels/wechat/hooks/fakes.ts b/src/lib/messaging/channels/wechat/hooks/fakes.ts new file mode 100644 index 0000000000..d7a856b0ca --- /dev/null +++ b/src/lib/messaging/channels/wechat/hooks/fakes.ts @@ -0,0 +1,123 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { + MessagingHookHandler, + MessagingHookInputMap, + MessagingHookRegistration, +} from "../../../hooks"; + +export const WECHAT_ILINK_LOGIN_HOOK_ID = "wechat.ilinkLogin"; +export const WECHAT_SEED_OPENCLAW_ACCOUNT_HOOK_ID = "wechat.seedOpenClawAccount"; + +const FAKE_WECHAT_ACCOUNT_ID = "fake-wechat-account"; +const FAKE_WECHAT_BASE_URL = "https://ilinkai.wechat.example"; +const FAKE_WECHAT_USER_ID = "fake-wechat-user"; +const FAKE_WECHAT_TOKEN_PLACEHOLDER = "openshell:resolve:env:WECHAT_BOT_TOKEN"; +const FAKE_WECHAT_SAVED_AT = "2026-01-01T00:00:00.000Z"; + +export const fakeWechatIlinkLoginHook: MessagingHookHandler = () => ({ + outputs: { + botToken: { + kind: "secret", + value: "fake-wechat-token", + }, + accountId: { + kind: "config", + value: FAKE_WECHAT_ACCOUNT_ID, + }, + baseUrl: { + kind: "config", + value: FAKE_WECHAT_BASE_URL, + }, + userId: { + kind: "config", + value: FAKE_WECHAT_USER_ID, + }, + }, +}); + +export const fakeWechatSeedOpenClawAccountHook: MessagingHookHandler = (context) => { + const accountId = inputString( + context.inputs, + "wechatConfig.accountId", + FAKE_WECHAT_ACCOUNT_ID, + ); + const baseUrl = inputString(context.inputs, "wechatConfig.baseUrl", FAKE_WECHAT_BASE_URL); + const userId = inputString(context.inputs, "wechatConfig.userId", FAKE_WECHAT_USER_ID); + const token = inputString( + context.inputs, + "credential.wechatBotToken.placeholder", + FAKE_WECHAT_TOKEN_PLACEHOLDER, + ); + + return { + outputs: { + openclawWeixinAccountsIndex: { + kind: "build-file", + value: { + path: "openclaw-weixin/accounts.json", + mode: "0600", + content: [accountId], + }, + }, + openclawWeixinAccountFile: { + kind: "build-file", + value: { + path: `openclaw-weixin/accounts/${accountId}.json`, + mode: "0600", + content: { + token, + savedAt: FAKE_WECHAT_SAVED_AT, + baseUrl, + userId, + }, + }, + }, + openclawConfigPatch: { + kind: "build-file", + value: { + path: "openclaw.json", + merge: { + plugins: { + entries: { + "openclaw-weixin": { + enabled: true, + }, + }, + }, + channels: { + "openclaw-weixin": { + accounts: { + [accountId]: { + enabled: true, + }, + }, + }, + }, + }, + }, + }, + }, + }; +}; + +function inputString( + inputs: MessagingHookInputMap | undefined, + key: string, + fallback: string, +): string { + const value = inputs?.[key]; + return typeof value === "string" && value.trim() ? value : fallback; +} + +export const FAKE_WECHAT_HOOK_REGISTRATIONS: readonly MessagingHookRegistration[] = [ + { + id: WECHAT_ILINK_LOGIN_HOOK_ID, + handler: fakeWechatIlinkLoginHook, + }, + { + id: WECHAT_SEED_OPENCLAW_ACCOUNT_HOOK_ID, + handler: fakeWechatSeedOpenClawAccountHook, + }, +] as const; diff --git a/src/lib/messaging/channels/wechat/manifest.ts b/src/lib/messaging/channels/wechat/manifest.ts new file mode 100644 index 0000000000..203e09c81a --- /dev/null +++ b/src/lib/messaging/channels/wechat/manifest.ts @@ -0,0 +1,164 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { ChannelManifest } from "../../manifest"; + +export const wechatManifest = { + schemaVersion: 1, + id: "wechat", + displayName: "WeChat", + description: "WeChat (personal) bot messaging", + supportedAgents: ["openclaw", "hermes"], + auth: { + mode: "host-qr", + }, + inputs: [ + { + id: "botToken", + kind: "secret", + required: true, + envKey: "WECHAT_BOT_TOKEN", + prompt: { + label: "WeChat Bot Token", + help: "Captured automatically via a host-side QR scan during onboard — pair the bot by scanning the QR with WeChat on your phone (Discover → Scan). DM-only.", + }, + }, + { + id: "accountId", + kind: "config", + required: true, + envKey: "WECHAT_ACCOUNT_ID", + statePath: "wechatConfig.accountId", + }, + { + id: "baseUrl", + kind: "config", + required: false, + envKey: "WECHAT_BASE_URL", + statePath: "wechatConfig.baseUrl", + }, + { + id: "userId", + kind: "config", + required: false, + envKey: "WECHAT_USER_ID", + statePath: "wechatConfig.userId", + }, + { + id: "allowedIds", + kind: "config", + required: false, + envKey: "WECHAT_ALLOWED_IDS", + statePath: "allowedIds.wechat", + prompt: { + label: "WeChat User ID(s) (DM allowlist)", + help: "Optional: restrict who can DM the bot. The WeChat user id of the operator who scanned is added automatically; supply additional ids as a comma-separated list.", + }, + }, + ], + credentials: [ + { + id: "wechatBotToken", + sourceInput: "botToken", + providerName: "{sandboxName}-wechat-bridge", + providerEnvKey: "WECHAT_BOT_TOKEN", + placeholder: "openshell:resolve:env:WECHAT_BOT_TOKEN", + }, + ], + policyPresets: ["wechat"], + render: [ + { + id: "wechat-hermes-env", + kind: "env-lines", + agent: "hermes", + target: "~/.hermes/.env", + lines: [ + "WEIXIN_TOKEN={{credential.wechatBotToken.placeholder}}", + "WEIXIN_ACCOUNT_ID={{wechatConfig.accountId}}", + "WEIXIN_BASE_URL={{wechatConfig.baseUrl}}", + "WEIXIN_ALLOWED_USERS={{allowedIds.wechat.csv}}", + ], + }, + ], + state: { + persist: { + wechatConfig: ["accountId", "baseUrl", "userId"], + allowedIds: ["allowedIds"], + }, + rebuildHydration: [ + { + statePath: "wechatConfig.accountId", + env: "WECHAT_ACCOUNT_ID", + }, + { + statePath: "wechatConfig.baseUrl", + env: "WECHAT_BASE_URL", + }, + { + statePath: "wechatConfig.userId", + env: "WECHAT_USER_ID", + }, + { + statePath: "allowedIds.wechat", + env: "WECHAT_ALLOWED_IDS", + }, + ], + }, + hooks: [ + { + id: "wechat-host-qr", + phase: "enroll", + handler: "wechat.ilinkLogin", + outputs: [ + { + id: "botToken", + kind: "secret", + required: true, + }, + { + id: "accountId", + kind: "config", + required: true, + }, + { + id: "baseUrl", + kind: "config", + }, + { + id: "userId", + kind: "config", + }, + ], + onFailure: "skip-channel", + }, + { + id: "wechat-seed-openclaw-account", + phase: "post-agent-install", + handler: "wechat.seedOpenClawAccount", + inputs: [ + "wechatConfig.accountId", + "wechatConfig.baseUrl", + "wechatConfig.userId", + "credential.wechatBotToken.placeholder", + ], + outputs: [ + { + id: "openclawWeixinAccountsIndex", + kind: "build-file", + required: true, + }, + { + id: "openclawWeixinAccountFile", + kind: "build-file", + required: true, + }, + { + id: "openclawConfigPatch", + kind: "build-file", + required: true, + }, + ], + onFailure: "abort", + }, + ], +} as const satisfies ChannelManifest; diff --git a/src/lib/messaging/hooks/common/index.ts b/src/lib/messaging/hooks/common/index.ts new file mode 100644 index 0000000000..b6eee49387 --- /dev/null +++ b/src/lib/messaging/hooks/common/index.ts @@ -0,0 +1,4 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export * from "./token-paste"; diff --git a/src/lib/messaging/hooks/common/token-paste.test.ts b/src/lib/messaging/hooks/common/token-paste.test.ts new file mode 100644 index 0000000000..a652c4c210 --- /dev/null +++ b/src/lib/messaging/hooks/common/token-paste.test.ts @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { slackManifest, telegramManifest } from "../../channels"; +import { runMessagingHook } from "../hook-runner"; +import { MessagingHookRegistry } from "../registry"; +import { + COMMON_TOKEN_PASTE_HOOK_HANDLER_ID, + FAKE_COMMON_HOOK_REGISTRATIONS, +} from "./token-paste"; + +describe("common token-paste hook implementation", () => { + it("uses the shared handler id declared by token-paste channel manifests", () => { + expect(FAKE_COMMON_HOOK_REGISTRATIONS.map((registration) => registration.id)).toEqual([ + COMMON_TOKEN_PASTE_HOOK_HANDLER_ID, + ]); + expect(telegramManifest.hooks[0]?.handler).toBe(COMMON_TOKEN_PASTE_HOOK_HANDLER_ID); + expect(slackManifest.hooks[0]?.handler).toBe(COMMON_TOKEN_PASTE_HOOK_HANDLER_ID); + }); + + it("shows the single-token enrollment output shape", async () => { + const registry = new MessagingHookRegistry(FAKE_COMMON_HOOK_REGISTRATIONS); + const hook = telegramManifest.hooks[0]; + + if (!hook) throw new Error("missing Telegram token-paste hook"); + + await expect( + runMessagingHook(hook, registry, { + channelId: "telegram", + }), + ).resolves.toMatchObject({ + handlerId: COMMON_TOKEN_PASTE_HOOK_HANDLER_ID, + phase: "enroll", + outputs: { + botToken: { + kind: "secret", + value: "fake-telegram-botToken", + }, + }, + }); + }); + + it("shows the multi-token enrollment output shape", async () => { + const registry = new MessagingHookRegistry(FAKE_COMMON_HOOK_REGISTRATIONS); + const hook = slackManifest.hooks[0]; + + if (!hook) throw new Error("missing Slack token-paste hook"); + + await expect( + runMessagingHook(hook, registry, { + channelId: "slack", + }), + ).resolves.toMatchObject({ + handlerId: COMMON_TOKEN_PASTE_HOOK_HANDLER_ID, + phase: "enroll", + outputs: { + botToken: { + kind: "secret", + value: "fake-slack-botToken", + }, + appToken: { + kind: "secret", + value: "fake-slack-appToken", + }, + }, + }); + }); +}); diff --git a/src/lib/messaging/hooks/common/token-paste.ts b/src/lib/messaging/hooks/common/token-paste.ts new file mode 100644 index 0000000000..58cfbb23a2 --- /dev/null +++ b/src/lib/messaging/hooks/common/token-paste.ts @@ -0,0 +1,31 @@ +// 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_TOKEN_PASTE_HOOK_HANDLER_ID = "common.tokenPaste"; + +export const fakeTokenPasteHook: MessagingHookHandler = (context) => { + const outputs: Record = {}; + + for (const output of context.outputDeclarations ?? []) { + if (output.kind !== "secret") continue; + outputs[output.id] = { + kind: "secret", + value: `fake-${context.channelId}-${output.id}`, + }; + } + + return { outputs }; +}; + +export const FAKE_COMMON_HOOK_REGISTRATIONS: readonly MessagingHookRegistration[] = [ + { + id: COMMON_TOKEN_PASTE_HOOK_HANDLER_ID, + handler: fakeTokenPasteHook, + }, +] as const; diff --git a/src/lib/messaging/hooks/hook-runner.test.ts b/src/lib/messaging/hooks/hook-runner.test.ts index b3e0a62527..1fd22ec86e 100644 --- a/src/lib/messaging/hooks/hook-runner.test.ts +++ b/src/lib/messaging/hooks/hook-runner.test.ts @@ -116,6 +116,13 @@ describe("MessagingHookRegistry", () => { inputs: { "wechatConfig.accountId": "ilink-bot-42", }, + outputDeclarations: [ + { + id: "accountFile", + kind: "build-file", + required: true, + }, + ], }, ]); expect(result.outputs.accountFile).toEqual({ diff --git a/src/lib/messaging/hooks/hook-runner.ts b/src/lib/messaging/hooks/hook-runner.ts index b7f227656a..dba3052290 100644 --- a/src/lib/messaging/hooks/hook-runner.ts +++ b/src/lib/messaging/hooks/hook-runner.ts @@ -27,6 +27,7 @@ export async function runMessagingHook( hookId: hook.id, phase: hook.phase, inputs: context.inputs, + outputDeclarations: hook.outputs, }); const outputs = result.outputs ?? EMPTY_OUTPUTS; diff --git a/src/lib/messaging/hooks/index.ts b/src/lib/messaging/hooks/index.ts index 3374d63971..70def342e3 100644 --- a/src/lib/messaging/hooks/index.ts +++ b/src/lib/messaging/hooks/index.ts @@ -3,4 +3,5 @@ export * from "./hook-runner"; export * from "./registry"; +export * from "./common"; export type * from "./types"; diff --git a/src/lib/messaging/hooks/types.ts b/src/lib/messaging/hooks/types.ts index b2cf33aa55..a739f1fb14 100644 --- a/src/lib/messaging/hooks/types.ts +++ b/src/lib/messaging/hooks/types.ts @@ -24,6 +24,7 @@ export interface MessagingHookRunContext { export interface MessagingHookContext extends MessagingHookRunContext { readonly hookId: string; readonly phase: ChannelHookPhase; + readonly outputDeclarations?: readonly ChannelHookOutputSpec[]; } /** One named output emitted by a hook handler. */ From 51e95919bb3ead3f2880449a22f64c29a965cf32 Mon Sep 17 00:00:00 2001 From: San Dang Date: Mon, 25 May 2026 14:04:24 +0700 Subject: [PATCH 04/20] feat(messaging): add manifest compiler (#4069) ## Summary Adds the phase-1 messaging manifest compiler that converts channel manifests into a serializable sandbox messaging plan. The compiler resolves channel inputs through env keys and interactive enrollment hooks, then delegates credential, policy, render, build-step, state-update, and health-check planning to small pure engines. ## Related Issue Fixes #3994 ## Changes - Add `ManifestCompiler` with interactive enrollment-hook input resolution and env-key input initialization. - Add compiler plan engines for credential bindings, network policy, agent render fragments, build steps, state updates, and health checks. - Expand `SandboxMessagingPlan` and related manifest plan types to the top-level plan shape required by #3994. - Add coverage for built-in Telegram/Discord/Slack/WeChat/WhatsApp plans, Hermes WeChat policy aliasing, non-interactive env input behavior, secret-free JSON plans, disabled channels, and a synthetic non-built-in channel. ## Type of Change - [x] Code change (feature, bug fix, or refactor) - [ ] Code change with doc updates - [ ] Doc only (prose changes, no code sample modifications) - [ ] Doc only (includes code sample changes) ## Verification - [ ] `npx prek run --all-files` passes - [ ] `npm test` passes - [x] Tests added or updated for new or changed behavior - [x] No secrets, API keys, or credentials committed - [ ] Docs updated for user-facing behavior changes - [ ] `make docs` builds without warnings (doc changes only) - [ ] Doc pages follow the [style guide](https://github.com/NVIDIA/NemoClaw/blob/main/docs/CONTRIBUTING.md) (doc changes only) - [ ] New doc pages include SPDX header and frontmatter (new pages only) Additional verification performed: - `npm test -- --project cli src/lib/messaging` passes. - `npm run typecheck:cli` passes. - `npm run lint -- src/lib/messaging` passes with the existing unrelated warning in `src/lib/onboard/child-exit-tracker.test.ts`. - `git diff --check` passes. - `npm run source-shape:check` passes. - `npx prek run --all-files` and the normal pre-push hook were attempted and currently fail in unrelated full CLI doctor/debug/snapshot tests outside the messaging compiler changes. --- Signed-off-by: San Dang ## Summary by CodeRabbit * **New Features** * Added manifest compilation system for messaging channels with support for multiple agents and workflows * Implemented credential binding and authentication management * Added network policy configuration and agent rendering capabilities * Introduced health check and build step planning * Added state persistence and hydration management * Implemented placeholder resolution for sandbox names and credentials * **Tests** * Added comprehensive test suite validating compilation behavior, credential handling, and plan serialization [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/NVIDIA/NemoClaw/pull/4069?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) --------- Signed-off-by: San Dang --- src/lib/messaging/applier/index.ts | 5 + .../messaging/applier/setup-applier.test.ts | 405 ++++++++++ src/lib/messaging/applier/setup-applier.ts | 743 ++++++++++++++++++ src/lib/messaging/applier/types.ts | 98 +++ src/lib/messaging/channels/manifests.test.ts | 31 +- .../channels/telegram/hooks/fakes.test.ts | 34 + .../channels/telegram/hooks/fakes.ts | 21 + .../messaging/channels/telegram/manifest.ts | 7 + .../channels/wechat/hooks/fakes.test.ts | 7 + .../messaging/channels/wechat/hooks/fakes.ts | 18 +- src/lib/messaging/channels/wechat/manifest.ts | 1 + .../compiler/engines/agent-render-engine.ts | 53 ++ .../compiler/engines/build-step-engine.ts | 34 + .../engines/credential-binding-engine.ts | 33 + .../compiler/engines/health-check-engine.ts | 17 + .../compiler/engines/policy-resolver.ts | 67 ++ .../compiler/engines/state-update-engine.ts | 24 + .../messaging/compiler/engines/template.ts | 88 +++ src/lib/messaging/compiler/index.ts | 6 + .../compiler/manifest-compiler.test.ts | 516 ++++++++++++ .../messaging/compiler/manifest-compiler.ts | 382 +++++++++ src/lib/messaging/compiler/types.ts | 24 + .../compiler/workflow-planner.test.ts | 315 ++++++++ .../messaging/compiler/workflow-planner.ts | 244 ++++++ src/lib/messaging/index.ts | 2 + src/lib/messaging/manifest/types.test.ts | 89 ++- src/lib/messaging/manifest/types.ts | 141 +++- 27 files changed, 3331 insertions(+), 74 deletions(-) create mode 100644 src/lib/messaging/applier/index.ts create mode 100644 src/lib/messaging/applier/setup-applier.test.ts create mode 100644 src/lib/messaging/applier/setup-applier.ts create mode 100644 src/lib/messaging/applier/types.ts create mode 100644 src/lib/messaging/channels/telegram/hooks/fakes.test.ts create mode 100644 src/lib/messaging/channels/telegram/hooks/fakes.ts create mode 100644 src/lib/messaging/compiler/engines/agent-render-engine.ts create mode 100644 src/lib/messaging/compiler/engines/build-step-engine.ts create mode 100644 src/lib/messaging/compiler/engines/credential-binding-engine.ts create mode 100644 src/lib/messaging/compiler/engines/health-check-engine.ts create mode 100644 src/lib/messaging/compiler/engines/policy-resolver.ts create mode 100644 src/lib/messaging/compiler/engines/state-update-engine.ts create mode 100644 src/lib/messaging/compiler/engines/template.ts create mode 100644 src/lib/messaging/compiler/index.ts create mode 100644 src/lib/messaging/compiler/manifest-compiler.test.ts create mode 100644 src/lib/messaging/compiler/manifest-compiler.ts create mode 100644 src/lib/messaging/compiler/types.ts create mode 100644 src/lib/messaging/compiler/workflow-planner.test.ts create mode 100644 src/lib/messaging/compiler/workflow-planner.ts diff --git a/src/lib/messaging/applier/index.ts b/src/lib/messaging/applier/index.ts new file mode 100644 index 0000000000..19a1e91b68 --- /dev/null +++ b/src/lib/messaging/applier/index.ts @@ -0,0 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export * from "./setup-applier"; +export type * from "./types"; diff --git a/src/lib/messaging/applier/setup-applier.test.ts b/src/lib/messaging/applier/setup-applier.test.ts new file mode 100644 index 0000000000..05d32f795f --- /dev/null +++ b/src/lib/messaging/applier/setup-applier.test.ts @@ -0,0 +1,405 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { createBuiltInChannelManifestRegistry } from "../channels"; +import { FAKE_TELEGRAM_HOOK_REGISTRATIONS } from "../channels/telegram/hooks/fakes"; +import { FAKE_WECHAT_HOOK_REGISTRATIONS } from "../channels/wechat/hooks/fakes"; +import { MessagingWorkflowPlanner } from "../compiler"; +import { MessagingHookRegistry, runMessagingHook } from "../hooks"; +import { FAKE_COMMON_HOOK_REGISTRATIONS } from "../hooks/common"; +import type { ChannelHookSpec } from "../manifest"; +import type { SandboxMessagingPlan } from "../manifest"; +import { MessagingSetupApplier } from "./setup-applier"; +import { MESSAGING_SETUP_APPLIER_ENV_KEY, type MessagingOpenShellRunner } from "./types"; + +async function withEnv( + values: Readonly>, + run: () => Promise, +): Promise { + const previous = Object.fromEntries( + Object.keys(values).map((key) => [key, process.env[key]]), + ); + try { + for (const [key, value] of Object.entries(values)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + return await run(); + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } +} + +function planner(): MessagingWorkflowPlanner { + return new MessagingWorkflowPlanner( + createBuiltInChannelManifestRegistry(), + new MessagingHookRegistry([ + ...FAKE_COMMON_HOOK_REGISTRATIONS, + ...FAKE_TELEGRAM_HOOK_REGISTRATIONS, + ...FAKE_WECHAT_HOOK_REGISTRATIONS, + ]), + ); +} + +async function planOnboard( + env: Readonly>, + selectedChannels: readonly string[], +): Promise { + return withEnv(env, () => + planner().planOnboard({ + sandboxName: "demo", + agent: "openclaw", + isInteractive: false, + selectedChannels, + }), + ); +} + +describe("MessagingSetupApplier", () => { + it("stores a serializable SandboxMessagingPlan in env without rejecting repeated aliases", async () => { + const plan = await planOnboard({ TELEGRAM_BOT_TOKEN: "123456:telegram-token" }, [ + "telegram", + ]); + const repeated = { value: "same" }; + const planWithAlias = { + ...plan, + agentRender: [ + { + channelId: "telegram", + kind: "json-fragment", + agent: "openclaw", + target: "openclaw.json", + path: "x", + value: [repeated, repeated], + templateRefs: [], + }, + ], + } satisfies SandboxMessagingPlan; + const env: NodeJS.ProcessEnv = {}; + + MessagingSetupApplier.writePlanToEnv(planWithAlias, { env }); + + const decoded = MessagingSetupApplier.readPlanFromEnv({ env }); + expect(env[MESSAGING_SETUP_APPLIER_ENV_KEY]).toBeTruthy(); + expect(decoded?.sandboxName).toBe("demo"); + expect(decoded?.agentRender[0]).toMatchObject({ + channelId: "telegram", + kind: "json-fragment", + }); + + const cyclic = { ...plan } as Record; + cyclic.self = cyclic; + expect(() => MessagingSetupApplier.encodePlan(cyclic as never)).toThrow(/cycle/); + }); + + it("lists hook requests by phase without executing hook implementations", async () => { + const plan = await planOnboard({ WECHAT_ACCOUNT_ID: "wechat-account" }, ["wechat"]); + + expect(MessagingSetupApplier.listHookRequests(plan, "enroll")).toEqual([ + expect.objectContaining({ + sandboxName: "demo", + channelId: "wechat", + hookId: "wechat-host-qr", + phase: "enroll", + handler: "wechat.ilinkLogin", + }), + ]); + expect(MessagingSetupApplier.listHookRequests(plan, "post-agent-install")).toEqual([ + expect.objectContaining({ + sandboxName: "demo", + channelId: "wechat", + hookId: "wechat-seed-openclaw-account", + phase: "post-agent-install", + handler: "wechat.seedOpenClawAccount", + }), + ]); + }); + + it("upserts OpenShell generic providers from plan credential bindings", async () => { + const plan = await planOnboard( + { + TELEGRAM_BOT_TOKEN: "123456:telegram-token", + SLACK_BOT_TOKEN: "xoxb-slack-token", + SLACK_APP_TOKEN: "xapp-slack-token", + }, + ["telegram", "slack"], + ); + const calls: Array<{ + args: readonly string[]; + env?: Readonly>; + }> = []; + const runOpenshell: MessagingOpenShellRunner = (args, options) => { + calls.push({ args, env: options?.env }); + if (args[0] === "provider" && args[1] === "get") { + return { status: args[2] === "demo-slack-bridge" ? 0 : 1 }; + } + return { status: 0 }; + }; + + const result = MessagingSetupApplier.applyCredentialsAtOpenShell(plan, { + env: { + TELEGRAM_BOT_TOKEN: "123456:telegram-token", + SLACK_BOT_TOKEN: "xoxb-slack-token", + SLACK_APP_TOKEN: "xapp-slack-token", + }, + runOpenshell, + }); + + expect(calls.map((call) => call.args)).toEqual([ + ["provider", "get", "demo-telegram-bridge"], + [ + "provider", + "create", + "--name", + "demo-telegram-bridge", + "--type", + "generic", + "--credential", + "TELEGRAM_BOT_TOKEN", + ], + ["provider", "get", "demo-slack-bridge"], + ["provider", "update", "demo-slack-bridge", "--credential", "SLACK_BOT_TOKEN"], + ["provider", "get", "demo-slack-app"], + [ + "provider", + "create", + "--name", + "demo-slack-app", + "--type", + "generic", + "--credential", + "SLACK_APP_TOKEN", + ], + ]); + expect(calls[1]?.env).toEqual({ TELEGRAM_BOT_TOKEN: "123456:telegram-token" }); + expect(result.upserted.map((entry) => `${entry.action}:${entry.providerName}`)).toEqual([ + "create:demo-telegram-bridge", + "update:demo-slack-bridge", + "create:demo-slack-app", + ]); + expect(result.sandboxCreateProviderArgs).toEqual([ + "--provider", + "demo-telegram-bridge", + "--provider", + "demo-slack-bridge", + "--provider", + "demo-slack-app", + ]); + expect(JSON.stringify(result)).not.toContain("telegram-token"); + expect(JSON.stringify(result)).not.toContain("slack-token"); + }); + + it("redacts OpenShell provider failure output", async () => { + const plan = await planOnboard({ TELEGRAM_BOT_TOKEN: "tokensecretvalue" }, [ + "telegram", + ]); + const runOpenshell: MessagingOpenShellRunner = (args) => { + if (args[0] === "provider" && args[1] === "get") { + return { status: 1 }; + } + return { + status: 1, + stderr: "provider rejected TELEGRAM_BOT_TOKEN=tokensecretvalue", + }; + }; + + let message = ""; + try { + MessagingSetupApplier.applyCredentialsAtOpenShell(plan, { + env: { TELEGRAM_BOT_TOKEN: "tokensecretvalue" }, + runOpenshell, + }); + } catch (error) { + message = error instanceof Error ? error.message : String(error); + } + + expect(message).toContain("TELEGRAM_BOT_TOKEN=toke"); + expect(message).not.toContain("tokensecretvalue"); + }); + + it("applies agent config render plans into sandbox files through OpenShell", async () => { + const plan = await planOnboard({ TELEGRAM_BOT_TOKEN: "123456:telegram-token" }, [ + "telegram", + ]); + const files: Record = { + "/sandbox/.openclaw/openclaw.json": JSON.stringify({ + agents: { + list: ["default"], + }, + }), + }; + const calls: Array<{ args: readonly string[]; input?: string }> = []; + const runOpenshell: MessagingOpenShellRunner = (args, options) => { + calls.push({ args, input: options?.input }); + const target = String(args.at(-1)); + if (args.includes("cat") && !options?.input) { + return { status: files[target] === undefined ? 1 : 0, stdout: files[target] ?? "" }; + } + if (options?.input !== undefined) { + files[target] = options.input; + return { status: 0 }; + } + return { status: 1 }; + }; + + const result = await MessagingSetupApplier.applyAgentConfigAtOpenShell(plan, { + runOpenshell, + }); + + expect(calls.map((call) => call.args)).toEqual([ + [ + "sandbox", + "exec", + "--name", + "demo", + "--", + "cat", + "/sandbox/.openclaw/openclaw.json", + ], + [ + "sandbox", + "exec", + "--name", + "demo", + "--", + "sh", + "-c", + 'mkdir -p "$(dirname "$1")" && cat > "$1"', + "sh", + "/sandbox/.openclaw/openclaw.json", + ], + ]); + expect(calls[1]?.input).toBeTruthy(); + const openclawConfig = JSON.parse(files["/sandbox/.openclaw/openclaw.json"] ?? "{}"); + expect(openclawConfig.agents.list).toEqual(["default"]); + expect(openclawConfig.channels.telegram.accounts.default).toMatchObject({ + botToken: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", + enabled: true, + groupPolicy: "open", + }); + expect(openclawConfig.channels.telegram.groups["*"]).toEqual({ + requireMention: "{{telegramConfig.requireMention}}", + }); + expect(result.appliedTargets).toEqual(["/sandbox/.openclaw/openclaw.json"]); + expect(result.appliedHooks).toEqual([]); + expect(result.unresolvedTemplateRefs).toEqual( + expect.arrayContaining(["proxyUrl", "telegramConfig.requireMention"]), + ); + }); + + it("runs post-install hook implementations and writes their build-file outputs", async () => { + const plan = await planOnboard( + { + WECHAT_ACCOUNT_ID: "wechat-account", + WECHAT_BASE_URL: "https://ilinkai.wechat.example", + WECHAT_USER_ID: "wechat-user", + }, + ["wechat"], + ); + const registry = new MessagingHookRegistry(FAKE_WECHAT_HOOK_REGISTRATIONS); + const files: Record = { + "/sandbox/.openclaw/openclaw.json": JSON.stringify({ + plugins: { + entries: { + acpx: { + enabled: false, + }, + }, + }, + }), + }; + + const result = await MessagingSetupApplier.applyAgentConfigAtOpenShell(plan, { + runOpenshell: (args, options) => { + const command = String(args[7] ?? ""); + const target = + options?.input !== undefined && command.includes("chmod") + ? String(args.at(-2)) + : String(args.at(-1)); + if (args.includes("cat") && options?.input === undefined) { + return { status: files[target] === undefined ? 1 : 0, stdout: files[target] ?? "" }; + } + if (options?.input !== undefined) { + files[target] = options.input; + return { status: 0 }; + } + return { status: 1 }; + }, + runHook: (request) => { + const hook = { + id: request.hookId, + phase: request.phase, + handler: request.handler, + inputs: request.inputKeys, + outputs: request.outputs, + onFailure: request.onFailure, + } satisfies ChannelHookSpec; + return runMessagingHook(hook, registry, { + channelId: request.channelId, + inputs: request.inputs, + }); + }, + }); + + expect(JSON.parse(files["/sandbox/.openclaw/openclaw-weixin/accounts.json"] ?? "[]")).toEqual( + ["wechat-account"], + ); + expect( + JSON.parse( + files["/sandbox/.openclaw/openclaw-weixin/accounts/wechat-account.json"] ?? "{}", + ), + ).toMatchObject({ + token: "openshell:resolve:env:WECHAT_BOT_TOKEN", + baseUrl: "https://ilinkai.wechat.example", + userId: "wechat-user", + }); + const openclawConfig = JSON.parse(files["/sandbox/.openclaw/openclaw.json"] ?? "{}"); + expect(openclawConfig.plugins.entries.acpx.enabled).toBe(false); + expect(openclawConfig.plugins.entries["openclaw-weixin"].enabled).toBe(true); + expect(openclawConfig.plugins.installs["openclaw-weixin"].spec).toBe( + "@tencent-weixin/openclaw-weixin@2.4.2", + ); + expect(openclawConfig.plugins.load.paths).toEqual([ + "/sandbox/.openclaw/extensions/openclaw-weixin", + ]); + expect(openclawConfig.channels["openclaw-weixin"].accounts["wechat-account"]).toEqual({ + enabled: true, + }); + expect(result.appliedTargets).toEqual([ + "/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"]); + }); + + it("applies policy presets directly from the serializable plan", async () => { + const plan = await planOnboard({ TELEGRAM_BOT_TOKEN: "123456:telegram-token" }, [ + "telegram", + ]); + const policyCalls: string[][] = []; + + const result = MessagingSetupApplier.applyPolicyAtOpenShell(plan, { + applyPresets: (sandboxName, presetNames) => { + policyCalls.push([sandboxName, ...presetNames]); + return true; + }, + }); + + expect(policyCalls).toEqual([["demo", "telegram"]]); + expect(result).toEqual({ + appliedPresets: ["telegram"], + }); + }); +}); diff --git a/src/lib/messaging/applier/setup-applier.ts b/src/lib/messaging/applier/setup-applier.ts new file mode 100644 index 0000000000..7a4ab6341b --- /dev/null +++ b/src/lib/messaging/applier/setup-applier.ts @@ -0,0 +1,743 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Buffer } from "node:buffer"; +import YAML from "yaml"; + +import { redact } from "../../security/redact"; +import type { + ChannelHookPhase, + MessagingAgentId, + MessagingSerializableValue, + SandboxMessagingAgentRenderPlan, + SandboxMessagingChannelPlan, + SandboxMessagingCredentialBindingPlan, + SandboxMessagingEnvLinesRenderPlan, + SandboxMessagingJsonRenderPlan, + SandboxMessagingPlan, +} from "../manifest"; +import type { MessagingHookOutputMap } from "../hooks"; +import { + MESSAGING_SETUP_APPLIER_ENV_KEY, + type MessagingCredentialApplyOptions, + type MessagingCredentialApplyResult, + type MessagingHookApplyRequest, + type MessagingHookApplyRunner, + type MessagingOpenShellRunner, + type MessagingPolicyApplyOptions, + type MessagingPolicyApplyResult, + type MessagingSetupEnvOptions, +} from "./types"; + +type MessagingCredentialApplyEntry = MessagingCredentialApplyResult["upserted"][number]; +type MessagingCredentialReuseEntry = MessagingCredentialApplyResult["reused"][number]; +type MessagingMissingCredentialEntry = MessagingCredentialApplyResult["missing"][number]; +type MessagingCredentialBindingLike = Pick< + SandboxMessagingCredentialBindingPlan, + "channelId" | "credentialId" | "providerName" | "providerEnvKey" +>; + +const AGENT_CONFIG_HOOK_PHASES = new Set([ + "apply", + "post-agent-install", +]); + +export class MessagingSetupApplier { + static encodePlan(plan: SandboxMessagingPlan): string { + assertSandboxMessagingPlan(plan); + assertJsonSerializable(plan); + return Buffer.from(JSON.stringify(plan), "utf8").toString("base64"); + } + + static decodePlan(encoded: string): SandboxMessagingPlan { + const raw = Buffer.from(encoded, "base64").toString("utf8"); + const parsed = JSON.parse(raw) as unknown; + assertSandboxMessagingPlan(parsed); + return parsed; + } + + static writePlanToEnv( + plan: SandboxMessagingPlan, + options: MessagingSetupEnvOptions = {}, + ): void { + const env = options.env ?? process.env; + env[options.envKey ?? MESSAGING_SETUP_APPLIER_ENV_KEY] = this.encodePlan(plan); + } + + static readPlanFromEnv(options: MessagingSetupEnvOptions = {}): SandboxMessagingPlan | null { + const env = options.env ?? process.env; + const value = env[options.envKey ?? MESSAGING_SETUP_APPLIER_ENV_KEY]; + return value ? this.decodePlan(value) : null; + } + + static requirePlanFromEnv(options: MessagingSetupEnvOptions = {}): SandboxMessagingPlan { + const plan = this.readPlanFromEnv(options); + if (!plan) { + throw new Error(`${options.envKey ?? MESSAGING_SETUP_APPLIER_ENV_KEY} is not set.`); + } + return plan; + } + + static clearPlanEnv(options: MessagingSetupEnvOptions = {}): void { + const env = options.env ?? process.env; + delete env[options.envKey ?? MESSAGING_SETUP_APPLIER_ENV_KEY]; + } + + static listHookRequests( + plan: SandboxMessagingPlan, + phase?: ChannelHookPhase, + ): MessagingHookApplyRequest[] { + assertSandboxMessagingPlan(plan); + return plan.channels.flatMap((channel) => + channel.hooks + .filter((hook) => !phase || hook.phase === phase) + .map((hook) => toHookApplyRequest(plan, channel, hook)), + ); + } + + static async applyAgentConfigAtOpenShell( + plan: SandboxMessagingPlan, + options: { + readonly runOpenshell: MessagingOpenShellRunner; + readonly runHook?: MessagingHookApplyRunner; + }, + ): Promise<{ + readonly appliedTargets: readonly string[]; + readonly appliedHooks: readonly string[]; + readonly unresolvedTemplateRefs: readonly string[]; + }> { + assertSandboxMessagingPlan(plan); + const hookRequests = hookRequestsForPhases(plan, AGENT_CONFIG_HOOK_PHASES); + if (hookRequests.length > 0 && !options.runHook) { + throw new Error("Messaging agent config hooks require a hook runner."); + } + + const appliedHooks: string[] = []; + const appliedTargets: string[] = []; + for (const request of hookRequests.filter((hook) => hook.phase === "apply")) { + await runApplyHook(request, options.runHook, plan, options.runOpenshell, { + appliedHooks, + appliedTargets, + }); + } + + for (const [target, render] of groupRenderByTarget(plan.agentRender)) { + const resolvedTarget = resolveSandboxAgentConfigTarget(target, plan.agent); + const kind = render[0]?.kind; + if (!kind) continue; + if (render.some((entry) => entry.kind !== kind)) { + throw new Error(`Cannot apply mixed messaging render kinds to ${target}.`); + } + const existing = readSandboxFile(plan.sandboxName, resolvedTarget, options.runOpenshell); + const contents = + kind === "json-fragment" + ? applyJsonFragments( + existing, + render.filter(isJsonRender), + resolvedTarget, + ) + : applyEnvLines(existing, render.filter(isEnvLinesRender)); + writeSandboxFile(plan.sandboxName, resolvedTarget, contents, options.runOpenshell); + appliedTargets.push(resolvedTarget); + } + + for (const request of hookRequests.filter((hook) => hook.phase === "post-agent-install")) { + await runApplyHook(request, options.runHook, plan, options.runOpenshell, { + appliedHooks, + appliedTargets, + }); + } + + return { + appliedTargets: uniqueStrings(appliedTargets), + appliedHooks, + unresolvedTemplateRefs: uniqueStrings( + plan.agentRender.flatMap((render) => render.templateRefs), + ), + }; + } + + static applyCredentialsAtOpenShell( + plan: SandboxMessagingPlan, + options: MessagingCredentialApplyOptions, + ): MessagingCredentialApplyResult { + assertSandboxMessagingPlan(plan); + const env = options.env ?? process.env; + const runOpenshell = options.runOpenshell; + const upserted: MessagingCredentialApplyEntry[] = []; + const reused: MessagingCredentialReuseEntry[] = []; + const missing: MessagingMissingCredentialEntry[] = []; + + for (const binding of plan.credentialBindings) { + const credential = readCredentialEnv(env, binding.providerEnvKey); + if (!credential) { + if (providerExistsInGateway(binding.providerName, runOpenshell)) { + reused.push(toReuseEntry(binding)); + } else { + missing.push(toMissingEntry(binding)); + } + continue; + } + + const action = providerExistsInGateway(binding.providerName, runOpenshell) + ? "update" + : "create"; + const result = runOpenshell( + buildProviderArgs(action, binding.providerName, binding.providerEnvKey), + { + ignoreError: true, + env: { [binding.providerEnvKey]: credential }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + const status = result.status ?? 0; + if (status !== 0) { + throw new Error( + `Failed to ${action} messaging provider '${binding.providerName}': ${compactOutput(result)}`, + ); + } + upserted.push({ + channelId: binding.channelId, + credentialId: binding.credentialId, + providerName: binding.providerName, + envKey: binding.providerEnvKey, + action, + }); + } + + const providerNames = uniqueStrings([ + ...upserted.map((entry) => entry.providerName), + ...reused.map((entry) => entry.providerName), + ]); + + return { + upserted, + reused, + missing, + providerNames, + sandboxCreateProviderArgs: providerNames.flatMap((providerName) => [ + "--provider", + providerName, + ]), + }; + } + + static applyPolicyAtOpenShell( + plan: SandboxMessagingPlan, + options: MessagingPolicyApplyOptions, + ): MessagingPolicyApplyResult { + assertSandboxMessagingPlan(plan); + const activePresets = uniqueStrings(plan.networkPolicy.presets); + if (activePresets.length > 0 && !options.applyPresets(plan.sandboxName, activePresets)) { + throw new Error(`Failed to apply messaging policy preset(s): ${activePresets.join(", ")}`); + } + + return { + appliedPresets: activePresets, + }; + } +} + +function hookRequestsForPhases( + plan: SandboxMessagingPlan, + phases: ReadonlySet, +): MessagingHookApplyRequest[] { + return plan.channels.flatMap((channel) => + channel.hooks + .filter((hook) => phases.has(hook.phase)) + .map((hook) => toHookApplyRequest(plan, channel, hook)), + ); +} + +function toHookApplyRequest( + plan: SandboxMessagingPlan, + channel: SandboxMessagingChannelPlan, + hook: SandboxMessagingChannelPlan["hooks"][number], +): MessagingHookApplyRequest { + const inputs = buildHookInputMap(plan, channel); + const selectedInputs = hook.inputs + ? Object.fromEntries( + hook.inputs + .filter((inputKey) => Object.hasOwn(inputs, inputKey)) + .map((inputKey) => [inputKey, inputs[inputKey] as MessagingSerializableValue]), + ) + : inputs; + + return { + sandboxName: plan.sandboxName, + agent: plan.agent, + channelId: channel.channelId, + hookId: hook.id, + phase: hook.phase, + handler: hook.handler, + inputKeys: hook.inputs, + inputs: selectedInputs, + outputs: hook.outputs, + onFailure: hook.onFailure, + }; +} + +function buildHookInputMap( + plan: SandboxMessagingPlan, + channel: SandboxMessagingChannelPlan, +): 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 plan.credentialBindings) { + if (credential.channelId !== channel.channelId) continue; + inputs[`credential.${credential.credentialId}.placeholder`] = credential.placeholder; + } + return inputs; +} + +async function runApplyHook( + request: MessagingHookApplyRequest, + runner: MessagingHookApplyRunner | undefined, + plan: SandboxMessagingPlan, + runOpenshell: MessagingOpenShellRunner, + applied: { + readonly appliedHooks: string[]; + readonly appliedTargets: string[]; + }, +): Promise { + if (!runner) return; + try { + const result = await runner(request); + applied.appliedHooks.push(`${request.channelId}:${request.hookId}`); + if (result?.outputs) { + applied.appliedTargets.push( + ...applyHookBuildFileOutputs(plan, result.outputs, runOpenshell), + ); + } + } catch (error) { + if (request.onFailure === "skip-channel") return; + throw error; + } +} + +function assertSandboxMessagingPlan(value: unknown): asserts value is SandboxMessagingPlan { + if ( + !isObject(value) || + value.schemaVersion !== 1 || + typeof value.sandboxName !== "string" || + typeof value.agent !== "string" || + typeof value.workflow !== "string" || + !Array.isArray(value.channels) || + !Array.isArray(value.credentialBindings) || + !isObject(value.networkPolicy) || + !Array.isArray(value.agentRender) || + !Array.isArray(value.buildSteps) || + !Array.isArray(value.stateUpdates) || + !Array.isArray(value.healthChecks) + ) { + throw new Error("Expected a serializable SandboxMessagingPlan."); + } +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function assertJsonSerializable( + value: unknown, + path = "$", + visiting: Set = new Set(), +): void { + if ( + value === null || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" || + typeof value === "undefined" + ) { + return; + } + if (Array.isArray(value)) { + assertAcyclicObject(value, path, visiting, () => { + value.forEach((entry, index) => assertJsonSerializable(entry, `${path}[${index}]`, visiting)); + }); + return; + } + if (typeof value === "object" && value !== null) { + assertAcyclicObject(value, path, visiting, () => { + for (const [key, entry] of Object.entries(value)) { + assertJsonSerializable(entry, `${path}.${key}`, visiting); + } + }); + return; + } + throw new Error(`Messaging setup plan is not JSON-serializable at ${path}.`); +} + +function assertAcyclicObject( + value: object, + path: string, + visiting: Set, + visit: () => void, +): void { + if (visiting.has(value)) { + throw new Error(`Messaging setup plan contains a cycle at ${path}.`); + } + visiting.add(value); + try { + visit(); + } finally { + visiting.delete(value); + } +} + +function groupRenderByTarget( + render: readonly SandboxMessagingAgentRenderPlan[], +): ReadonlyMap { + const groups = new Map(); + for (const entry of render) { + const group = groups.get(entry.target) ?? []; + group.push(entry); + groups.set(entry.target, group); + } + return groups; +} + +function isJsonRender( + render: SandboxMessagingAgentRenderPlan, +): render is SandboxMessagingJsonRenderPlan { + return render.kind === "json-fragment"; +} + +function isEnvLinesRender( + render: SandboxMessagingAgentRenderPlan, +): render is SandboxMessagingEnvLinesRenderPlan { + return render.kind === "env-lines"; +} + +function applyJsonFragments( + existing: string | undefined, + render: readonly SandboxMessagingJsonRenderPlan[], + target: string, +): string { + const format = target.endsWith(".yaml") || target.endsWith(".yml") ? "yaml" : "json"; + const root = parseStructuredConfig(existing, target, format); + for (const entry of render) { + setJsonPath(root, entry.path, entry.value); + } + return format === "yaml" ? YAML.stringify(root) : `${JSON.stringify(root, null, 2)}\n`; +} + +function parseStructuredConfig( + existing: string | undefined, + target: string, + format: "json" | "yaml", +): Record { + if (!existing || existing.trim().length === 0) return {}; + const parsed = format === "yaml" ? YAML.parse(existing) : (JSON.parse(existing) as unknown); + if (!isObject(parsed)) { + throw new Error(`Messaging agent config target ${target} must contain an object.`); + } + return parsed as Record; +} + +function setJsonPath( + root: Record, + path: string, + value: MessagingSerializableValue, +): void { + const segments = path.split(".").filter(Boolean); + if (segments.length === 0) { + throw new Error("Messaging render path must not be empty."); + } + let cursor: Record = root; + for (const segment of segments.slice(0, -1)) { + const next = cursor[segment]; + if (!isObject(next)) { + const created: Record = {}; + cursor[segment] = created; + cursor = created; + } else { + cursor = next as Record; + } + } + cursor[segments[segments.length - 1] as string] = value; +} + +function applyEnvLines( + existing: string | undefined, + render: readonly SandboxMessagingEnvLinesRenderPlan[], +): string { + const desired = new Map(); + const rawDesiredLines: string[] = []; + for (const entry of render) { + for (const line of entry.lines) { + const key = readEnvLineKey(line); + if (key) { + desired.set(key, line); + } else { + rawDesiredLines.push(line); + } + } + } + + const written = new Set(); + const output = (existing ?? "") + .split(/\n/) + .filter((line, index, lines) => line.length > 0 || index < lines.length - 1) + .map((line) => { + const key = readEnvLineKey(line); + if (!key || !desired.has(key)) return line; + written.add(key); + return desired.get(key) as string; + }); + + for (const [key, line] of desired) { + if (!written.has(key)) output.push(line); + } + output.push(...rawDesiredLines); + return output.length > 0 ? `${output.join("\n")}\n` : ""; +} + +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 applyHookBuildFileOutputs( + plan: SandboxMessagingPlan, + outputs: MessagingHookOutputMap, + runOpenshell: MessagingOpenShellRunner, +): string[] { + const appliedTargets: string[] = []; + for (const output of Object.values(outputs)) { + if (output.kind !== "build-file") continue; + const file = readHookBuildFile(output.value); + const target = resolveHookBuildFileTarget(file.path, plan.agent); + const contents = + file.merge !== undefined + ? applyStructuredMerge( + readSandboxFile(plan.sandboxName, target, runOpenshell), + file.merge, + target, + ) + : serializeHookBuildFileContent(file.content, target); + writeSandboxFile(plan.sandboxName, target, contents, runOpenshell, file.mode); + appliedTargets.push(target); + } + return appliedTargets; +} + +function readHookBuildFile(value: MessagingSerializableValue): { + readonly path: string; + readonly mode?: string; + readonly content?: MessagingSerializableValue; + readonly merge?: MessagingSerializableValue; +} { + if (!isObject(value) || typeof value.path !== "string" || value.path.trim().length === 0) { + throw new Error("Messaging build-file hook output must include a non-empty path."); + } + const file = value as Record; + const path = value.path; + const mode = value.mode; + if (file.content === undefined && file.merge === undefined) { + throw new Error(`Messaging build-file '${path}' must include content or merge.`); + } + if (mode !== undefined && typeof mode !== "string") { + throw new Error(`Messaging build-file '${path}' mode must be a string.`); + } + return { + path, + mode, + content: file.content, + merge: file.merge, + }; +} + +function applyStructuredMerge( + existing: string | undefined, + patch: MessagingSerializableValue, + target: string, +): string { + if (!isObject(patch)) { + throw new Error(`Messaging build-file merge for ${target} must be an object.`); + } + const format = target.endsWith(".yaml") || target.endsWith(".yml") ? "yaml" : "json"; + const root = parseStructuredConfig(existing, target, format); + mergeObjects(root, patch); + return format === "yaml" ? YAML.stringify(root) : `${JSON.stringify(root, null, 2)}\n`; +} + +function mergeObjects( + target: Record, + patch: Record, +): void { + for (const [key, value] of Object.entries(patch)) { + const existing = target[key]; + if (isObject(existing) && isObject(value)) { + mergeObjects( + existing as Record, + value as Record, + ); + continue; + } + target[key] = value; + } +} + +function serializeHookBuildFileContent( + content: MessagingSerializableValue | undefined, + target: string, +): string { + if (content === undefined) return ""; + if (typeof content === "string") return content.endsWith("\n") ? content : `${content}\n`; + if (target.endsWith(".yaml") || target.endsWith(".yml")) return YAML.stringify(content); + return `${JSON.stringify(content, null, 2)}\n`; +} + +function resolveHookBuildFileTarget(path: string, agent: MessagingAgentId): string { + if (path.startsWith("/")) return path; + if (path === "openclaw.json") return resolveSandboxAgentConfigTarget(path, "openclaw"); + if (path === "config.yaml" && agent === "hermes") { + return resolveSandboxAgentConfigTarget("~/.hermes/config.yaml", agent); + } + if (path === ".env" && agent === "hermes") { + return resolveSandboxAgentConfigTarget("~/.hermes/.env", agent); + } + if (agent === "openclaw") return `/sandbox/.openclaw/${path}`; + if (agent === "hermes") return `/sandbox/.hermes/${path}`; + throw new Error(`Cannot resolve messaging build-file target '${path}' for ${agent}.`); +} + +function resolveSandboxAgentConfigTarget(target: string, agent: MessagingAgentId): string { + if (target.startsWith("/")) return target; + if (agent === "openclaw" && target === "openclaw.json") { + return "/sandbox/.openclaw/openclaw.json"; + } + if (target.startsWith("~/.openclaw/")) { + return `/sandbox/.openclaw/${target.slice("~/.openclaw/".length)}`; + } + if (target.startsWith("~/.hermes/")) { + return `/sandbox/.hermes/${target.slice("~/.hermes/".length)}`; + } + throw new Error(`Cannot resolve messaging agent config target '${target}' for ${agent}.`); +} + +function readSandboxFile( + sandboxName: string, + target: string, + runOpenshell: MessagingOpenShellRunner, +): string | undefined { + const result = runOpenshell( + ["sandbox", "exec", "--name", sandboxName, "--", "cat", target], + { + ignoreError: true, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + const status = result.status ?? 0; + return status === 0 ? String(result.stdout ?? "") : undefined; +} + +function writeSandboxFile( + sandboxName: string, + target: string, + contents: string, + runOpenshell: MessagingOpenShellRunner, + mode?: string, +): void { + const result = runOpenshell( + [ + "sandbox", + "exec", + "--name", + sandboxName, + "--", + "sh", + "-c", + mode + ? 'mkdir -p "$(dirname "$1")" && cat > "$1" && chmod "$2" "$1"' + : 'mkdir -p "$(dirname "$1")" && cat > "$1"', + "sh", + target, + ...(mode ? [mode] : []), + ], + { + input: contents, + stdio: ["pipe", "pipe", "pipe"], + }, + ); + const status = result.status ?? 0; + if (status !== 0) { + throw new Error( + `Failed to apply messaging agent config '${target}': ${compactOutput(result)}`, + ); + } +} + +function readCredentialEnv(env: NodeJS.ProcessEnv, envKey: string): string | null { + const raw = env[envKey]; + if (typeof raw !== "string") return null; + const normalized = raw.replace(/\r/g, "").trim(); + return normalized || null; +} + +function providerExistsInGateway( + providerName: string, + runOpenshell: MessagingOpenShellRunner, +): boolean { + const result = runOpenshell(["provider", "get", providerName], { + ignoreError: true, + stdio: ["ignore", "ignore", "ignore"], + }); + return (result.status ?? 0) === 0; +} + +function buildProviderArgs( + action: "create" | "update", + providerName: string, + credentialEnv: string, +): string[] { + return action === "create" + ? [ + "provider", + "create", + "--name", + providerName, + "--type", + "generic", + "--credential", + credentialEnv, + ] + : ["provider", "update", providerName, "--credential", credentialEnv]; +} + +function toReuseEntry(binding: MessagingCredentialBindingLike): MessagingCredentialReuseEntry { + return { + channelId: binding.channelId, + credentialId: binding.credentialId, + providerName: binding.providerName, + envKey: binding.providerEnvKey, + }; +} + +function toMissingEntry(binding: MessagingCredentialBindingLike): MessagingMissingCredentialEntry { + return { + channelId: binding.channelId, + credentialId: binding.credentialId, + providerName: binding.providerName, + envKey: binding.providerEnvKey, + }; +} + +function compactOutput(result: { readonly stdout?: unknown; readonly stderr?: unknown }): string { + const output = redact(`${String(result.stderr ?? "")}${String(result.stdout ?? "")}`) + .replace(/\r/g, "") + .trim(); + return output || "OpenShell command failed."; +} + +function uniqueStrings(values: readonly string[]): string[] { + return [...new Set(values.filter(Boolean))]; +} diff --git a/src/lib/messaging/applier/types.ts b/src/lib/messaging/applier/types.ts new file mode 100644 index 0000000000..96847394e0 --- /dev/null +++ b/src/lib/messaging/applier/types.ts @@ -0,0 +1,98 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { + ChannelHookFailureMode, + ChannelHookOutputSpec, + ChannelHookPhase, + MessagingAgentId, + MessagingChannelId, + SandboxMessagingHookReferencePlan, + SandboxMessagingPlan, +} from "../manifest"; +import type { MessagingHookInputMap, MessagingHookOutputMap, MessagingHookRunResult } from "../hooks"; + +export const MESSAGING_SETUP_APPLIER_ENV_KEY = "NEMOCLAW_MESSAGING_PLAN_B64"; + +export interface MessagingSetupEnvOptions { + readonly env?: NodeJS.ProcessEnv; + readonly envKey?: string; +} + +export interface MessagingHookApplyRequest { + readonly sandboxName: string; + readonly agent: MessagingAgentId; + readonly channelId: MessagingChannelId; + readonly hookId: string; + readonly phase: ChannelHookPhase; + readonly handler: string; + readonly inputKeys?: readonly string[]; + readonly inputs: MessagingHookInputMap; + readonly outputs?: readonly ChannelHookOutputSpec[]; + readonly onFailure?: ChannelHookFailureMode; +} + +export type MessagingHookApplyRunner = ( + request: MessagingHookApplyRequest, +) => + | void + | MessagingHookRunResult + | { readonly outputs?: MessagingHookOutputMap } + | Promise; + +export interface MessagingOpenShellRunOptions { + readonly ignoreError?: boolean; + readonly env?: Readonly>; + readonly input?: string; + readonly stdio?: readonly unknown[]; +} + +export interface MessagingOpenShellRunResult { + readonly status?: number | null; + readonly stdout?: unknown; + readonly stderr?: unknown; +} + +export type MessagingOpenShellRunner = ( + args: readonly string[], + options?: MessagingOpenShellRunOptions, +) => MessagingOpenShellRunResult; + +export interface MessagingCredentialApplyOptions extends MessagingSetupEnvOptions { + readonly runOpenshell: MessagingOpenShellRunner; +} + +export interface MessagingCredentialApplyResult { + readonly upserted: readonly { + readonly channelId: MessagingChannelId; + readonly credentialId: string; + readonly providerName: string; + readonly envKey: string; + readonly action: "create" | "update"; + }[]; + readonly reused: readonly { + readonly channelId: MessagingChannelId; + readonly credentialId: string; + readonly providerName: string; + readonly envKey: string; + }[]; + readonly missing: readonly { + readonly channelId: MessagingChannelId; + readonly credentialId: string; + readonly providerName: string; + readonly envKey: string; + }[]; + readonly providerNames: readonly string[]; + readonly sandboxCreateProviderArgs: readonly string[]; +} + +export interface MessagingPolicyApplyOptions { + readonly applyPresets: (sandboxName: string, presetNames: string[]) => boolean; +} + +export interface MessagingPolicyApplyResult { + readonly appliedPresets: readonly string[]; +} + +export type MessagingSerializablePlan = SandboxMessagingPlan; +export type MessagingSerializableHook = SandboxMessagingHookReferencePlan; diff --git a/src/lib/messaging/channels/manifests.test.ts b/src/lib/messaging/channels/manifests.test.ts index 5704200885..e13c93b631 100644 --- a/src/lib/messaging/channels/manifests.test.ts +++ b/src/lib/messaging/channels/manifests.test.ts @@ -37,19 +37,17 @@ function renderJson(manifest: ChannelManifest): string { } function expectTokenPasteEnrollHook(manifest: ChannelManifest, outputIds: readonly string[]): void { - expect(manifest.hooks).toEqual([ - { - id: `${manifest.id}-token-paste`, - phase: "enroll", - handler: COMMON_TOKEN_PASTE_HOOK_HANDLER_ID, - outputs: outputIds.map((id) => ({ - id, - kind: "secret", - required: true, - })), - onFailure: "skip-channel", - }, - ]); + expect(manifest.hooks).toContainEqual({ + id: `${manifest.id}-token-paste`, + phase: "enroll", + handler: COMMON_TOKEN_PASTE_HOOK_HANDLER_ID, + outputs: outputIds.map((id) => ({ + id, + kind: "secret", + required: true, + })), + onFailure: "skip-channel", + }); } describe("built-in channel manifests", () => { @@ -151,6 +149,13 @@ describe("built-in channel manifests", () => { expect(renderJson(telegramManifest)).toContain("channels.telegram.groups"); expect(renderJson(telegramManifest)).toContain("telegramConfig.requireMention"); expectTokenPasteEnrollHook(telegramManifest, ["botToken"]); + expect(telegramManifest.hooks).toContainEqual({ + id: "telegram-reachability", + phase: "reachability-check", + handler: "telegram.getMeReachability", + inputs: ["botToken"], + onFailure: "abort", + }); }); it("declares Discord guild and allowlist render intent for both agents", () => { diff --git a/src/lib/messaging/channels/telegram/hooks/fakes.test.ts b/src/lib/messaging/channels/telegram/hooks/fakes.test.ts new file mode 100644 index 0000000000..26595f9e62 --- /dev/null +++ b/src/lib/messaging/channels/telegram/hooks/fakes.test.ts @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { MessagingHookRegistry, runMessagingHook } from "../../../hooks"; +import { telegramManifest } from "../manifest"; +import { + FAKE_TELEGRAM_HOOK_REGISTRATIONS, + TELEGRAM_GET_ME_REACHABILITY_HOOK_ID, +} from "./fakes"; + +describe("Telegram fake hook implementations", () => { + it("declares the reachability hook without exposing the token in outputs", async () => { + const registry = new MessagingHookRegistry(FAKE_TELEGRAM_HOOK_REGISTRATIONS); + const hook = telegramManifest.hooks.find((entry) => entry.phase === "reachability-check"); + + if (!hook) throw new Error("missing Telegram reachability hook"); + + await expect( + runMessagingHook(hook, registry, { + channelId: "telegram", + inputs: { + botToken: "123456:telegram-token", + }, + }), + ).resolves.toEqual({ + hookId: "telegram-reachability", + handlerId: TELEGRAM_GET_ME_REACHABILITY_HOOK_ID, + phase: "reachability-check", + outputs: {}, + }); + }); +}); diff --git a/src/lib/messaging/channels/telegram/hooks/fakes.ts b/src/lib/messaging/channels/telegram/hooks/fakes.ts new file mode 100644 index 0000000000..be72c6385b --- /dev/null +++ b/src/lib/messaging/channels/telegram/hooks/fakes.ts @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { MessagingHookHandler, MessagingHookRegistration } from "../../../hooks"; + +export const TELEGRAM_GET_ME_REACHABILITY_HOOK_ID = "telegram.getMeReachability"; + +export const fakeTelegramGetMeReachabilityHook: MessagingHookHandler = (context) => { + const token = context.inputs?.botToken; + if (typeof token !== "string" || token.length === 0) { + throw new Error("Telegram reachability check requires botToken."); + } + return {}; +}; + +export const FAKE_TELEGRAM_HOOK_REGISTRATIONS: readonly MessagingHookRegistration[] = [ + { + id: TELEGRAM_GET_ME_REACHABILITY_HOOK_ID, + handler: fakeTelegramGetMeReachabilityHook, + }, +] as const; diff --git a/src/lib/messaging/channels/telegram/manifest.ts b/src/lib/messaging/channels/telegram/manifest.ts index a322c9a090..0aa9906acd 100644 --- a/src/lib/messaging/channels/telegram/manifest.ts +++ b/src/lib/messaging/channels/telegram/manifest.ts @@ -145,5 +145,12 @@ export const telegramManifest = { ], onFailure: "skip-channel", }, + { + id: "telegram-reachability", + phase: "reachability-check", + handler: "telegram.getMeReachability", + inputs: ["botToken"], + onFailure: "abort", + }, ], } as const satisfies ChannelManifest; diff --git a/src/lib/messaging/channels/wechat/hooks/fakes.test.ts b/src/lib/messaging/channels/wechat/hooks/fakes.test.ts index ba25417532..428e9f7fac 100644 --- a/src/lib/messaging/channels/wechat/hooks/fakes.test.ts +++ b/src/lib/messaging/channels/wechat/hooks/fakes.test.ts @@ -95,6 +95,13 @@ describe("WeChat fake hook implementations", () => { value: { path: "openclaw.json", merge: { + plugins: { + entries: { + "openclaw-weixin": { + enabled: true, + }, + }, + }, channels: { "openclaw-weixin": { accounts: { diff --git a/src/lib/messaging/channels/wechat/hooks/fakes.ts b/src/lib/messaging/channels/wechat/hooks/fakes.ts index d7a856b0ca..e7876f3bcc 100644 --- a/src/lib/messaging/channels/wechat/hooks/fakes.ts +++ b/src/lib/messaging/channels/wechat/hooks/fakes.ts @@ -15,6 +15,9 @@ const FAKE_WECHAT_BASE_URL = "https://ilinkai.wechat.example"; const FAKE_WECHAT_USER_ID = "fake-wechat-user"; const FAKE_WECHAT_TOKEN_PLACEHOLDER = "openshell:resolve:env:WECHAT_BOT_TOKEN"; const FAKE_WECHAT_SAVED_AT = "2026-01-01T00:00:00.000Z"; +const WECHAT_PLUGIN_ID = "openclaw-weixin"; +const WECHAT_PLUGIN_INSTALL_PATH = "/sandbox/.openclaw/extensions/openclaw-weixin"; +const WECHAT_PLUGIN_SPEC = "@tencent-weixin/openclaw-weixin@2.4.2"; export const fakeWechatIlinkLoginHook: MessagingHookHandler = () => ({ outputs: { @@ -80,14 +83,25 @@ export const fakeWechatSeedOpenClawAccountHook: MessagingHookHandler = (context) path: "openclaw.json", merge: { plugins: { + installs: { + [WECHAT_PLUGIN_ID]: { + source: "npm", + spec: WECHAT_PLUGIN_SPEC, + installPath: WECHAT_PLUGIN_INSTALL_PATH, + }, + }, + load: { + paths: [WECHAT_PLUGIN_INSTALL_PATH], + }, entries: { - "openclaw-weixin": { + [WECHAT_PLUGIN_ID]: { enabled: true, }, }, }, channels: { - "openclaw-weixin": { + [WECHAT_PLUGIN_ID]: { + channelConfigUpdatedAt: FAKE_WECHAT_SAVED_AT, accounts: { [accountId]: { enabled: true, diff --git a/src/lib/messaging/channels/wechat/manifest.ts b/src/lib/messaging/channels/wechat/manifest.ts index 203e09c81a..6fb465983d 100644 --- a/src/lib/messaging/channels/wechat/manifest.ts +++ b/src/lib/messaging/channels/wechat/manifest.ts @@ -135,6 +135,7 @@ export const wechatManifest = { id: "wechat-seed-openclaw-account", phase: "post-agent-install", handler: "wechat.seedOpenClawAccount", + agents: ["openclaw"], inputs: [ "wechatConfig.accountId", "wechatConfig.baseUrl", diff --git a/src/lib/messaging/compiler/engines/agent-render-engine.ts b/src/lib/messaging/compiler/engines/agent-render-engine.ts new file mode 100644 index 0000000000..5e58126811 --- /dev/null +++ b/src/lib/messaging/compiler/engines/agent-render-engine.ts @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { + ChannelManifest, + SandboxMessagingAgentRenderPlan, + SandboxMessagingEnvLinesRenderPlan, + SandboxMessagingJsonRenderPlan, +} from "../../manifest"; +import type { ManifestCompilerContext } from "../types"; +import { + collectTemplateReferencesInLines, + collectTemplateReferencesInValue, + resolveCredentialTemplatesInLines, + resolveCredentialTemplatesInValue, +} from "./template"; + +export 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 { + channelId: manifest.id, + renderId: render.id, + kind: "env-lines", + agent: render.agent, + target: render.target, + lines, + templateRefs: collectTemplateReferencesInLines(lines), + } satisfies SandboxMessagingEnvLinesRenderPlan; + }); +} diff --git a/src/lib/messaging/compiler/engines/build-step-engine.ts b/src/lib/messaging/compiler/engines/build-step-engine.ts new file mode 100644 index 0000000000..29ec2dcb69 --- /dev/null +++ b/src/lib/messaging/compiler/engines/build-step-engine.ts @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { + ChannelHookOutputSpec, + ChannelManifest, + MessagingAgentId, + SandboxMessagingBuildStepPlan, +} from "../../manifest"; + +export 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) => ({ + channelId: manifest.id, + kind: output.kind, + hookId: hook.id, + handler: hook.handler, + outputId: output.id, + required: output.required === true, + })); + }); +} + +function isBuildStepOutput( + output: ChannelHookOutputSpec, +): output is ChannelHookOutputSpec & { readonly kind: "build-arg" | "build-file" } { + return output.kind === "build-arg" || output.kind === "build-file"; +} diff --git a/src/lib/messaging/compiler/engines/credential-binding-engine.ts b/src/lib/messaging/compiler/engines/credential-binding-engine.ts new file mode 100644 index 0000000000..523f94ea2a --- /dev/null +++ b/src/lib/messaging/compiler/engines/credential-binding-engine.ts @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { + ChannelManifest, + SandboxMessagingCredentialBindingPlan, + SandboxMessagingInputReference, +} from "../../manifest"; +import type { ManifestCompilerContext } from "../types"; +import { resolveSandboxNameTemplate } from "./template"; + +export function planCredentialBindings( + manifest: ChannelManifest, + context: ManifestCompilerContext, + inputs: readonly SandboxMessagingInputReference[], +): SandboxMessagingCredentialBindingPlan[] { + return manifest.credentials.map((credential) => { + const sourceInput = inputs.find((input) => input.inputId === credential.sourceInput); + + return { + channelId: manifest.id, + credentialId: credential.id, + sourceInput: credential.sourceInput, + providerName: resolveSandboxNameTemplate(credential.providerName, context.sandboxName), + providerEnvKey: credential.providerEnvKey, + placeholder: credential.placeholder, + credentialAvailable: + sourceInput?.credentialAvailable === true || + context.credentialAvailability?.[credential.id] === true || + context.credentialAvailability?.[`${manifest.id}.${credential.id}`] === true, + }; + }); +} diff --git a/src/lib/messaging/compiler/engines/health-check-engine.ts b/src/lib/messaging/compiler/engines/health-check-engine.ts new file mode 100644 index 0000000000..d19952efbf --- /dev/null +++ b/src/lib/messaging/compiler/engines/health-check-engine.ts @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { ChannelManifest, SandboxMessagingHealthCheckPlan } from "../../manifest"; + +export function planHealthChecks(manifest: ChannelManifest): SandboxMessagingHealthCheckPlan[] { + return [ + { + channelId: manifest.id, + phase: "health-check", + requiredBefore: "lifecycle-success", + hookIds: manifest.hooks + .filter((hook) => hook.phase === "health-check") + .map((hook) => hook.id), + }, + ]; +} diff --git a/src/lib/messaging/compiler/engines/policy-resolver.ts b/src/lib/messaging/compiler/engines/policy-resolver.ts new file mode 100644 index 0000000000..4f178fb319 --- /dev/null +++ b/src/lib/messaging/compiler/engines/policy-resolver.ts @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { + ChannelManifest, + MessagingAgentId, + SandboxMessagingNetworkPolicyEntryPlan, + SandboxMessagingNetworkPolicyPlan, +} from "../../manifest"; +import type { ManifestCompilerContext } from "../types"; + +const BUILTIN_POLICY_KEYS: Readonly> = { + telegram: ["telegram_bot"], + discord: ["discord"], + slack: ["slack"], + wechat: ["wechat_bridge"], + whatsapp: ["whatsapp"], +}; + +const AGENT_POLICY_KEY_ALIASES: Readonly< + Record>> +> = { + openclaw: {}, + hermes: { + wechat: ["wechat_bridge"], + }, +}; + +export function planNetworkPolicy( + manifests: readonly ChannelManifest[], + context: ManifestCompilerContext, +): SandboxMessagingNetworkPolicyPlan { + const entries = manifests.flatMap((manifest) => planManifestPolicyEntries(manifest, context)); + return { + presets: unique(entries.map((entry) => entry.presetName)), + entries, + }; +} + +function planManifestPolicyEntries( + manifest: ChannelManifest, + context: ManifestCompilerContext, +): SandboxMessagingNetworkPolicyEntryPlan[] { + return (manifest.policyPresets ?? []).map((presetName) => { + const agentAlias = AGENT_POLICY_KEY_ALIASES[context.agent][presetName]; + if (agentAlias) { + return { + channelId: manifest.id, + presetName, + policyKeys: agentAlias, + source: "agent-alias", + }; + } + + const builtinKeys = BUILTIN_POLICY_KEYS[presetName]; + return { + channelId: manifest.id, + presetName, + policyKeys: builtinKeys ?? [presetName], + source: builtinKeys ? "builtin" : "manifest", + }; + }); +} + +function unique(values: readonly string[]): string[] { + return [...new Set(values)]; +} diff --git a/src/lib/messaging/compiler/engines/state-update-engine.ts b/src/lib/messaging/compiler/engines/state-update-engine.ts new file mode 100644 index 0000000000..7d9adeb90c --- /dev/null +++ b/src/lib/messaging/compiler/engines/state-update-engine.ts @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { ChannelManifest, SandboxMessagingStateUpdatePlan } from "../../manifest"; + +export function planStateUpdates(manifest: ChannelManifest): SandboxMessagingStateUpdatePlan[] { + const persistUpdates = Object.entries(manifest.state.persist ?? {}).map( + ([stateKey, inputIds]) => ({ + channelId: manifest.id, + kind: "persist-inputs" as const, + stateKey, + inputIds, + }), + ); + + const hydrationUpdates = (manifest.state.rebuildHydration ?? []).map((hydration) => ({ + channelId: manifest.id, + kind: "rebuild-hydration" as const, + statePath: hydration.statePath, + env: hydration.env, + })); + + return [...persistUpdates, ...hydrationUpdates]; +} diff --git a/src/lib/messaging/compiler/engines/template.ts b/src/lib/messaging/compiler/engines/template.ts new file mode 100644 index 0000000000..1400029c81 --- /dev/null +++ b/src/lib/messaging/compiler/engines/template.ts @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { + ChannelCredentialSpec, + MessagingSerializableValue, + MessagingTemplateString, +} from "../../manifest"; + +const CREDENTIAL_PLACEHOLDER_PATTERN = + /\{\{\s*credential\.([A-Za-z0-9_-]+)\.placeholder\s*\}\}/g; +const TEMPLATE_REFERENCE_PATTERN = /\{\{\s*([^}]+?)\s*\}\}/g; + +export function resolveSandboxNameTemplate( + value: MessagingTemplateString, + sandboxName: string, +): MessagingTemplateString { + return value.replaceAll("{sandboxName}", sandboxName); +} + +export function resolveCredentialTemplatesInValue( + value: MessagingSerializableValue, + credentials: readonly ChannelCredentialSpec[], +): MessagingSerializableValue { + if (typeof value === "string") return resolveCredentialTemplatesInString(value, credentials); + if (Array.isArray(value)) { + return value.map((entry) => resolveCredentialTemplatesInValue(entry, credentials)); + } + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [ + key, + resolveCredentialTemplatesInValue(entry, credentials), + ]), + ); + } + return value; +} + +export function resolveCredentialTemplatesInLines( + lines: readonly MessagingTemplateString[], + credentials: readonly ChannelCredentialSpec[], +): MessagingTemplateString[] { + return lines.map((line) => resolveCredentialTemplatesInString(line, credentials)); +} + +export function collectTemplateReferencesInValue( + value: MessagingSerializableValue, +): string[] { + if (typeof value === "string") return collectTemplateReferencesInString(value); + if (Array.isArray(value)) { + return unique(value.flatMap((entry) => collectTemplateReferencesInValue(entry))); + } + if (value && typeof value === "object") { + return unique( + Object.values(value).flatMap((entry) => collectTemplateReferencesInValue(entry)), + ); + } + return []; +} + +export function collectTemplateReferencesInLines( + lines: readonly MessagingTemplateString[], +): string[] { + return unique(lines.flatMap((line) => collectTemplateReferencesInString(line))); +} + +function resolveCredentialTemplatesInString( + value: MessagingTemplateString, + credentials: readonly ChannelCredentialSpec[], +): MessagingTemplateString { + return value.replace(CREDENTIAL_PLACEHOLDER_PATTERN, (match, credentialId: string) => { + const credential = credentials.find((entry) => entry.id === credentialId); + return credential?.placeholder ?? match; + }); +} + +function collectTemplateReferencesInString(value: MessagingTemplateString): string[] { + return unique( + [...value.matchAll(TEMPLATE_REFERENCE_PATTERN)] + .map((match) => match[1]?.trim()) + .filter((reference): reference is string => typeof reference === "string" && reference.length > 0), + ); +} + +function unique(values: readonly string[]): string[] { + return [...new Set(values)]; +} diff --git a/src/lib/messaging/compiler/index.ts b/src/lib/messaging/compiler/index.ts new file mode 100644 index 0000000000..ae24e2779a --- /dev/null +++ b/src/lib/messaging/compiler/index.ts @@ -0,0 +1,6 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export * from "./manifest-compiler"; +export * from "./workflow-planner"; +export type * from "./types"; diff --git a/src/lib/messaging/compiler/manifest-compiler.test.ts b/src/lib/messaging/compiler/manifest-compiler.test.ts new file mode 100644 index 0000000000..3f5482f03d --- /dev/null +++ b/src/lib/messaging/compiler/manifest-compiler.test.ts @@ -0,0 +1,516 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { createBuiltInChannelManifestRegistry } from "../channels"; +import { FAKE_TELEGRAM_HOOK_REGISTRATIONS } from "../channels/telegram/hooks/fakes"; +import { FAKE_WECHAT_HOOK_REGISTRATIONS } from "../channels/wechat/hooks/fakes"; +import { MessagingHookRegistry } from "../hooks"; +import { FAKE_COMMON_HOOK_REGISTRATIONS } from "../hooks/common"; +import { + ChannelManifestRegistry, + type ChannelManifest, + type SandboxMessagingPlan, +} from "../manifest"; +import { ManifestCompiler } from "./manifest-compiler"; + +const ALL_CHANNELS = ["telegram", "discord", "wechat", "slack", "whatsapp"] as const; + +function compiler(): ManifestCompiler { + return new ManifestCompiler( + createBuiltInChannelManifestRegistry(), + new MessagingHookRegistry([ + ...FAKE_COMMON_HOOK_REGISTRATIONS, + ...FAKE_TELEGRAM_HOOK_REGISTRATIONS, + ...FAKE_WECHAT_HOOK_REGISTRATIONS, + ]), + ); +} + +function jsonRoundTrip(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function findFunctionPaths(value: unknown, prefix = "$"): string[] { + if (typeof value === "function") return [prefix]; + if (Array.isArray(value)) { + return value.flatMap((entry, index) => findFunctionPaths(entry, `${prefix}[${index}]`)); + } + if (value && typeof value === "object") { + return Object.entries(value).flatMap(([key, entry]) => + findFunctionPaths(entry, `${prefix}.${key}`), + ); + } + return []; +} + +async function withEnv( + values: Readonly>, + run: () => Promise, +): Promise { + const previous = Object.fromEntries( + Object.keys(values).map((key) => [key, process.env[key]]), + ); + try { + for (const [key, value] of Object.entries(values)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + return await run(); + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } +} + +describe("ManifestCompiler", () => { + it("compiles built-in manifests into a deterministic OpenClaw plan", async () => { + const plan = await compiler().compile({ + sandboxName: "demo", + agent: "openclaw", + workflow: "onboard", + isInteractive: true, + selectedChannels: ["slack", "telegram", "wechat", "discord", "whatsapp"], + credentialAvailability: { + TELEGRAM_BOT_TOKEN: true, + DISCORD_BOT_TOKEN: true, + WECHAT_BOT_TOKEN: true, + SLACK_BOT_TOKEN: true, + SLACK_APP_TOKEN: true, + }, + }); + + expect(plan.channels.map((channel) => channel.channelId)).toEqual(ALL_CHANNELS); + expect(plan.channels.every((channel) => channel.active)).toBe(true); + expect(plan.credentialBindings.map((binding) => binding.providerName)).toEqual([ + "demo-telegram-bridge", + "demo-discord-bridge", + "demo-wechat-bridge", + "demo-slack-bridge", + "demo-slack-app", + ]); + expect(plan.credentialBindings.map((binding) => binding.placeholder)).toEqual([ + "openshell:resolve:env:TELEGRAM_BOT_TOKEN", + "openshell:resolve:env:DISCORD_BOT_TOKEN", + "openshell:resolve:env:WECHAT_BOT_TOKEN", + "xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN", + "xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN", + ]); + expect(plan.networkPolicy.entries).toEqual([ + { + channelId: "telegram", + presetName: "telegram", + policyKeys: ["telegram_bot"], + source: "builtin", + }, + { + channelId: "discord", + presetName: "discord", + policyKeys: ["discord"], + source: "builtin", + }, + { + channelId: "wechat", + presetName: "wechat", + policyKeys: ["wechat_bridge"], + source: "builtin", + }, + { + channelId: "slack", + presetName: "slack", + policyKeys: ["slack"], + source: "builtin", + }, + { + channelId: "whatsapp", + presetName: "whatsapp", + policyKeys: ["whatsapp"], + source: "builtin", + }, + ]); + 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(JSON.stringify(plan.agentRender)).toContain( + "openshell:resolve:env:TELEGRAM_BOT_TOKEN", + ); + expect(plan.buildSteps).toEqual([ + { + channelId: "wechat", + kind: "build-file", + hookId: "wechat-seed-openclaw-account", + handler: "wechat.seedOpenClawAccount", + outputId: "openclawWeixinAccountsIndex", + required: true, + }, + { + channelId: "wechat", + kind: "build-file", + hookId: "wechat-seed-openclaw-account", + handler: "wechat.seedOpenClawAccount", + outputId: "openclawWeixinAccountFile", + required: true, + }, + { + channelId: "wechat", + kind: "build-file", + hookId: "wechat-seed-openclaw-account", + handler: "wechat.seedOpenClawAccount", + outputId: "openclawConfigPatch", + required: true, + }, + ]); + expect(plan.stateUpdates).toContainEqual({ + channelId: "wechat", + kind: "rebuild-hydration", + statePath: "wechatConfig.accountId", + env: "WECHAT_ACCOUNT_ID", + }); + expect(plan.healthChecks).toHaveLength(ALL_CHANNELS.length); + expect(plan.healthChecks.every((check) => check.requiredBefore === "lifecycle-success")).toBe( + true, + ); + expect( + plan.agentRender.find( + (render) => render.channelId === "telegram" && render.kind === "json-fragment", + )?.templateRefs, + ).toEqual(expect.arrayContaining(["proxyUrl", "allowedIds.telegram.values"])); + }); + + it("compiles Hermes render and WeChat agent policy alias intent", async () => { + const plan = await compiler().compile({ + sandboxName: "demo", + agent: "hermes", + workflow: "rebuild", + isInteractive: false, + selectedChannels: ALL_CHANNELS, + }); + + expect(plan.networkPolicy.entries.find((entry) => entry.channelId === "wechat")).toEqual({ + channelId: "wechat", + presetName: "wechat", + policyKeys: ["wechat_bridge"], + source: "agent-alias", + }); + expect(plan.agentRender.map((render) => `${render.channelId}:${render.target}`)).toEqual([ + "telegram:~/.hermes/.env", + "telegram:~/.hermes/config.yaml", + "discord:~/.hermes/.env", + "discord:~/.hermes/config.yaml", + "wechat:~/.hermes/.env", + "slack:~/.hermes/.env", + "whatsapp:~/.hermes/.env", + ]); + expect(JSON.stringify(plan.agentRender)).toContain( + "WEIXIN_TOKEN=openshell:resolve:env:WECHAT_BOT_TOKEN", + ); + expect(plan.buildSteps).toEqual([]); + expect( + plan.channels + .find((channel) => channel.channelId === "wechat") + ?.inputs.find((input) => input.inputId === "accountId"), + ).not.toHaveProperty("value"); + }); + + it("runs enrollment hooks before returning the final channel input plan", async () => { + const plan = await compiler().compile({ + sandboxName: "demo", + agent: "openclaw", + workflow: "onboard", + isInteractive: true, + selectedChannels: ["wechat", "telegram"], + }); + + const telegram = plan.channels.find((channel) => channel.channelId === "telegram"); + const wechat = plan.channels.find((channel) => channel.channelId === "wechat"); + + expect(telegram?.inputs.find((input) => input.inputId === "botToken")).toMatchObject({ + kind: "secret", + credentialAvailable: true, + }); + expect(wechat?.inputs.find((input) => input.inputId === "botToken")).toMatchObject({ + kind: "secret", + credentialAvailable: true, + }); + expect(wechat?.inputs.find((input) => input.inputId === "accountId")).toMatchObject({ + kind: "config", + value: "fake-wechat-account", + }); + expect(wechat?.inputs.find((input) => input.inputId === "baseUrl")).toMatchObject({ + kind: "config", + value: "https://ilinkai.wechat.example", + }); + }); + + it("skips token-paste and QR enrollment hooks for non-interactive create plans", async () => { + const hooks = new MessagingHookRegistry([ + { + id: "common.tokenPaste", + handler: () => { + throw new Error("token-paste hook should not run"); + }, + }, + ]); + const plan = await new ManifestCompiler( + createBuiltInChannelManifestRegistry(), + hooks, + ).compile({ + sandboxName: "demo", + agent: "openclaw", + workflow: "onboard", + isInteractive: false, + selectedChannels: ["telegram"], + credentialAvailability: { + TELEGRAM_BOT_TOKEN: true, + }, + }); + + expect(plan.channels[0]?.inputs.find((input) => input.inputId === "botToken")).toMatchObject({ + kind: "secret", + credentialAvailable: true, + }); + }); + + it("reads input values from env keys before returning non-interactive plans", async () => { + await withEnv( + { + TELEGRAM_BOT_TOKEN: "123456:raw-telegram-token", + TELEGRAM_ALLOWED_IDS: "123456789", + }, + async () => { + const plan = await compiler().compile({ + sandboxName: "demo", + agent: "openclaw", + workflow: "onboard", + isInteractive: false, + selectedChannels: ["telegram"], + }); + + expect(plan.channels[0]?.inputs.find((input) => input.inputId === "botToken")).toMatchObject({ + kind: "secret", + credentialAvailable: true, + }); + expect(plan.channels[0]?.inputs.find((input) => input.inputId === "allowedIds")).toMatchObject({ + kind: "config", + value: "123456789", + }); + expect(JSON.stringify(plan)).not.toContain("123456:raw-telegram-token"); + }, + ); + }); + + it("keeps compiled plans serializable, deterministic, and secret-free", async () => { + const context = { + sandboxName: "demo", + agent: "openclaw", + workflow: "onboard", + isInteractive: false, + selectedChannels: ["telegram"], + credentialAvailability: { + TELEGRAM_BOT_TOKEN: true, + }, + } as const; + const first = await compiler().compile(context); + const second = await compiler().compile(context); + const serialized = JSON.stringify(first); + + expect(second).toEqual(first); + expect(jsonRoundTrip(first)).toEqual(first); + expect(findFunctionPaths(first)).toEqual([]); + expect(serialized).toContain("openshell:resolve:env:TELEGRAM_BOT_TOKEN"); + expect(serialized).not.toContain("123456:raw-telegram-token"); + expect(Object.keys(first)).toEqual([ + "schemaVersion", + "sandboxName", + "agent", + "workflow", + "channels", + "credentialBindings", + "networkPolicy", + "agentRender", + "buildSteps", + "stateUpdates", + "healthChecks", + ] satisfies Array); + }); + + it("records disabled configured channels without planning side effects for them", async () => { + const plan = await compiler().compile({ + sandboxName: "demo", + agent: "openclaw", + workflow: "stop-channel", + isInteractive: false, + selectedChannels: [], + configuredChannels: ["telegram"], + disabledChannels: ["telegram"], + }); + + expect(plan.channels).toHaveLength(1); + expect(plan.channels[0]).toMatchObject({ + channelId: "telegram", + active: false, + configured: true, + disabled: true, + }); + expect(plan.credentialBindings).toEqual([]); + expect(plan.networkPolicy.entries).toEqual([]); + expect(plan.agentRender).toEqual([]); + expect(plan.buildSteps).toEqual([]); + expect(plan.stateUpdates).toEqual([]); + expect(plan.healthChecks).toEqual([]); + }); + + it("compiles a non-built-in channel manifest through the same generic path", async () => { + const hookCalls: string[] = []; + const customManifest = { + schemaVersion: 1, + id: "matrix", + displayName: "Matrix", + supportedAgents: ["openclaw"], + auth: { + mode: "token-paste", + }, + inputs: [ + { + id: "accessToken", + kind: "secret", + required: true, + envKey: "MATRIX_ACCESS_TOKEN", + }, + { + id: "roomId", + kind: "config", + required: true, + envKey: "MATRIX_ROOM_ID", + }, + ], + credentials: [ + { + id: "matrixAccessToken", + sourceInput: "accessToken", + providerName: "{sandboxName}-matrix-bridge", + providerEnvKey: "MATRIX_ACCESS_TOKEN", + placeholder: "openshell:resolve:env:MATRIX_ACCESS_TOKEN", + }, + ], + policyPresets: ["matrix"], + render: [], + state: {}, + hooks: [ + { + id: "matrix-enroll", + phase: "enroll", + handler: "matrix.enroll", + outputs: [ + { + id: "accessToken", + kind: "secret", + required: true, + }, + { + id: "roomId", + kind: "config", + required: true, + }, + ], + }, + { + id: "matrix-host-probe", + phase: "reachability-check", + handler: "matrix.probeHost", + inputs: ["roomId"], + onFailure: "abort", + }, + ], + } as const satisfies ChannelManifest; + const hooks = new MessagingHookRegistry([ + { + id: "matrix.enroll", + handler: () => { + hookCalls.push("enroll"); + return { + outputs: { + accessToken: { + kind: "secret", + value: "raw-matrix-token", + }, + roomId: { + kind: "config", + value: "!room:example.com", + }, + }, + }; + }, + }, + { + id: "matrix.probeHost", + handler: (context) => { + hookCalls.push(`reachability:${String(context.inputs?.roomId)}`); + return {}; + }, + }, + ]); + const plan = await new ManifestCompiler( + new ChannelManifestRegistry([customManifest]), + hooks, + ).compile({ + sandboxName: "demo", + agent: "openclaw", + workflow: "onboard", + isInteractive: true, + selectedChannels: ["matrix"], + }); + + expect(plan.channels.map((channel) => channel.channelId)).toEqual(["matrix"]); + expect(plan.channels[0]?.inputs).toContainEqual( + expect.objectContaining({ + inputId: "accessToken", + credentialAvailable: true, + }), + ); + expect(plan.channels[0]?.inputs).toContainEqual( + expect.objectContaining({ + inputId: "roomId", + value: "!room:example.com", + }), + ); + expect(plan.credentialBindings[0]).toMatchObject({ + channelId: "matrix", + providerName: "demo-matrix-bridge", + credentialAvailable: true, + }); + expect(plan.networkPolicy.entries).toEqual([ + { + channelId: "matrix", + presetName: "matrix", + policyKeys: ["matrix"], + source: "manifest", + }, + ]); + expect(plan.channels[0]?.hooks).toContainEqual( + expect.objectContaining({ + phase: "reachability-check", + handler: "matrix.probeHost", + }), + ); + expect(hookCalls).toEqual([ + "enroll", + "reachability:!room:example.com", + ]); + expect(JSON.stringify(plan)).not.toContain("raw-matrix-token"); + }); +}); diff --git a/src/lib/messaging/compiler/manifest-compiler.ts b/src/lib/messaging/compiler/manifest-compiler.ts new file mode 100644 index 0000000000..d4f2d8615c --- /dev/null +++ b/src/lib/messaging/compiler/manifest-compiler.ts @@ -0,0 +1,382 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { + ChannelHookSpec, + ChannelInputSpec, + ChannelManifest, + ChannelManifestRegistry, + MessagingChannelId, + MessagingStatePath, + MessagingSerializableValue, + SandboxMessagingChannelPlan, + SandboxMessagingHookReferencePlan, + SandboxMessagingInputReference, + SandboxMessagingPlan, +} from "../manifest"; +import { MessagingHookRegistry, runMessagingHook } from "../hooks"; +import type { + MessagingHookInputMap, + MessagingHookOutputMap, + MessagingHookRunResult, +} from "../hooks"; +import { planAgentRender } from "./engines/agent-render-engine"; +import { planBuildSteps } from "./engines/build-step-engine"; +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 { ManifestCompilerContext } from "./types"; + +export class ManifestCompiler { + constructor( + private readonly registry: ChannelManifestRegistry, + private readonly hooks = new MessagingHookRegistry(), + ) {} + + async compile(context: ManifestCompilerContext): Promise { + const manifests = this.resolveManifests(requestedChannelIds(context), context); + const channels = []; + for (const manifest of manifests) { + channels.push(await this.compileChannel(manifest, context)); + } + const inputRegistry = new Map( + channels.map((channel) => [channel.channelId, channel.inputs] as const), + ); + const activeManifests = manifests.filter((manifest) => + isChannelActive(manifest.id, context), + ); + const credentialBindings = activeManifests.flatMap((manifest) => + planCredentialBindings(manifest, context, inputRegistry.get(manifest.id) ?? []), + ); + const networkPolicy = planNetworkPolicy(activeManifests, context); + const agentRender = activeManifests.flatMap((manifest) => + planAgentRender(manifest, context), + ); + const buildSteps = activeManifests.flatMap((manifest) => + planBuildSteps(manifest, context.agent), + ); + const stateUpdates = activeManifests.flatMap((manifest) => planStateUpdates(manifest)); + const healthChecks = activeManifests.flatMap((manifest) => planHealthChecks(manifest)); + + return { + schemaVersion: 1, + sandboxName: context.sandboxName, + agent: context.agent, + workflow: context.workflow, + channels, + credentialBindings, + networkPolicy, + agentRender, + buildSteps, + stateUpdates, + healthChecks, + }; + } + + private resolveManifests( + channelIds: readonly MessagingChannelId[], + context: ManifestCompilerContext, + ): ChannelManifest[] { + const requestedIds = new Set(channelIds); + const supportedIds = + context.supportedChannelIds && context.supportedChannelIds.length > 0 + ? new Set(context.supportedChannelIds) + : null; + + const manifests = this.registry + .list() + .filter((manifest) => requestedIds.has(manifest.id)) + .filter((manifest) => manifest.supportedAgents.includes(context.agent)) + .filter((manifest) => !supportedIds || supportedIds.has(manifest.id)); + + const foundIds = new Set(manifests.map((manifest) => manifest.id)); + const missingIds = [...requestedIds].filter((channelId) => !foundIds.has(channelId)); + if (missingIds.length > 0) { + throw new Error(`Missing messaging channel manifest(s): ${missingIds.join(", ")}`); + } + + return manifests; + } + + private async compileChannel( + manifest: ChannelManifest, + context: ManifestCompilerContext, + ): Promise { + const selected = context.selectedChannels.includes(manifest.id); + const configured = context.configuredChannels?.includes(manifest.id) ?? false; + const disabled = context.disabledChannels?.includes(manifest.id) ?? false; + const active = !disabled && (selected || configured); + + return { + channelId: manifest.id, + displayName: manifest.displayName, + authMode: manifest.auth.mode, + active, + selected, + configured, + disabled, + inputs: await resolveChannelInputs(manifest, context, this.hooks, { + runEnrollment: + selected && active && isEnrollmentWorkflow(context.workflow) && context.isInteractive, + runEnrollmentChecks: selected && active && isEnrollmentWorkflow(context.workflow), + }), + hooks: manifest.hooks + .filter((hook) => isHookForAgent(hook, context.agent)) + .map((hook) => cloneHookReference(manifest.id, hook)), + }; + } +} + +function isHookForAgent(hook: ChannelHookSpec, agent: ManifestCompilerContext["agent"]): boolean { + return !hook.agents || hook.agents.includes(agent); +} + +function requestedChannelIds(context: ManifestCompilerContext): MessagingChannelId[] { + return uniqueChannels([...context.selectedChannels, ...(context.configuredChannels ?? [])]); +} + +function uniqueChannels(channelIds: readonly MessagingChannelId[]): MessagingChannelId[] { + return [...new Set(channelIds)]; +} + +function isEnrollmentWorkflow(workflow: ManifestCompilerContext["workflow"]): boolean { + return workflow === "onboard" || workflow === "add-channel"; +} + +function isChannelActive( + channelId: MessagingChannelId, + context: ManifestCompilerContext, +): boolean { + if (context.disabledChannels?.includes(channelId)) return false; + return ( + context.selectedChannels.includes(channelId) || + (context.configuredChannels ?? []).includes(channelId) + ); +} + +function cloneHookReference( + channelId: MessagingChannelId, + hook: ChannelManifest["hooks"][number], +): SandboxMessagingHookReferencePlan { + return { + channelId, + id: hook.id, + phase: hook.phase, + handler: hook.handler, + agents: hook.agents ? [...hook.agents] : undefined, + inputs: hook.inputs ? [...hook.inputs] : undefined, + outputs: hook.outputs?.map((output) => ({ ...output })), + onFailure: hook.onFailure, + }; +} + +async function resolveChannelInputs( + manifest: ChannelManifest, + context: ManifestCompilerContext, + hooks: MessagingHookRegistry, + options: { readonly runEnrollment: boolean; readonly runEnrollmentChecks: boolean }, +): Promise { + let inputs = manifest.inputs.map((input) => resolveChannelInput(manifest, input, context)); + let hookInputs = buildCompilerHookInputs(manifest, inputs); + inputs = applyCredentialAvailability(manifest, inputs, context); + const enrollmentHooks = options.runEnrollment + ? manifest.hooks + .filter((hook) => isHookForAgent(hook, context.agent)) + .filter((hook) => hook.phase === "enroll") + : []; + + for (const hook of enrollmentHooks) { + const result = await runCompilerHook(manifest, hook, hooks, hookInputs); + if (!result) continue; + hookInputs = mergeHookOutputsIntoInputs(manifest, hookInputs, result.outputs); + inputs = applyCredentialAvailability( + manifest, + mergeEnrollmentOutputs(inputs, result.outputs), + context, + ); + } + + if (options.runEnrollmentChecks && hasRequiredInputsAvailable(manifest, inputs)) { + for (const hook of manifest.hooks + .filter((entry) => isHookForAgent(entry, context.agent)) + .filter((entry) => entry.phase === "reachability-check") + .filter((entry) => hasDeclaredHookInputs(hookInputs, entry))) { + await runCompilerHook(manifest, hook, hooks, hookInputs); + } + } + + return inputs; +} + +async function runCompilerHook( + manifest: ChannelManifest, + hook: ChannelHookSpec, + hooks: MessagingHookRegistry, + inputs: MessagingHookInputMap, +): Promise { + try { + return await runMessagingHook(hook, hooks, { + channelId: manifest.id, + inputs: selectDeclaredHookInputs(hook, inputs), + }); + } catch (error) { + if (hook.onFailure === "skip-channel") return null; + throw error; + } +} + +function resolveChannelInput( + manifest: ChannelManifest, + input: ChannelInputSpec, + context: ManifestCompilerContext, +): SandboxMessagingInputReference { + const base = inputReferenceBase(manifest, input); + const envValue = readInputEnvValue(input); + if (envValue !== undefined) { + return input.kind === "secret" + ? { ...base, credentialAvailable: true } + : { ...base, value: envValue }; + } + + return { + ...base, + }; +} + +function inputReferenceBase( + manifest: ChannelManifest, + input: ChannelInputSpec, +): Omit { + const statePath = readInputStatePath(input); + + return { + channelId: manifest.id, + inputId: input.id, + kind: input.kind, + required: input.required, + sourceEnv: input.envKey, + ...(statePath ? { statePath } : {}), + }; +} + +function readInputEnvValue(input: ChannelInputSpec): MessagingSerializableValue | undefined { + if (!input.envKey) return undefined; + const value = process.env[input.envKey]; + return value && value.length > 0 ? value : undefined; +} + +function readInputStatePath(input: ChannelInputSpec): MessagingStatePath | undefined { + return input.kind === "config" ? input.statePath : undefined; +} + +function isCredentialAvailable( + manifest: ChannelManifest, + input: SandboxMessagingInputReference, + context: ManifestCompilerContext, +): boolean { + const availability = context.credentialAvailability ?? {}; + const keys = [input.inputId, `${manifest.id}.${input.inputId}`, input.sourceEnv].filter( + (key): key is string => typeof key === "string" && key.length > 0, + ); + + return keys.some((key) => availability[key] === true); +} + +function applyCredentialAvailability( + manifest: ChannelManifest, + inputs: readonly SandboxMessagingInputReference[], + context: ManifestCompilerContext, +): SandboxMessagingInputReference[] { + return inputs.map((input) => { + if (input.kind !== "secret") return input; + return { + ...input, + credentialAvailable: + input.credentialAvailable === true || isCredentialAvailable(manifest, input, context), + }; + }); +} + +function hasRequiredInputsAvailable( + manifest: ChannelManifest, + inputs: readonly SandboxMessagingInputReference[], +): boolean { + const byId = new Map(inputs.map((input) => [input.inputId, input])); + return manifest.inputs.every((input) => { + if (!input.required) return true; + const resolved = byId.get(input.id); + if (!resolved) return false; + return resolved.kind === "secret" + ? resolved.credentialAvailable === true + : resolved.value !== undefined; + }); +} + +function buildCompilerHookInputs( + manifest: ChannelManifest, + inputs: readonly SandboxMessagingInputReference[], +): Record { + const inputSpecs = new Map(manifest.inputs.map((input) => [input.id, input])); + const entries: Array<[string, MessagingSerializableValue]> = []; + for (const input of inputs) { + const spec = inputSpecs.get(input.inputId); + const value = input.value ?? (spec ? readInputEnvValue(spec) : undefined); + if (value === undefined) continue; + entries.push([input.inputId, value]); + if (input.statePath) entries.push([input.statePath, value]); + } + return Object.fromEntries(entries); +} + +function mergeHookOutputsIntoInputs( + manifest: ChannelManifest, + inputs: Record, + outputs: MessagingHookOutputMap, +): Record { + const next = { ...inputs }; + const inputSpecs = new Map(manifest.inputs.map((input) => [input.id, input])); + for (const [outputId, output] of Object.entries(outputs)) { + if (output.kind !== "secret" && output.kind !== "config") continue; + next[outputId] = output.value; + const statePath = inputSpecs.get(outputId)?.statePath; + if (statePath) next[statePath] = output.value; + } + return next; +} + +function hasDeclaredHookInputs( + inputs: MessagingHookInputMap, + hook: ChannelHookSpec, +): boolean { + return (hook.inputs ?? []).every((inputKey) => Object.hasOwn(inputs, inputKey)); +} + +function selectDeclaredHookInputs( + hook: ChannelHookSpec, + inputs: MessagingHookInputMap, +): MessagingHookInputMap | undefined { + if (!hook.inputs || hook.inputs.length === 0) return undefined; + return Object.fromEntries( + hook.inputs + .filter((inputKey) => Object.hasOwn(inputs, inputKey)) + .map((inputKey) => [inputKey, inputs[inputKey] as MessagingSerializableValue]), + ); +} + +function mergeEnrollmentOutputs( + inputs: readonly SandboxMessagingInputReference[], + outputs: MessagingHookOutputMap, +): SandboxMessagingInputReference[] { + return inputs.map((input) => { + const output = outputs[input.inputId]; + if (!output) return input; + if (output.kind === "secret") { + return { ...input, credentialAvailable: true }; + } + if (output.kind === "config") { + return input.value === undefined ? { ...input, value: output.value } : input; + } + return input; + }); +} diff --git a/src/lib/messaging/compiler/types.ts b/src/lib/messaging/compiler/types.ts new file mode 100644 index 0000000000..3dbd030c6d --- /dev/null +++ b/src/lib/messaging/compiler/types.ts @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { + MessagingAgentId, + MessagingChannelId, + MessagingCompilerWorkflow, +} from "../manifest"; + +/** Credential availability lookup by env key, channel.input id, or credential id. */ +export type MessagingCompilerCredentialAvailability = Readonly>; + +/** Compiler inputs; values here must not contain raw secret material. */ +export interface ManifestCompilerContext { + readonly sandboxName: string; + readonly agent: MessagingAgentId; + readonly workflow: MessagingCompilerWorkflow; + readonly isInteractive: boolean; + readonly selectedChannels: readonly MessagingChannelId[]; + readonly configuredChannels?: readonly MessagingChannelId[]; + readonly disabledChannels?: readonly MessagingChannelId[]; + readonly supportedChannelIds?: readonly MessagingChannelId[]; + readonly credentialAvailability?: MessagingCompilerCredentialAvailability; +} diff --git a/src/lib/messaging/compiler/workflow-planner.test.ts b/src/lib/messaging/compiler/workflow-planner.test.ts new file mode 100644 index 0000000000..3fb501ad59 --- /dev/null +++ b/src/lib/messaging/compiler/workflow-planner.test.ts @@ -0,0 +1,315 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { createBuiltInChannelManifestRegistry } from "../channels"; +import { FAKE_TELEGRAM_HOOK_REGISTRATIONS } from "../channels/telegram/hooks/fakes"; +import { FAKE_WECHAT_HOOK_REGISTRATIONS } from "../channels/wechat/hooks/fakes"; +import { MessagingHookRegistry } from "../hooks"; +import { FAKE_COMMON_HOOK_REGISTRATIONS } from "../hooks/common"; +import { MessagingWorkflowPlanner } from "./workflow-planner"; + +function planner(): MessagingWorkflowPlanner { + return new MessagingWorkflowPlanner( + createBuiltInChannelManifestRegistry(), + new MessagingHookRegistry([ + ...FAKE_COMMON_HOOK_REGISTRATIONS, + ...FAKE_TELEGRAM_HOOK_REGISTRATIONS, + ...FAKE_WECHAT_HOOK_REGISTRATIONS, + ]), + ); +} + +function findFunctionPaths(value: unknown, prefix = "$"): string[] { + if (typeof value === "function") return [prefix]; + if (Array.isArray(value)) { + return value.flatMap((entry, index) => findFunctionPaths(entry, `${prefix}[${index}]`)); + } + if (value && typeof value === "object") { + return Object.entries(value).flatMap(([key, entry]) => + findFunctionPaths(entry, `${prefix}.${key}`), + ); + } + return []; +} + +async function withEnv( + values: Readonly>, + run: () => Promise, +): Promise { + const previous = Object.fromEntries( + Object.keys(values).map((key) => [key, process.env[key]]), + ); + try { + for (const [key, value] of Object.entries(values)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + return await run(); + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } +} + +describe("MessagingWorkflowPlanner", () => { + it("plans onboard as selected, configured, active channels with enrollment inputs", async () => { + const plan = await planner().planOnboard({ + sandboxName: "demo", + agent: "openclaw", + isInteractive: true, + selectedChannels: ["wechat", "telegram"], + }); + + expect(plan.workflow).toBe("onboard"); + expect(plan.channels.map((channel) => channel.channelId)).toEqual([ + "telegram", + "wechat", + ]); + expect(plan.channels).toEqual([ + expect.objectContaining({ + channelId: "telegram", + active: true, + selected: true, + configured: true, + disabled: false, + }), + expect.objectContaining({ + channelId: "wechat", + active: true, + selected: true, + configured: true, + disabled: false, + }), + ]); + expect( + plan.channels + .find((channel) => channel.channelId === "wechat") + ?.inputs.find((input) => input.inputId === "accountId"), + ).toMatchObject({ + kind: "config", + value: "fake-wechat-account", + }); + expect(plan.networkPolicy.entries.map((entry) => entry.channelId)).toEqual([ + "telegram", + "wechat", + ]); + }); + + it("plans add-channel as a configured active target and clears stale disabled state", async () => { + const plan = await planner().planAddChannel({ + sandboxName: "demo", + agent: "openclaw", + isInteractive: true, + channelId: "slack", + configuredChannels: ["telegram"], + disabledChannels: ["telegram", "slack"], + }); + + expect(plan.workflow).toBe("add-channel"); + expect(plan.channels.find((channel) => channel.channelId === "telegram")).toMatchObject({ + configured: true, + disabled: true, + active: false, + selected: false, + }); + expect(plan.channels.find((channel) => channel.channelId === "slack")).toMatchObject({ + configured: true, + disabled: false, + active: true, + selected: true, + }); + expect(plan.networkPolicy.entries.map((entry) => entry.channelId)).toEqual(["slack"]); + }); + + it("runs add-channel enrollment only for the selected channel", async () => { + const hooks = new MessagingHookRegistry([ + { + id: "common.tokenPaste", + handler: (context) => { + if (context.channelId === "telegram") { + throw new Error("existing channels should not re-enroll"); + } + const outputs: Record = {}; + for (const output of context.outputDeclarations ?? []) { + if (output.kind === "secret") { + outputs[output.id] = { + kind: "secret", + value: `fake-${context.channelId}-${output.id}`, + }; + } + } + return { outputs }; + }, + }, + ]); + const plan = await new MessagingWorkflowPlanner( + createBuiltInChannelManifestRegistry(), + hooks, + ).planAddChannel({ + sandboxName: "demo", + agent: "openclaw", + isInteractive: true, + channelId: "slack", + configuredChannels: ["telegram"], + }); + + expect(plan.channels.find((channel) => channel.channelId === "telegram")).toMatchObject({ + active: true, + selected: false, + }); + expect( + plan.channels + .find((channel) => channel.channelId === "slack") + ?.inputs.filter((input) => input.kind === "secret") + .every((input) => input.credentialAvailable === true), + ).toBe(true); + }); + + it("plans stop-channel by keeping configured state and disabling only that channel", async () => { + const plan = await planner().planStopChannel({ + sandboxName: "demo", + agent: "openclaw", + isInteractive: false, + channelId: "telegram", + configuredChannels: ["telegram", "slack"], + }); + + expect(plan.workflow).toBe("stop-channel"); + expect(plan.channels.find((channel) => channel.channelId === "telegram")).toMatchObject({ + configured: true, + disabled: true, + active: false, + selected: true, + }); + expect(plan.channels.find((channel) => channel.channelId === "slack")).toMatchObject({ + configured: true, + disabled: false, + active: true, + selected: false, + }); + expect(plan.networkPolicy.entries.map((entry) => entry.channelId)).toEqual(["slack"]); + expect( + plan.credentialBindings.some((binding) => binding.channelId === "telegram"), + ).toBe(false); + }); + + it("plans start-channel by preserving configured state and making the channel active", async () => { + const plan = await planner().planStartChannel({ + sandboxName: "demo", + agent: "openclaw", + isInteractive: false, + channelId: "telegram", + configuredChannels: ["telegram", "slack"], + disabledChannels: ["telegram"], + credentialAvailability: { + TELEGRAM_BOT_TOKEN: true, + SLACK_BOT_TOKEN: true, + SLACK_APP_TOKEN: true, + }, + }); + + expect(plan.workflow).toBe("start-channel"); + expect(plan.channels.find((channel) => channel.channelId === "telegram")).toMatchObject({ + configured: true, + disabled: false, + active: true, + selected: true, + }); + expect(plan.networkPolicy.entries.map((entry) => entry.channelId)).toEqual([ + "telegram", + "slack", + ]); + }); + + it("plans remove-channel by deleting configured and disabled state", async () => { + const plan = await planner().planRemoveChannel({ + sandboxName: "demo", + agent: "openclaw", + isInteractive: false, + channelId: "telegram", + configuredChannels: ["telegram", "wechat", "slack"], + disabledChannels: ["telegram", "wechat"], + }); + + expect(plan.workflow).toBe("remove-channel"); + expect(plan.channels.map((channel) => channel.channelId)).toEqual(["wechat", "slack"]); + expect(plan.channels.find((channel) => channel.channelId === "telegram")).toBeUndefined(); + expect(plan.channels.find((channel) => channel.channelId === "wechat")).toMatchObject({ + configured: true, + disabled: true, + active: false, + }); + expect(plan.networkPolicy.entries.map((entry) => entry.channelId)).toEqual(["slack"]); + }); + + it("plans rebuild from configured and disabled registry snapshots", async () => { + const plan = await planner().planRebuild({ + sandboxName: "demo", + agent: "openclaw", + isInteractive: false, + configuredChannels: ["telegram", "discord", "wechat"], + disabledChannels: ["discord"], + }); + + expect(plan.workflow).toBe("rebuild"); + expect(plan.channels.map((channel) => channel.channelId)).toEqual([ + "telegram", + "discord", + "wechat", + ]); + expect(plan.channels.find((channel) => channel.channelId === "discord")).toMatchObject({ + configured: true, + disabled: true, + active: false, + selected: false, + }); + expect(plan.networkPolicy.entries.map((entry) => entry.channelId)).toEqual([ + "telegram", + "wechat", + ]); + }); + + it("reports unsupported channels deterministically before compiling", async () => { + await expect( + planner().planOnboard({ + sandboxName: "demo", + agent: "openclaw", + isInteractive: false, + selectedChannels: ["slack", "discord"], + supportedChannelIds: ["telegram"], + }), + ).rejects.toThrow("Unsupported messaging channel(s) for openclaw: discord, slack"); + }); + + it("returns serializable, secret-free plans suitable for dry-run and shadow output", async () => { + await withEnv( + { + TELEGRAM_BOT_TOKEN: "123456:raw-telegram-token", + }, + async () => { + const plan = await planner().planAddChannel({ + sandboxName: "demo", + agent: "openclaw", + isInteractive: false, + channelId: "telegram", + }); + const serialized = JSON.stringify(plan); + + expect(JSON.parse(serialized)).toEqual(plan); + expect(findFunctionPaths(plan)).toEqual([]); + expect(serialized).toContain("openshell:resolve:env:TELEGRAM_BOT_TOKEN"); + expect(serialized).not.toContain("123456:raw-telegram-token"); + }, + ); + }); +}); diff --git a/src/lib/messaging/compiler/workflow-planner.ts b/src/lib/messaging/compiler/workflow-planner.ts new file mode 100644 index 0000000000..d10b6ba6f2 --- /dev/null +++ b/src/lib/messaging/compiler/workflow-planner.ts @@ -0,0 +1,244 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { MessagingHookRegistry } from "../hooks"; +import type { + ChannelManifestRegistry, + MessagingAgentId, + MessagingChannelId, + MessagingCompilerWorkflow, + SandboxMessagingPlan, +} from "../manifest"; +import { ManifestCompiler } from "./manifest-compiler"; +import type { + ManifestCompilerContext, + MessagingCompilerCredentialAvailability, +} from "./types"; + +export interface MessagingWorkflowPlannerBaseContext { + readonly sandboxName: string; + readonly agent: MessagingAgentId; + readonly isInteractive: boolean; + readonly configuredChannels?: readonly MessagingChannelId[]; + readonly disabledChannels?: readonly MessagingChannelId[]; + readonly supportedChannelIds?: readonly MessagingChannelId[]; + readonly credentialAvailability?: MessagingCompilerCredentialAvailability; +} + +export interface MessagingWorkflowPlannerOnboardContext + extends MessagingWorkflowPlannerBaseContext { + readonly selectedChannels: readonly MessagingChannelId[]; +} + +export interface MessagingWorkflowPlannerChannelContext + extends MessagingWorkflowPlannerBaseContext { + readonly channelId: MessagingChannelId; +} + +export class MessagingWorkflowPlanner { + private readonly compiler: ManifestCompiler; + + constructor( + private readonly registry: ChannelManifestRegistry, + hooks = new MessagingHookRegistry(), + ) { + this.compiler = new ManifestCompiler(registry, hooks); + } + + async planOnboard( + context: MessagingWorkflowPlannerOnboardContext, + ): Promise { + const selectedChannels = uniqueChannels(context.selectedChannels); + this.assertSupportedChannels(selectedChannels, context); + + return this.compileWorkflow(context, { + workflow: "onboard", + selectedChannels, + configuredChannels: selectedChannels, + disabledChannels: [], + }); + } + + async planAddChannel( + context: MessagingWorkflowPlannerChannelContext, + ): Promise { + const configuredChannels = addChannels(context.configuredChannels, [context.channelId]); + const disabledChannels = removeChannels( + onlyConfiguredChannels(context.disabledChannels, configuredChannels), + [context.channelId], + ); + this.assertSupportedChannels([...configuredChannels, context.channelId], context); + + return this.compileWorkflow(context, { + workflow: "add-channel", + selectedChannels: [context.channelId], + configuredChannels, + disabledChannels, + }); + } + + async planRemoveChannel( + context: MessagingWorkflowPlannerChannelContext, + ): Promise { + const configuredChannels = removeChannels(context.configuredChannels, [context.channelId]); + const disabledChannels = removeChannels( + onlyConfiguredChannels(context.disabledChannels, configuredChannels), + [context.channelId], + ); + this.assertSupportedChannels([...configuredChannels, context.channelId], context); + + return this.compileWorkflow(context, { + workflow: "remove-channel", + selectedChannels: [], + configuredChannels, + disabledChannels, + }); + } + + async planStartChannel( + context: MessagingWorkflowPlannerChannelContext, + ): Promise { + const configuredChannels = uniqueChannels(context.configuredChannels); + const selectedChannels = configuredChannels.includes(context.channelId) + ? [context.channelId] + : []; + const disabledChannels = removeChannels( + onlyConfiguredChannels(context.disabledChannels, configuredChannels), + [context.channelId], + ); + this.assertSupportedChannels([...configuredChannels, context.channelId], context); + + return this.compileWorkflow(context, { + workflow: "start-channel", + selectedChannels, + configuredChannels, + disabledChannels, + }); + } + + async planStopChannel( + context: MessagingWorkflowPlannerChannelContext, + ): Promise { + const configuredChannels = uniqueChannels(context.configuredChannels); + const selectedChannels = configuredChannels.includes(context.channelId) + ? [context.channelId] + : []; + const disabledChannels = configuredChannels.includes(context.channelId) + ? addChannels(onlyConfiguredChannels(context.disabledChannels, configuredChannels), [ + context.channelId, + ]) + : onlyConfiguredChannels(context.disabledChannels, configuredChannels); + this.assertSupportedChannels([...configuredChannels, context.channelId], context); + + return this.compileWorkflow(context, { + workflow: "stop-channel", + selectedChannels, + configuredChannels, + disabledChannels, + }); + } + + async planRebuild( + context: MessagingWorkflowPlannerBaseContext, + ): Promise { + const configuredChannels = uniqueChannels(context.configuredChannels); + const disabledChannels = onlyConfiguredChannels(context.disabledChannels, configuredChannels); + this.assertSupportedChannels(configuredChannels, context); + + return this.compileWorkflow(context, { + workflow: "rebuild", + selectedChannels: [], + configuredChannels, + disabledChannels, + }); + } + + private compileWorkflow( + context: MessagingWorkflowPlannerBaseContext, + workflow: { + readonly workflow: MessagingCompilerWorkflow; + readonly selectedChannels: readonly MessagingChannelId[]; + readonly configuredChannels: readonly MessagingChannelId[]; + readonly disabledChannels: readonly MessagingChannelId[]; + }, + ): Promise { + const compilerContext: ManifestCompilerContext = { + sandboxName: context.sandboxName, + agent: context.agent, + isInteractive: context.isInteractive, + workflow: workflow.workflow, + selectedChannels: workflow.selectedChannels, + configuredChannels: workflow.configuredChannels, + disabledChannels: workflow.disabledChannels, + supportedChannelIds: context.supportedChannelIds, + credentialAvailability: context.credentialAvailability, + }; + return this.compiler.compile(compilerContext); + } + + private assertSupportedChannels( + channelIds: readonly MessagingChannelId[], + context: Pick< + MessagingWorkflowPlannerBaseContext, + "agent" | "supportedChannelIds" + >, + ): void { + const supportedIds = new Set(this.supportedChannelIds(context)); + const unsupportedIds = uniqueChannels(channelIds) + .filter((channelId) => !supportedIds.has(channelId)) + .sort(); + + if (unsupportedIds.length > 0) { + throw new Error( + `Unsupported messaging channel(s) for ${context.agent}: ${unsupportedIds.join(", ")}`, + ); + } + } + + private supportedChannelIds( + context: Pick< + MessagingWorkflowPlannerBaseContext, + "agent" | "supportedChannelIds" + >, + ): MessagingChannelId[] { + const supportedFilter = + context.supportedChannelIds && context.supportedChannelIds.length > 0 + ? new Set(context.supportedChannelIds) + : null; + + return this.registry + .list() + .filter((manifest) => manifest.supportedAgents.includes(context.agent)) + .filter((manifest) => !supportedFilter || supportedFilter.has(manifest.id)) + .map((manifest) => manifest.id); + } +} + +function uniqueChannels( + channelIds: readonly MessagingChannelId[] | undefined, +): MessagingChannelId[] { + return [...new Set(channelIds ?? [])]; +} + +function addChannels( + current: readonly MessagingChannelId[] | undefined, + additions: readonly MessagingChannelId[], +): MessagingChannelId[] { + return uniqueChannels([...(current ?? []), ...additions]); +} + +function removeChannels( + current: readonly MessagingChannelId[] | undefined, + removals: readonly MessagingChannelId[], +): MessagingChannelId[] { + const remove = new Set(removals); + return uniqueChannels(current).filter((channelId) => !remove.has(channelId)); +} + +function onlyConfiguredChannels( + channelIds: readonly MessagingChannelId[] | undefined, + configuredChannels: readonly MessagingChannelId[], +): MessagingChannelId[] { + const configured = new Set(configuredChannels); + return uniqueChannels(channelIds).filter((channelId) => configured.has(channelId)); +} diff --git a/src/lib/messaging/index.ts b/src/lib/messaging/index.ts index ab993fe4c3..0f2c562802 100644 --- a/src/lib/messaging/index.ts +++ b/src/lib/messaging/index.ts @@ -2,5 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 export * from "./channels"; +export * from "./compiler"; export * from "./hooks"; +export * from "./applier"; export * from "./manifest"; diff --git a/src/lib/messaging/manifest/types.test.ts b/src/lib/messaging/manifest/types.test.ts index f891474608..820bf742f6 100644 --- a/src/lib/messaging/manifest/types.test.ts +++ b/src/lib/messaging/manifest/types.test.ts @@ -57,7 +57,6 @@ const telegramManifest = { required: false, envKey: "TELEGRAM_REQUIRE_MENTION", validValues: ["0", "1"], - defaultValue: "1", statePath: "telegramConfig.requireMention", }, ], @@ -185,19 +184,29 @@ const wechatHookManifest = { const telegramPlan = { schemaVersion: 1, + sandboxName: "demo", + agent: "openclaw", + workflow: "onboard", channels: [ { channelId: "telegram", displayName: "Telegram", + authMode: "token-paste", active: true, + selected: true, + configured: false, + disabled: false, inputs: [ { + channelId: "telegram", inputId: "botToken", kind: "secret", required: true, sourceEnv: "TELEGRAM_BOT_TOKEN", + credentialAvailable: true, }, { + channelId: "telegram", inputId: "allowedIds", kind: "config", required: false, @@ -205,38 +214,56 @@ const telegramPlan = { statePath: "allowedIds.telegram", }, ], - credentialBindings: [ - { - credentialId: "telegramBotToken", - sourceInput: "botToken", - providerName: "demo-telegram-bridge", - providerEnvKey: "TELEGRAM_BOT_TOKEN", - placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", - }, - ], - policyPresets: ["telegram"], - render: [ - { - kind: "json-fragment", - agent: "openclaw", - target: "openclaw.json", - path: "channels.telegram.accounts.default", - value: { - botToken: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", - enabled: true, - }, - }, - { - kind: "env-lines", - agent: "hermes", - target: "~/.hermes/.env", - lines: ["TELEGRAM_BOT_TOKEN=openshell:resolve:env:TELEGRAM_BOT_TOKEN"], - }, - ], - buildInputs: [], hooks: [], }, ], + credentialBindings: [ + { + channelId: "telegram", + credentialId: "telegramBotToken", + sourceInput: "botToken", + providerName: "demo-telegram-bridge", + providerEnvKey: "TELEGRAM_BOT_TOKEN", + placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", + credentialAvailable: true, + }, + ], + networkPolicy: { + presets: ["telegram"], + entries: [ + { + channelId: "telegram", + presetName: "telegram", + policyKeys: ["telegram_bot"], + source: "builtin", + }, + ], + }, + agentRender: [ + { + channelId: "telegram", + renderId: "telegram-openclaw", + kind: "json-fragment", + agent: "openclaw", + target: "openclaw.json", + path: "channels.telegram.accounts.default", + value: { + botToken: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", + enabled: true, + }, + templateRefs: [], + }, + ], + buildSteps: [], + stateUpdates: [], + healthChecks: [ + { + channelId: "telegram", + phase: "health-check", + requiredBefore: "lifecycle-success", + hookIds: [], + }, + ], } as const satisfies SandboxMessagingPlan; function jsonRoundTrip(value: T): T { @@ -282,7 +309,7 @@ describe("messaging manifest type contracts", () => { expect(parsed).toEqual(telegramPlan); expect(serialized).toContain("openshell:resolve:env:TELEGRAM_BOT_TOKEN"); expect(serialized).not.toContain(rawSecret); - expect(parsed.channels[0]?.credentialBindings[0]).not.toHaveProperty("value"); + expect(parsed.credentialBindings[0]).not.toHaveProperty("value"); }); it("uses hook handler references instead of function-valued fields", () => { diff --git a/src/lib/messaging/manifest/types.ts b/src/lib/messaging/manifest/types.ts index f397e9859f..5d27160856 100644 --- a/src/lib/messaging/manifest/types.ts +++ b/src/lib/messaging/manifest/types.ts @@ -71,15 +71,13 @@ interface ChannelInputBaseSpec { /** Secret input metadata; values must be referenced, not stored in manifests or plans. */ export interface ChannelSecretInputSpec extends ChannelInputBaseSpec { readonly kind: "secret"; - readonly defaultValue?: never; readonly statePath?: never; } -/** Non-secret input metadata that may default and/or persist into channel state. */ +/** Non-secret input metadata that may persist into channel state. */ export interface ChannelConfigInputSpec extends ChannelInputBaseSpec { readonly kind: "config"; readonly statePath?: MessagingStatePath; - readonly defaultValue?: MessagingSerializableValue; } /** Manifest input declaration, split so secrets cannot declare defaults or state paths. */ @@ -137,9 +135,12 @@ export interface ChannelRebuildHydrationSpec { /** Lifecycle phase where a referenced hook may run. */ export type ChannelHookPhase = | "enroll" + | "reachability-check" | "apply" | "post-agent-install" - | "health-check"; + | "health-check" + | "diagnostic" + | "status"; /** How the planner/applier should treat a hook failure. */ export type ChannelHookFailureMode = "abort" | "skip-channel"; @@ -149,6 +150,7 @@ export interface ChannelHookSpec { readonly id: string; readonly phase: ChannelHookPhase; readonly handler: string; + readonly agents?: readonly MessagingAgentId[]; readonly inputs?: readonly string[]; readonly outputs?: readonly ChannelHookOutputSpec[]; readonly onFailure?: ChannelHookFailureMode; @@ -164,85 +166,168 @@ export interface ChannelHookOutputSpec { /** Serializable compiled plan for all selected messaging channels. */ export interface SandboxMessagingPlan { readonly schemaVersion: 1; + readonly sandboxName: string; + readonly agent: MessagingAgentId; + readonly workflow: MessagingCompilerWorkflow; readonly channels: readonly SandboxMessagingChannelPlan[]; + readonly credentialBindings: readonly SandboxMessagingCredentialBindingPlan[]; + readonly networkPolicy: SandboxMessagingNetworkPolicyPlan; + readonly agentRender: readonly SandboxMessagingAgentRenderPlan[]; + readonly buildSteps: readonly SandboxMessagingBuildStepPlan[]; + readonly stateUpdates: readonly SandboxMessagingStateUpdatePlan[]; + readonly healthChecks: readonly SandboxMessagingHealthCheckPlan[]; } -/** Compiled plan for one selected channel. */ +/** Workflow that requested a compiled messaging plan. */ +export type MessagingCompilerWorkflow = + | "onboard" + | "add-channel" + | "remove-channel" + | "start-channel" + | "stop-channel" + | "rebuild"; + +/** Compiled metadata for one requested channel. */ export interface SandboxMessagingChannelPlan { readonly channelId: MessagingChannelId; readonly displayName: string; + readonly authMode: ChannelAuthMode; readonly active: boolean; + readonly selected: boolean; + readonly configured: boolean; + readonly disabled: boolean; readonly inputs: readonly SandboxMessagingInputReference[]; - readonly credentialBindings: readonly SandboxMessagingCredentialBindingPlan[]; - readonly policyPresets: readonly string[]; - readonly render: readonly SandboxMessagingRenderFragmentPlan[]; - readonly buildInputs: readonly SandboxMessagingBuildInputPlan[]; readonly hooks: readonly SandboxMessagingHookReferencePlan[]; } /** Resolved input metadata carried into the plan without raw secret values. */ export interface SandboxMessagingInputReference { + readonly channelId: MessagingChannelId; readonly inputId: string; readonly kind: "secret" | "config"; readonly required: boolean; readonly sourceEnv?: string; readonly statePath?: MessagingStatePath; + readonly credentialAvailable?: boolean; + readonly value?: MessagingSerializableValue; } /** Plan entry describing an OpenShell provider/env binding to create or attach. */ export interface SandboxMessagingCredentialBindingPlan { + readonly channelId: MessagingChannelId; readonly credentialId: string; readonly sourceInput: string; readonly providerName: MessagingTemplateString; readonly providerEnvKey: string; readonly placeholder: MessagingTemplateString; + readonly credentialAvailable: boolean; +} + +/** Network policy presets and concrete policy keys required by active channels. */ +export interface SandboxMessagingNetworkPolicyPlan { + readonly presets: readonly string[]; + readonly entries: readonly SandboxMessagingNetworkPolicyEntryPlan[]; +} + +/** One active channel's requested policy preset and resolved policy keys. */ +export interface SandboxMessagingNetworkPolicyEntryPlan { + readonly channelId: MessagingChannelId; + readonly presetName: string; + readonly policyKeys: readonly string[]; + readonly source: "builtin" | "agent-alias" | "manifest"; } /** Compiled render output for supported target formats. */ -export type SandboxMessagingRenderFragmentPlan = - | SandboxMessagingJsonRenderFragmentPlan - | SandboxMessagingEnvLinesRenderFragmentPlan; +export type SandboxMessagingAgentRenderPlan = + | SandboxMessagingJsonRenderPlan + | SandboxMessagingEnvLinesRenderPlan; + +/** Compatibility alias for older phase-1 tests and callers. */ +export type SandboxMessagingRenderFragmentPlan = SandboxMessagingAgentRenderPlan; /** Shared metadata for compiled render outputs. */ -interface SandboxMessagingRenderFragmentBasePlan { +interface SandboxMessagingAgentRenderBasePlan { + readonly channelId: MessagingChannelId; + readonly renderId?: string; readonly agent: MessagingAgentId; readonly target: string; } /** Compiled JSON fragment ready for an applier/render engine. */ -export interface SandboxMessagingJsonRenderFragmentPlan - extends SandboxMessagingRenderFragmentBasePlan { +export interface SandboxMessagingJsonRenderPlan + extends SandboxMessagingAgentRenderBasePlan { readonly kind: "json-fragment"; readonly path: MessagingStatePath; readonly value: MessagingSerializableValue; + readonly templateRefs: readonly string[]; } /** Compiled env-file lines ready for an applier/render engine. */ -export interface SandboxMessagingEnvLinesRenderFragmentPlan - extends SandboxMessagingRenderFragmentBasePlan { +export interface SandboxMessagingEnvLinesRenderPlan + extends SandboxMessagingAgentRenderBasePlan { readonly kind: "env-lines"; readonly lines: readonly MessagingTemplateString[]; + readonly templateRefs: readonly string[]; } /** Build-time input the applier may pass into sandbox create/rebuild. */ -export type SandboxMessagingBuildInputPlan = - | SandboxMessagingBuildArgPlan - | SandboxMessagingBuildFilePlan; +export type SandboxMessagingBuildStepPlan = + | SandboxMessagingBuildArgStepPlan + | SandboxMessagingBuildFileStepPlan; + +/** Compatibility alias for older phase-1 tests and callers. */ +export type SandboxMessagingBuildInputPlan = SandboxMessagingBuildStepPlan; /** Docker/build argument planned for sandbox create or rebuild. */ -export interface SandboxMessagingBuildArgPlan { +export interface SandboxMessagingBuildArgStepPlan { + readonly channelId: MessagingChannelId; readonly kind: "build-arg"; - readonly name: string; - readonly valueTemplate: MessagingTemplateString; + readonly hookId: string; + readonly handler: string; + readonly outputId: string; + readonly required: boolean; } /** File planned for the sandbox build context, optionally sourced from a hook. */ -export interface SandboxMessagingBuildFilePlan { +export interface SandboxMessagingBuildFileStepPlan { + readonly channelId: MessagingChannelId; readonly kind: "build-file"; - readonly path: string; - readonly contentTemplate?: MessagingTemplateString; - readonly sourceHookOutput?: string; + readonly hookId: string; + readonly handler: string; + readonly outputId: string; + readonly required: boolean; } /** Hook reference carried into a compiled plan. */ -export type SandboxMessagingHookReferencePlan = ChannelHookSpec; +export interface SandboxMessagingHookReferencePlan extends ChannelHookSpec { + readonly channelId: MessagingChannelId; +} + +/** Planned state persistence or rebuild hydration produced from channel manifests. */ +export type SandboxMessagingStateUpdatePlan = + | SandboxMessagingPersistInputsStateUpdatePlan + | SandboxMessagingRebuildHydrationStateUpdatePlan; + +/** State input persistence planned for later workflow integration. */ +export interface SandboxMessagingPersistInputsStateUpdatePlan { + readonly channelId: MessagingChannelId; + readonly kind: "persist-inputs"; + readonly stateKey: string; + readonly inputIds: readonly string[]; +} + +/** Rebuild-time state hydration planned for later build integration. */ +export interface SandboxMessagingRebuildHydrationStateUpdatePlan { + readonly channelId: MessagingChannelId; + readonly kind: "rebuild-hydration"; + readonly statePath: MessagingStatePath; + readonly env: string; +} + +/** Health gates that must run before a lifecycle can report success. */ +export interface SandboxMessagingHealthCheckPlan { + readonly channelId: MessagingChannelId; + readonly phase: "health-check"; + readonly requiredBefore: "lifecycle-success"; + readonly hookIds: readonly string[]; +} From d8947ec36d4b817e9912f129cd3ab61814dd37cb Mon Sep 17 00:00:00 2001 From: San Dang Date: Mon, 25 May 2026 14:46:25 +0700 Subject: [PATCH 05/20] fix(messaging): address review feedback Signed-off-by: San Dang --- .../messaging/applier/setup-applier.test.ts | 37 +++++++++++++++++++ src/lib/messaging/applier/setup-applier.ts | 9 ++++- .../compiler/manifest-compiler.test.ts | 1 + .../messaging/compiler/manifest-compiler.ts | 8 ++-- 4 files changed, 51 insertions(+), 4 deletions(-) diff --git a/src/lib/messaging/applier/setup-applier.test.ts b/src/lib/messaging/applier/setup-applier.test.ts index 05d32f795f..282865c619 100644 --- a/src/lib/messaging/applier/setup-applier.test.ts +++ b/src/lib/messaging/applier/setup-applier.test.ts @@ -384,6 +384,43 @@ describe("MessagingSetupApplier", () => { expect(result.appliedHooks).toEqual(["wechat:wechat-seed-openclaw-account"]); }); + it("rejects prototype-polluting build-file merge keys", async () => { + const plan = await planOnboard({ WECHAT_ACCOUNT_ID: "wechat-account" }, ["wechat"]); + const files: Record = { + "/sandbox/.openclaw/openclaw.json": "{}", + }; + const runOpenshell: MessagingOpenShellRunner = (args, options) => { + const target = String(args.at(-1)); + if (args.includes("cat") && options?.input === undefined) { + return { status: files[target] === undefined ? 1 : 0, stdout: files[target] ?? "" }; + } + if (options?.input !== undefined) { + files[target] = options.input; + return { status: 0 }; + } + return { status: 1 }; + }; + const unsafeMerge = JSON.parse('{"__proto__":{"polluted":true}}'); + + await expect( + MessagingSetupApplier.applyAgentConfigAtOpenShell(plan, { + runOpenshell, + runHook: () => ({ + outputs: { + openclawConfigPatch: { + kind: "build-file", + value: { + path: "openclaw.json", + merge: unsafeMerge, + }, + }, + }, + }), + }), + ).rejects.toThrow("unsafe object key '__proto__'"); + expect(({} as { polluted?: boolean }).polluted).toBeUndefined(); + }); + it("applies policy presets directly from the serializable plan", async () => { const plan = await planOnboard({ TELEGRAM_BOT_TOKEN: "123456:telegram-token" }, [ "telegram", diff --git a/src/lib/messaging/applier/setup-applier.ts b/src/lib/messaging/applier/setup-applier.ts index 7a4ab6341b..0340fe83cb 100644 --- a/src/lib/messaging/applier/setup-applier.ts +++ b/src/lib/messaging/applier/setup-applier.ts @@ -362,7 +362,7 @@ function assertJsonSerializable( }); return; } - if (typeof value === "object" && value !== null) { + if (typeof value === "object") { assertAcyclicObject(value, path, visiting, () => { for (const [key, entry] of Object.entries(value)) { assertJsonSerializable(entry, `${path}.${key}`, visiting); @@ -574,6 +574,7 @@ function mergeObjects( patch: Record, ): void { for (const [key, value] of Object.entries(patch)) { + assertSafeObjectKey(key); const existing = target[key]; if (isObject(existing) && isObject(value)) { mergeObjects( @@ -586,6 +587,12 @@ function mergeObjects( } } +function assertSafeObjectKey(key: string): void { + if (key === "__proto__" || key === "prototype" || key === "constructor") { + throw new Error(`Messaging build-file merge rejected unsafe object key '${key}'.`); + } +} + function serializeHookBuildFileContent( content: MessagingSerializableValue | undefined, target: string, diff --git a/src/lib/messaging/compiler/manifest-compiler.test.ts b/src/lib/messaging/compiler/manifest-compiler.test.ts index 3f5482f03d..0254fe81a3 100644 --- a/src/lib/messaging/compiler/manifest-compiler.test.ts +++ b/src/lib/messaging/compiler/manifest-compiler.test.ts @@ -372,6 +372,7 @@ describe("ManifestCompiler", () => { expect(plan.buildSteps).toEqual([]); expect(plan.stateUpdates).toEqual([]); expect(plan.healthChecks).toEqual([]); + expect(plan.channels[0]?.hooks).toEqual([]); }); it("compiles a non-built-in channel manifest through the same generic path", async () => { diff --git a/src/lib/messaging/compiler/manifest-compiler.ts b/src/lib/messaging/compiler/manifest-compiler.ts index d4f2d8615c..278318e020 100644 --- a/src/lib/messaging/compiler/manifest-compiler.ts +++ b/src/lib/messaging/compiler/manifest-compiler.ts @@ -121,9 +121,11 @@ export class ManifestCompiler { selected && active && isEnrollmentWorkflow(context.workflow) && context.isInteractive, runEnrollmentChecks: selected && active && isEnrollmentWorkflow(context.workflow), }), - hooks: manifest.hooks - .filter((hook) => isHookForAgent(hook, context.agent)) - .map((hook) => cloneHookReference(manifest.id, hook)), + hooks: active + ? manifest.hooks + .filter((hook) => isHookForAgent(hook, context.agent)) + .map((hook) => cloneHookReference(manifest.id, hook)) + : [], }; } } From b030f4a55c82d83f1d4e3b50760e39fac2b964e2 Mon Sep 17 00:00:00 2001 From: San Dang Date: Mon, 25 May 2026 14:48:45 +0700 Subject: [PATCH 06/20] fix(messaging): inline merge key guard Signed-off-by: San Dang --- src/lib/messaging/applier/setup-applier.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/lib/messaging/applier/setup-applier.ts b/src/lib/messaging/applier/setup-applier.ts index 0340fe83cb..9b03906a6c 100644 --- a/src/lib/messaging/applier/setup-applier.ts +++ b/src/lib/messaging/applier/setup-applier.ts @@ -574,7 +574,9 @@ function mergeObjects( patch: Record, ): void { for (const [key, value] of Object.entries(patch)) { - assertSafeObjectKey(key); + if (key === "__proto__" || key === "prototype" || key === "constructor") { + throw new Error(`Messaging build-file merge rejected unsafe object key '${key}'.`); + } const existing = target[key]; if (isObject(existing) && isObject(value)) { mergeObjects( @@ -587,12 +589,6 @@ function mergeObjects( } } -function assertSafeObjectKey(key: string): void { - if (key === "__proto__" || key === "prototype" || key === "constructor") { - throw new Error(`Messaging build-file merge rejected unsafe object key '${key}'.`); - } -} - function serializeHookBuildFileContent( content: MessagingSerializableValue | undefined, target: string, From 710e7934c101d1a8991540ee0439ffbaf9942e00 Mon Sep 17 00:00:00 2001 From: San Dang Date: Mon, 25 May 2026 14:59:11 +0700 Subject: [PATCH 07/20] fix(messaging): validate merge subtrees Signed-off-by: San Dang --- .../messaging/applier/setup-applier.test.ts | 33 +++++++++++-------- src/lib/messaging/applier/setup-applier.ts | 17 ++++++++++ 2 files changed, 36 insertions(+), 14 deletions(-) diff --git a/src/lib/messaging/applier/setup-applier.test.ts b/src/lib/messaging/applier/setup-applier.test.ts index 282865c619..72096a9846 100644 --- a/src/lib/messaging/applier/setup-applier.test.ts +++ b/src/lib/messaging/applier/setup-applier.test.ts @@ -400,24 +400,29 @@ describe("MessagingSetupApplier", () => { } return { status: 1 }; }; - const unsafeMerge = JSON.parse('{"__proto__":{"polluted":true}}'); + const unsafeMerges = [ + JSON.parse('{"__proto__":{"polluted":true}}'), + JSON.parse('{"safe":{"__proto__":{"polluted":true}}}'), + ]; - await expect( - MessagingSetupApplier.applyAgentConfigAtOpenShell(plan, { - runOpenshell, - runHook: () => ({ - outputs: { - openclawConfigPatch: { - kind: "build-file", - value: { - path: "openclaw.json", - merge: unsafeMerge, + for (const unsafeMerge of unsafeMerges) { + await expect( + MessagingSetupApplier.applyAgentConfigAtOpenShell(plan, { + runOpenshell, + runHook: () => ({ + outputs: { + openclawConfigPatch: { + kind: "build-file", + value: { + path: "openclaw.json", + merge: unsafeMerge, + }, }, }, - }, + }), }), - }), - ).rejects.toThrow("unsafe object key '__proto__'"); + ).rejects.toThrow("unsafe object key '__proto__'"); + } expect(({} as { polluted?: boolean }).polluted).toBeUndefined(); }); diff --git a/src/lib/messaging/applier/setup-applier.ts b/src/lib/messaging/applier/setup-applier.ts index 9b03906a6c..d512ecfd63 100644 --- a/src/lib/messaging/applier/setup-applier.ts +++ b/src/lib/messaging/applier/setup-applier.ts @@ -585,10 +585,27 @@ function mergeObjects( ); continue; } + validateSafeMergeValue(value); target[key] = value; } } +function validateSafeMergeValue(value: MessagingSerializableValue): void { + if (Array.isArray(value)) { + for (const entry of value) { + validateSafeMergeValue(entry); + } + return; + } + if (!isObject(value)) return; + for (const [key, entry] of Object.entries(value)) { + if (key === "__proto__" || key === "prototype" || key === "constructor") { + throw new Error(`Messaging build-file merge rejected unsafe object key '${key}'.`); + } + validateSafeMergeValue(entry as MessagingSerializableValue); + } +} + function serializeHookBuildFileContent( content: MessagingSerializableValue | undefined, target: string, From 458b8855a6e971f17aa11ff1c2160f21029ea65e Mon Sep 17 00:00:00 2001 From: San Dang Date: Mon, 25 May 2026 18:53:05 +0700 Subject: [PATCH 08/20] fix(messaging): implement channel hooks and split applier --- src/lib/messaging/applier/agent-config.ts | 501 ++++++++++++++ src/lib/messaging/applier/index.ts | 3 + .../messaging/applier/openshell-provider.ts | 153 +++++ src/lib/messaging/applier/policy.ts | 23 + .../messaging/applier/setup-applier.test.ts | 87 ++- src/lib/messaging/applier/setup-applier.ts | 614 +----------------- .../channels/telegram/hooks/fakes.test.ts | 34 - .../channels/telegram/hooks/fakes.ts | 21 - .../hooks/get-me-reachability.test.ts | 89 +++ .../telegram/hooks/get-me-reachability.ts | 91 +++ .../channels/telegram/hooks/index.ts | 4 + .../channels/wechat/hooks/fakes.test.ts | 120 ---- .../messaging/channels/wechat/hooks/fakes.ts | 137 ---- .../channels/wechat/hooks/ilink-login.ts | 122 ++++ .../wechat/hooks/implementations.test.ts | 166 +++++ .../messaging/channels/wechat/hooks/index.ts | 29 + .../wechat/hooks/seed-openclaw-account.ts | 137 ++++ src/lib/messaging/channels/wechat/manifest.ts | 5 + .../compiler/manifest-compiler.test.ts | 63 +- .../compiler/workflow-planner.test.ts | 62 +- src/lib/messaging/hooks/builtins.ts | 33 + .../hooks/common/token-paste.test.ts | 120 +++- src/lib/messaging/hooks/common/token-paste.ts | 140 +++- src/lib/messaging/hooks/hook-runner.test.ts | 47 +- src/lib/messaging/hooks/index.ts | 1 + 25 files changed, 1833 insertions(+), 969 deletions(-) create mode 100644 src/lib/messaging/applier/agent-config.ts create mode 100644 src/lib/messaging/applier/openshell-provider.ts create mode 100644 src/lib/messaging/applier/policy.ts delete mode 100644 src/lib/messaging/channels/telegram/hooks/fakes.test.ts delete mode 100644 src/lib/messaging/channels/telegram/hooks/fakes.ts create mode 100644 src/lib/messaging/channels/telegram/hooks/get-me-reachability.test.ts create mode 100644 src/lib/messaging/channels/telegram/hooks/get-me-reachability.ts create mode 100644 src/lib/messaging/channels/telegram/hooks/index.ts delete mode 100644 src/lib/messaging/channels/wechat/hooks/fakes.test.ts delete mode 100644 src/lib/messaging/channels/wechat/hooks/fakes.ts create mode 100644 src/lib/messaging/channels/wechat/hooks/ilink-login.ts create mode 100644 src/lib/messaging/channels/wechat/hooks/implementations.test.ts create mode 100644 src/lib/messaging/channels/wechat/hooks/index.ts create mode 100644 src/lib/messaging/channels/wechat/hooks/seed-openclaw-account.ts create mode 100644 src/lib/messaging/hooks/builtins.ts diff --git a/src/lib/messaging/applier/agent-config.ts b/src/lib/messaging/applier/agent-config.ts new file mode 100644 index 0000000000..dc74e22172 --- /dev/null +++ b/src/lib/messaging/applier/agent-config.ts @@ -0,0 +1,501 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import YAML from "yaml"; + +import { redact } from "../../security/redact"; +import type { + ChannelHookPhase, + MessagingAgentId, + MessagingSerializableValue, + SandboxMessagingAgentRenderPlan, + SandboxMessagingChannelPlan, + SandboxMessagingEnvLinesRenderPlan, + SandboxMessagingJsonRenderPlan, + SandboxMessagingPlan, +} from "../manifest"; +import type { MessagingHookOutputMap } from "../hooks"; +import type { + MessagingHookApplyRequest, + MessagingHookApplyRunner, + MessagingOpenShellRunner, +} from "./types"; + +const AGENT_CONFIG_HOOK_PHASES = new Set([ + "apply", + "post-agent-install", +]); + +export function listHookRequests( + plan: SandboxMessagingPlan, + phase?: ChannelHookPhase, +): MessagingHookApplyRequest[] { + return plan.channels.flatMap((channel) => + channel.hooks + .filter((hook) => !phase || hook.phase === phase) + .map((hook) => toHookApplyRequest(plan, channel, hook)), + ); +} + +export async function applyAgentConfigAtOpenShell( + plan: SandboxMessagingPlan, + options: { + readonly runOpenshell: MessagingOpenShellRunner; + readonly runHook?: MessagingHookApplyRunner; + }, +): Promise<{ + readonly appliedTargets: readonly string[]; + readonly appliedHooks: readonly string[]; + readonly unresolvedTemplateRefs: readonly string[]; +}> { + const hookRequests = hookRequestsForPhases(plan, AGENT_CONFIG_HOOK_PHASES); + if (hookRequests.length > 0 && !options.runHook) { + throw new Error("Messaging agent config hooks require a hook runner."); + } + + const appliedHooks: string[] = []; + const appliedTargets: string[] = []; + for (const request of hookRequests.filter((hook) => hook.phase === "apply")) { + await runApplyHook(request, options.runHook, plan, options.runOpenshell, { + appliedHooks, + appliedTargets, + }); + } + + for (const [target, render] of groupRenderByTarget(plan.agentRender)) { + const resolvedTarget = resolveSandboxAgentConfigTarget(target, plan.agent); + const kind = render[0]?.kind; + if (!kind) continue; + if (render.some((entry) => entry.kind !== kind)) { + throw new Error(`Cannot apply mixed messaging render kinds to ${target}.`); + } + const existing = readSandboxFile(plan.sandboxName, resolvedTarget, options.runOpenshell); + const contents = + kind === "json-fragment" + ? applyJsonFragments( + existing, + render.filter(isJsonRender), + resolvedTarget, + ) + : applyEnvLines(existing, render.filter(isEnvLinesRender)); + writeSandboxFile(plan.sandboxName, resolvedTarget, contents, options.runOpenshell); + appliedTargets.push(resolvedTarget); + } + + for (const request of hookRequests.filter((hook) => hook.phase === "post-agent-install")) { + await runApplyHook(request, options.runHook, plan, options.runOpenshell, { + appliedHooks, + appliedTargets, + }); + } + + return { + appliedTargets: uniqueStrings(appliedTargets), + appliedHooks, + unresolvedTemplateRefs: uniqueStrings( + plan.agentRender.flatMap((render) => render.templateRefs), + ), + }; +} + +function hookRequestsForPhases( + plan: SandboxMessagingPlan, + phases: ReadonlySet, +): MessagingHookApplyRequest[] { + return plan.channels.flatMap((channel) => + channel.hooks + .filter((hook) => phases.has(hook.phase)) + .map((hook) => toHookApplyRequest(plan, channel, hook)), + ); +} + +function toHookApplyRequest( + plan: SandboxMessagingPlan, + channel: SandboxMessagingChannelPlan, + hook: SandboxMessagingChannelPlan["hooks"][number], +): MessagingHookApplyRequest { + const inputs = buildHookInputMap(plan, channel); + const selectedInputs = hook.inputs + ? Object.fromEntries( + hook.inputs + .filter((inputKey) => Object.hasOwn(inputs, inputKey)) + .map((inputKey) => [inputKey, inputs[inputKey] as MessagingSerializableValue]), + ) + : inputs; + + return { + sandboxName: plan.sandboxName, + agent: plan.agent, + channelId: channel.channelId, + hookId: hook.id, + phase: hook.phase, + handler: hook.handler, + inputKeys: hook.inputs, + inputs: selectedInputs, + outputs: hook.outputs, + onFailure: hook.onFailure, + }; +} + +function buildHookInputMap( + plan: SandboxMessagingPlan, + channel: SandboxMessagingChannelPlan, +): 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 plan.credentialBindings) { + if (credential.channelId !== channel.channelId) continue; + inputs[`credential.${credential.credentialId}.placeholder`] = credential.placeholder; + } + return inputs; +} + +async function runApplyHook( + request: MessagingHookApplyRequest, + runner: MessagingHookApplyRunner | undefined, + plan: SandboxMessagingPlan, + runOpenshell: MessagingOpenShellRunner, + applied: { + readonly appliedHooks: string[]; + readonly appliedTargets: string[]; + }, +): Promise { + if (!runner) return; + try { + const result = await runner(request); + applied.appliedHooks.push(`${request.channelId}:${request.hookId}`); + if (result?.outputs) { + applied.appliedTargets.push( + ...applyHookBuildFileOutputs(plan, result.outputs, runOpenshell), + ); + } + } catch (error) { + if (request.onFailure === "skip-channel") return; + throw error; + } +} + +function groupRenderByTarget( + render: readonly SandboxMessagingAgentRenderPlan[], +): ReadonlyMap { + const groups = new Map(); + for (const entry of render) { + const group = groups.get(entry.target) ?? []; + group.push(entry); + groups.set(entry.target, group); + } + return groups; +} + +function isJsonRender( + render: SandboxMessagingAgentRenderPlan, +): render is SandboxMessagingJsonRenderPlan { + return render.kind === "json-fragment"; +} + +function isEnvLinesRender( + render: SandboxMessagingAgentRenderPlan, +): render is SandboxMessagingEnvLinesRenderPlan { + return render.kind === "env-lines"; +} + +function applyJsonFragments( + existing: string | undefined, + render: readonly SandboxMessagingJsonRenderPlan[], + target: string, +): string { + const format = target.endsWith(".yaml") || target.endsWith(".yml") ? "yaml" : "json"; + const root = parseStructuredConfig(existing, target, format); + for (const entry of render) { + setJsonPath(root, entry.path, entry.value); + } + return format === "yaml" ? YAML.stringify(root) : `${JSON.stringify(root, null, 2)}\n`; +} + +function parseStructuredConfig( + existing: string | undefined, + target: string, + format: "json" | "yaml", +): Record { + if (!existing || existing.trim().length === 0) return {}; + const parsed = format === "yaml" ? YAML.parse(existing) : (JSON.parse(existing) as unknown); + if (!isObject(parsed)) { + throw new Error(`Messaging agent config target ${target} must contain an object.`); + } + return parsed as Record; +} + +function setJsonPath( + root: Record, + path: string, + value: MessagingSerializableValue, +): void { + const segments = path.split(".").filter(Boolean); + if (segments.length === 0) { + throw new Error("Messaging render path must not be empty."); + } + let cursor: Record = root; + for (const segment of segments.slice(0, -1)) { + const next = cursor[segment]; + if (!isObject(next)) { + const created: Record = {}; + cursor[segment] = created; + cursor = created; + } else { + cursor = next as Record; + } + } + cursor[segments[segments.length - 1] as string] = value; +} + +function applyEnvLines( + existing: string | undefined, + render: readonly SandboxMessagingEnvLinesRenderPlan[], +): string { + const desired = new Map(); + const rawDesiredLines: string[] = []; + for (const entry of render) { + for (const line of entry.lines) { + const key = readEnvLineKey(line); + if (key) { + desired.set(key, line); + } else { + rawDesiredLines.push(line); + } + } + } + + const written = new Set(); + const output = (existing ?? "") + .split(/\n/) + .filter((line, index, lines) => line.length > 0 || index < lines.length - 1) + .map((line) => { + const key = readEnvLineKey(line); + if (!key || !desired.has(key)) return line; + written.add(key); + return desired.get(key) as string; + }); + + for (const [key, line] of desired) { + if (!written.has(key)) output.push(line); + } + output.push(...rawDesiredLines); + return output.length > 0 ? `${output.join("\n")}\n` : ""; +} + +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 applyHookBuildFileOutputs( + plan: SandboxMessagingPlan, + outputs: MessagingHookOutputMap, + runOpenshell: MessagingOpenShellRunner, +): string[] { + const appliedTargets: string[] = []; + for (const output of Object.values(outputs)) { + if (output.kind !== "build-file") continue; + const file = readHookBuildFile(output.value); + const target = resolveHookBuildFileTarget(file.path, plan.agent); + const contents = + file.merge !== undefined + ? applyStructuredMerge( + readSandboxFile(plan.sandboxName, target, runOpenshell), + file.merge, + target, + ) + : serializeHookBuildFileContent(file.content, target); + writeSandboxFile(plan.sandboxName, target, contents, runOpenshell, file.mode); + appliedTargets.push(target); + } + return appliedTargets; +} + +function readHookBuildFile(value: MessagingSerializableValue): { + readonly path: string; + readonly mode?: string; + readonly content?: MessagingSerializableValue; + readonly merge?: MessagingSerializableValue; +} { + if (!isObject(value) || typeof value.path !== "string" || value.path.trim().length === 0) { + throw new Error("Messaging build-file hook output must include a non-empty path."); + } + const file = value as Record; + const path = value.path; + const mode = value.mode; + if (file.content === undefined && file.merge === undefined) { + throw new Error(`Messaging build-file '${path}' must include content or merge.`); + } + if (mode !== undefined && typeof mode !== "string") { + throw new Error(`Messaging build-file '${path}' mode must be a string.`); + } + return { + path, + mode, + content: file.content, + merge: file.merge, + }; +} + +function applyStructuredMerge( + existing: string | undefined, + patch: MessagingSerializableValue, + target: string, +): string { + if (!isObject(patch)) { + throw new Error(`Messaging build-file merge for ${target} must be an object.`); + } + const format = target.endsWith(".yaml") || target.endsWith(".yml") ? "yaml" : "json"; + const root = parseStructuredConfig(existing, target, format); + mergeObjects(root, patch); + return format === "yaml" ? YAML.stringify(root) : `${JSON.stringify(root, null, 2)}\n`; +} + +function mergeObjects( + target: Record, + patch: Record, +): void { + for (const [key, value] of Object.entries(patch)) { + if (key === "__proto__" || key === "prototype" || key === "constructor") { + throw new Error(`Messaging build-file merge rejected unsafe object key '${key}'.`); + } + const existing = target[key]; + if (isObject(existing) && isObject(value)) { + mergeObjects( + existing as Record, + value as Record, + ); + continue; + } + validateSafeMergeValue(value); + target[key] = value; + } +} + +function validateSafeMergeValue(value: MessagingSerializableValue): void { + if (Array.isArray(value)) { + for (const entry of value) { + validateSafeMergeValue(entry); + } + return; + } + if (!isObject(value)) return; + for (const [key, entry] of Object.entries(value)) { + if (key === "__proto__" || key === "prototype" || key === "constructor") { + throw new Error(`Messaging build-file merge rejected unsafe object key '${key}'.`); + } + validateSafeMergeValue(entry as MessagingSerializableValue); + } +} + +function serializeHookBuildFileContent( + content: MessagingSerializableValue | undefined, + target: string, +): string { + if (content === undefined) return ""; + if (typeof content === "string") return content.endsWith("\n") ? content : `${content}\n`; + if (target.endsWith(".yaml") || target.endsWith(".yml")) return YAML.stringify(content); + return `${JSON.stringify(content, null, 2)}\n`; +} + +function resolveHookBuildFileTarget(path: string, agent: MessagingAgentId): string { + if (path.startsWith("/")) return path; + if (path === "openclaw.json") return resolveSandboxAgentConfigTarget(path, "openclaw"); + if (path === "config.yaml" && agent === "hermes") { + return resolveSandboxAgentConfigTarget("~/.hermes/config.yaml", agent); + } + if (path === ".env" && agent === "hermes") { + return resolveSandboxAgentConfigTarget("~/.hermes/.env", agent); + } + if (agent === "openclaw") return `/sandbox/.openclaw/${path}`; + if (agent === "hermes") return `/sandbox/.hermes/${path}`; + throw new Error(`Cannot resolve messaging build-file target '${path}' for ${agent}.`); +} + +function resolveSandboxAgentConfigTarget(target: string, agent: MessagingAgentId): string { + if (target.startsWith("/")) return target; + if (agent === "openclaw" && target === "openclaw.json") { + return "/sandbox/.openclaw/openclaw.json"; + } + if (target.startsWith("~/.openclaw/")) { + return `/sandbox/.openclaw/${target.slice("~/.openclaw/".length)}`; + } + if (target.startsWith("~/.hermes/")) { + return `/sandbox/.hermes/${target.slice("~/.hermes/".length)}`; + } + throw new Error(`Cannot resolve messaging agent config target '${target}' for ${agent}.`); +} + +function readSandboxFile( + sandboxName: string, + target: string, + runOpenshell: MessagingOpenShellRunner, +): string | undefined { + const result = runOpenshell( + ["sandbox", "exec", "--name", sandboxName, "--", "cat", target], + { + ignoreError: true, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + const status = result.status ?? 0; + return status === 0 ? String(result.stdout ?? "") : undefined; +} + +function writeSandboxFile( + sandboxName: string, + target: string, + contents: string, + runOpenshell: MessagingOpenShellRunner, + mode?: string, +): void { + const result = runOpenshell( + [ + "sandbox", + "exec", + "--name", + sandboxName, + "--", + "sh", + "-c", + mode + ? 'mkdir -p "$(dirname "$1")" && cat > "$1" && chmod "$2" "$1"' + : 'mkdir -p "$(dirname "$1")" && cat > "$1"', + "sh", + target, + ...(mode ? [mode] : []), + ], + { + input: contents, + stdio: ["pipe", "pipe", "pipe"], + }, + ); + const status = result.status ?? 0; + if (status !== 0) { + throw new Error( + `Failed to apply messaging agent config '${target}': ${compactOutput(result)}`, + ); + } +} + +function compactOutput(result: { readonly stdout?: unknown; readonly stderr?: unknown }): string { + const output = redact(`${String(result.stderr ?? "")}${String(result.stdout ?? "")}`) + .replace(/\r/g, "") + .trim(); + return output || "OpenShell command failed."; +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function uniqueStrings(values: readonly string[]): string[] { + return [...new Set(values.filter(Boolean))]; +} diff --git a/src/lib/messaging/applier/index.ts b/src/lib/messaging/applier/index.ts index 19a1e91b68..31b80dc3c2 100644 --- a/src/lib/messaging/applier/index.ts +++ b/src/lib/messaging/applier/index.ts @@ -2,4 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 export * from "./setup-applier"; +export * from "./agent-config"; +export * from "./openshell-provider"; +export * from "./policy"; export type * from "./types"; diff --git a/src/lib/messaging/applier/openshell-provider.ts b/src/lib/messaging/applier/openshell-provider.ts new file mode 100644 index 0000000000..a4d42df8a4 --- /dev/null +++ b/src/lib/messaging/applier/openshell-provider.ts @@ -0,0 +1,153 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { redact } from "../../security/redact"; +import type { + SandboxMessagingCredentialBindingPlan, + SandboxMessagingPlan, +} from "../manifest"; +import type { + MessagingCredentialApplyOptions, + MessagingCredentialApplyResult, + MessagingOpenShellRunner, +} from "./types"; + +type MessagingCredentialApplyEntry = MessagingCredentialApplyResult["upserted"][number]; +type MessagingCredentialReuseEntry = MessagingCredentialApplyResult["reused"][number]; +type MessagingMissingCredentialEntry = MessagingCredentialApplyResult["missing"][number]; +type MessagingCredentialBindingLike = Pick< + SandboxMessagingCredentialBindingPlan, + "channelId" | "credentialId" | "providerName" | "providerEnvKey" +>; + +export function applyCredentialsAtOpenShell( + plan: SandboxMessagingPlan, + options: MessagingCredentialApplyOptions, +): MessagingCredentialApplyResult { + const env = options.env ?? process.env; + const runOpenshell = options.runOpenshell; + const upserted: MessagingCredentialApplyEntry[] = []; + const reused: MessagingCredentialReuseEntry[] = []; + const missing: MessagingMissingCredentialEntry[] = []; + + for (const binding of plan.credentialBindings) { + const credential = readCredentialEnv(env, binding.providerEnvKey); + if (!credential) { + if (providerExistsInGateway(binding.providerName, runOpenshell)) { + reused.push(toReuseEntry(binding)); + } else { + missing.push(toMissingEntry(binding)); + } + continue; + } + + const action = providerExistsInGateway(binding.providerName, runOpenshell) + ? "update" + : "create"; + const result = runOpenshell( + buildProviderArgs(action, binding.providerName, binding.providerEnvKey), + { + ignoreError: true, + env: { [binding.providerEnvKey]: credential }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + const status = result.status ?? 0; + if (status !== 0) { + throw new Error( + `Failed to ${action} messaging provider '${binding.providerName}': ${compactOutput(result)}`, + ); + } + upserted.push({ + channelId: binding.channelId, + credentialId: binding.credentialId, + providerName: binding.providerName, + envKey: binding.providerEnvKey, + action, + }); + } + + const providerNames = uniqueStrings([ + ...upserted.map((entry) => entry.providerName), + ...reused.map((entry) => entry.providerName), + ]); + + return { + upserted, + reused, + missing, + providerNames, + sandboxCreateProviderArgs: providerNames.flatMap((providerName) => [ + "--provider", + providerName, + ]), + }; +} + +function readCredentialEnv(env: NodeJS.ProcessEnv, envKey: string): string | null { + const raw = env[envKey]; + if (typeof raw !== "string") return null; + const normalized = raw.replace(/\r/g, "").trim(); + return normalized || null; +} + +function providerExistsInGateway( + providerName: string, + runOpenshell: MessagingOpenShellRunner, +): boolean { + const result = runOpenshell(["provider", "get", providerName], { + ignoreError: true, + stdio: ["ignore", "ignore", "ignore"], + }); + return (result.status ?? 0) === 0; +} + +function buildProviderArgs( + action: "create" | "update", + providerName: string, + credentialEnv: string, +): string[] { + return action === "create" + ? [ + "provider", + "create", + "--name", + providerName, + "--type", + "generic", + "--credential", + credentialEnv, + ] + : ["provider", "update", providerName, "--credential", credentialEnv]; +} + +function toReuseEntry(binding: MessagingCredentialBindingLike): MessagingCredentialReuseEntry { + return { + channelId: binding.channelId, + credentialId: binding.credentialId, + providerName: binding.providerName, + envKey: binding.providerEnvKey, + }; +} + +function toMissingEntry( + binding: MessagingCredentialBindingLike, +): MessagingMissingCredentialEntry { + return { + channelId: binding.channelId, + credentialId: binding.credentialId, + providerName: binding.providerName, + envKey: binding.providerEnvKey, + }; +} + +function compactOutput(result: { readonly stdout?: unknown; readonly stderr?: unknown }): string { + const output = redact(`${String(result.stderr ?? "")}${String(result.stdout ?? "")}`) + .replace(/\r/g, "") + .trim(); + return output || "OpenShell command failed."; +} + +function uniqueStrings(values: readonly string[]): string[] { + return [...new Set(values.filter(Boolean))]; +} diff --git a/src/lib/messaging/applier/policy.ts b/src/lib/messaging/applier/policy.ts new file mode 100644 index 0000000000..31460845d5 --- /dev/null +++ b/src/lib/messaging/applier/policy.ts @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { SandboxMessagingPlan } from "../manifest"; +import type { MessagingPolicyApplyOptions, MessagingPolicyApplyResult } from "./types"; + +export function applyPolicyAtOpenShell( + plan: SandboxMessagingPlan, + options: MessagingPolicyApplyOptions, +): MessagingPolicyApplyResult { + const activePresets = uniqueStrings(plan.networkPolicy.presets); + if (activePresets.length > 0 && !options.applyPresets(plan.sandboxName, activePresets)) { + throw new Error(`Failed to apply messaging policy preset(s): ${activePresets.join(", ")}`); + } + + return { + appliedPresets: activePresets, + }; +} + +function uniqueStrings(values: readonly string[]): string[] { + return [...new Set(values.filter(Boolean))]; +} diff --git a/src/lib/messaging/applier/setup-applier.test.ts b/src/lib/messaging/applier/setup-applier.test.ts index 72096a9846..d6f55ef385 100644 --- a/src/lib/messaging/applier/setup-applier.test.ts +++ b/src/lib/messaging/applier/setup-applier.test.ts @@ -4,16 +4,21 @@ import { describe, expect, it } from "vitest"; import { createBuiltInChannelManifestRegistry } from "../channels"; -import { FAKE_TELEGRAM_HOOK_REGISTRATIONS } from "../channels/telegram/hooks/fakes"; -import { FAKE_WECHAT_HOOK_REGISTRATIONS } from "../channels/wechat/hooks/fakes"; import { MessagingWorkflowPlanner } from "../compiler"; -import { MessagingHookRegistry, runMessagingHook } from "../hooks"; -import { FAKE_COMMON_HOOK_REGISTRATIONS } from "../hooks/common"; +import { createBuiltInMessagingHookRegistry, runMessagingHook } from "../hooks"; import type { ChannelHookSpec } from "../manifest"; import type { SandboxMessagingPlan } from "../manifest"; import { MessagingSetupApplier } from "./setup-applier"; import { MESSAGING_SETUP_APPLIER_ENV_KEY, type MessagingOpenShellRunner } from "./types"; +const TEST_CREDENTIALS: Readonly> = { + TELEGRAM_BOT_TOKEN: "123456:test-telegram-token", + DISCORD_BOT_TOKEN: "test-discord-token", + WECHAT_BOT_TOKEN: "test-wechat-token", + SLACK_BOT_TOKEN: "xoxb-test-slack-token", + SLACK_APP_TOKEN: "xapp-test-slack-token", +}; + async function withEnv( values: Readonly>, run: () => Promise, @@ -44,11 +49,39 @@ async function withEnv( function planner(): MessagingWorkflowPlanner { return new MessagingWorkflowPlanner( createBuiltInChannelManifestRegistry(), - new MessagingHookRegistry([ - ...FAKE_COMMON_HOOK_REGISTRATIONS, - ...FAKE_TELEGRAM_HOOK_REGISTRATIONS, - ...FAKE_WECHAT_HOOK_REGISTRATIONS, - ]), + createBuiltInMessagingHookRegistry({ + common: { + env: {}, + getCredential: (key) => TEST_CREDENTIALS[key] ?? null, + saveCredential: () => {}, + prompt: async () => "unused", + log: () => {}, + }, + telegram: { + fetch: async () => ({ + ok: true, + status: 200, + async json() { + return { ok: true }; + }, + async text() { + return ""; + }, + }), + }, + wechat: { + ilinkLogin: { + env: {}, + saveCredential: () => {}, + runLogin: async () => ({ + kind: "timeout", + }), + }, + seedOpenClawAccount: { + now: () => "2026-01-01T00:00:00.000Z", + }, + }, + }), ); } @@ -307,7 +340,39 @@ describe("MessagingSetupApplier", () => { }, ["wechat"], ); - const registry = new MessagingHookRegistry(FAKE_WECHAT_HOOK_REGISTRATIONS); + const registry = createBuiltInMessagingHookRegistry({ + common: { + env: {}, + getCredential: (key) => TEST_CREDENTIALS[key] ?? null, + saveCredential: () => {}, + prompt: async () => "unused", + log: () => {}, + }, + telegram: { + fetch: async () => ({ + ok: true, + status: 200, + async json() { + return { ok: true }; + }, + async text() { + return ""; + }, + }), + }, + wechat: { + ilinkLogin: { + env: {}, + saveCredential: () => {}, + runLogin: async () => ({ + kind: "timeout", + }), + }, + seedOpenClawAccount: { + now: () => "2026-01-01T00:00:00.000Z", + }, + }, + }); const files: Record = { "/sandbox/.openclaw/openclaw.json": JSON.stringify({ plugins: { @@ -368,7 +433,7 @@ describe("MessagingSetupApplier", () => { expect(openclawConfig.plugins.entries.acpx.enabled).toBe(false); expect(openclawConfig.plugins.entries["openclaw-weixin"].enabled).toBe(true); expect(openclawConfig.plugins.installs["openclaw-weixin"].spec).toBe( - "@tencent-weixin/openclaw-weixin@2.4.2", + "@tencent-weixin/openclaw-weixin@2.4.3", ); expect(openclawConfig.plugins.load.paths).toEqual([ "/sandbox/.openclaw/extensions/openclaw-weixin", diff --git a/src/lib/messaging/applier/setup-applier.ts b/src/lib/messaging/applier/setup-applier.ts index d512ecfd63..658b17e238 100644 --- a/src/lib/messaging/applier/setup-applier.ts +++ b/src/lib/messaging/applier/setup-applier.ts @@ -2,21 +2,14 @@ // SPDX-License-Identifier: Apache-2.0 import { Buffer } from "node:buffer"; -import YAML from "yaml"; -import { redact } from "../../security/redact"; -import type { - ChannelHookPhase, - MessagingAgentId, - MessagingSerializableValue, - SandboxMessagingAgentRenderPlan, - SandboxMessagingChannelPlan, - SandboxMessagingCredentialBindingPlan, - SandboxMessagingEnvLinesRenderPlan, - SandboxMessagingJsonRenderPlan, - SandboxMessagingPlan, -} from "../manifest"; -import type { MessagingHookOutputMap } from "../hooks"; +import type { ChannelHookPhase, SandboxMessagingPlan } from "../manifest"; +import { + applyAgentConfigAtOpenShell as applyAgentConfigPlanAtOpenShell, + listHookRequests as listPlanHookRequests, +} from "./agent-config"; +import { applyCredentialsAtOpenShell as applyCredentialsPlanAtOpenShell } from "./openshell-provider"; +import { applyPolicyAtOpenShell as applyPolicyPlanAtOpenShell } from "./policy"; import { MESSAGING_SETUP_APPLIER_ENV_KEY, type MessagingCredentialApplyOptions, @@ -29,19 +22,6 @@ import { type MessagingSetupEnvOptions, } from "./types"; -type MessagingCredentialApplyEntry = MessagingCredentialApplyResult["upserted"][number]; -type MessagingCredentialReuseEntry = MessagingCredentialApplyResult["reused"][number]; -type MessagingMissingCredentialEntry = MessagingCredentialApplyResult["missing"][number]; -type MessagingCredentialBindingLike = Pick< - SandboxMessagingCredentialBindingPlan, - "channelId" | "credentialId" | "providerName" | "providerEnvKey" ->; - -const AGENT_CONFIG_HOOK_PHASES = new Set([ - "apply", - "post-agent-install", -]); - export class MessagingSetupApplier { static encodePlan(plan: SandboxMessagingPlan): string { assertSandboxMessagingPlan(plan); @@ -88,11 +68,7 @@ export class MessagingSetupApplier { phase?: ChannelHookPhase, ): MessagingHookApplyRequest[] { assertSandboxMessagingPlan(plan); - return plan.channels.flatMap((channel) => - channel.hooks - .filter((hook) => !phase || hook.phase === phase) - .map((hook) => toHookApplyRequest(plan, channel, hook)), - ); + return listPlanHookRequests(plan, phase); } static async applyAgentConfigAtOpenShell( @@ -107,54 +83,7 @@ export class MessagingSetupApplier { readonly unresolvedTemplateRefs: readonly string[]; }> { assertSandboxMessagingPlan(plan); - const hookRequests = hookRequestsForPhases(plan, AGENT_CONFIG_HOOK_PHASES); - if (hookRequests.length > 0 && !options.runHook) { - throw new Error("Messaging agent config hooks require a hook runner."); - } - - const appliedHooks: string[] = []; - const appliedTargets: string[] = []; - for (const request of hookRequests.filter((hook) => hook.phase === "apply")) { - await runApplyHook(request, options.runHook, plan, options.runOpenshell, { - appliedHooks, - appliedTargets, - }); - } - - for (const [target, render] of groupRenderByTarget(plan.agentRender)) { - const resolvedTarget = resolveSandboxAgentConfigTarget(target, plan.agent); - const kind = render[0]?.kind; - if (!kind) continue; - if (render.some((entry) => entry.kind !== kind)) { - throw new Error(`Cannot apply mixed messaging render kinds to ${target}.`); - } - const existing = readSandboxFile(plan.sandboxName, resolvedTarget, options.runOpenshell); - const contents = - kind === "json-fragment" - ? applyJsonFragments( - existing, - render.filter(isJsonRender), - resolvedTarget, - ) - : applyEnvLines(existing, render.filter(isEnvLinesRender)); - writeSandboxFile(plan.sandboxName, resolvedTarget, contents, options.runOpenshell); - appliedTargets.push(resolvedTarget); - } - - for (const request of hookRequests.filter((hook) => hook.phase === "post-agent-install")) { - await runApplyHook(request, options.runHook, plan, options.runOpenshell, { - appliedHooks, - appliedTargets, - }); - } - - return { - appliedTargets: uniqueStrings(appliedTargets), - appliedHooks, - unresolvedTemplateRefs: uniqueStrings( - plan.agentRender.flatMap((render) => render.templateRefs), - ), - }; + return applyAgentConfigPlanAtOpenShell(plan, options); } static applyCredentialsAtOpenShell( @@ -162,64 +91,7 @@ export class MessagingSetupApplier { options: MessagingCredentialApplyOptions, ): MessagingCredentialApplyResult { assertSandboxMessagingPlan(plan); - const env = options.env ?? process.env; - const runOpenshell = options.runOpenshell; - const upserted: MessagingCredentialApplyEntry[] = []; - const reused: MessagingCredentialReuseEntry[] = []; - const missing: MessagingMissingCredentialEntry[] = []; - - for (const binding of plan.credentialBindings) { - const credential = readCredentialEnv(env, binding.providerEnvKey); - if (!credential) { - if (providerExistsInGateway(binding.providerName, runOpenshell)) { - reused.push(toReuseEntry(binding)); - } else { - missing.push(toMissingEntry(binding)); - } - continue; - } - - const action = providerExistsInGateway(binding.providerName, runOpenshell) - ? "update" - : "create"; - const result = runOpenshell( - buildProviderArgs(action, binding.providerName, binding.providerEnvKey), - { - ignoreError: true, - env: { [binding.providerEnvKey]: credential }, - stdio: ["ignore", "pipe", "pipe"], - }, - ); - const status = result.status ?? 0; - if (status !== 0) { - throw new Error( - `Failed to ${action} messaging provider '${binding.providerName}': ${compactOutput(result)}`, - ); - } - upserted.push({ - channelId: binding.channelId, - credentialId: binding.credentialId, - providerName: binding.providerName, - envKey: binding.providerEnvKey, - action, - }); - } - - const providerNames = uniqueStrings([ - ...upserted.map((entry) => entry.providerName), - ...reused.map((entry) => entry.providerName), - ]); - - return { - upserted, - reused, - missing, - providerNames, - sandboxCreateProviderArgs: providerNames.flatMap((providerName) => [ - "--provider", - providerName, - ]), - }; + return applyCredentialsPlanAtOpenShell(plan, options); } static applyPolicyAtOpenShell( @@ -227,95 +99,7 @@ export class MessagingSetupApplier { options: MessagingPolicyApplyOptions, ): MessagingPolicyApplyResult { assertSandboxMessagingPlan(plan); - const activePresets = uniqueStrings(plan.networkPolicy.presets); - if (activePresets.length > 0 && !options.applyPresets(plan.sandboxName, activePresets)) { - throw new Error(`Failed to apply messaging policy preset(s): ${activePresets.join(", ")}`); - } - - return { - appliedPresets: activePresets, - }; - } -} - -function hookRequestsForPhases( - plan: SandboxMessagingPlan, - phases: ReadonlySet, -): MessagingHookApplyRequest[] { - return plan.channels.flatMap((channel) => - channel.hooks - .filter((hook) => phases.has(hook.phase)) - .map((hook) => toHookApplyRequest(plan, channel, hook)), - ); -} - -function toHookApplyRequest( - plan: SandboxMessagingPlan, - channel: SandboxMessagingChannelPlan, - hook: SandboxMessagingChannelPlan["hooks"][number], -): MessagingHookApplyRequest { - const inputs = buildHookInputMap(plan, channel); - const selectedInputs = hook.inputs - ? Object.fromEntries( - hook.inputs - .filter((inputKey) => Object.hasOwn(inputs, inputKey)) - .map((inputKey) => [inputKey, inputs[inputKey] as MessagingSerializableValue]), - ) - : inputs; - - return { - sandboxName: plan.sandboxName, - agent: plan.agent, - channelId: channel.channelId, - hookId: hook.id, - phase: hook.phase, - handler: hook.handler, - inputKeys: hook.inputs, - inputs: selectedInputs, - outputs: hook.outputs, - onFailure: hook.onFailure, - }; -} - -function buildHookInputMap( - plan: SandboxMessagingPlan, - channel: SandboxMessagingChannelPlan, -): 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 plan.credentialBindings) { - if (credential.channelId !== channel.channelId) continue; - inputs[`credential.${credential.credentialId}.placeholder`] = credential.placeholder; - } - return inputs; -} - -async function runApplyHook( - request: MessagingHookApplyRequest, - runner: MessagingHookApplyRunner | undefined, - plan: SandboxMessagingPlan, - runOpenshell: MessagingOpenShellRunner, - applied: { - readonly appliedHooks: string[]; - readonly appliedTargets: string[]; - }, -): Promise { - if (!runner) return; - try { - const result = await runner(request); - applied.appliedHooks.push(`${request.channelId}:${request.hookId}`); - if (result?.outputs) { - applied.appliedTargets.push( - ...applyHookBuildFileOutputs(plan, result.outputs, runOpenshell), - ); - } - } catch (error) { - if (request.onFailure === "skip-channel") return; - throw error; + return applyPolicyPlanAtOpenShell(plan, options); } } @@ -358,7 +142,9 @@ function assertJsonSerializable( } if (Array.isArray(value)) { assertAcyclicObject(value, path, visiting, () => { - value.forEach((entry, index) => assertJsonSerializable(entry, `${path}[${index}]`, visiting)); + value.forEach((entry, index) => + assertJsonSerializable(entry, `${path}[${index}]`, visiting), + ); }); return; } @@ -389,375 +175,3 @@ function assertAcyclicObject( visiting.delete(value); } } - -function groupRenderByTarget( - render: readonly SandboxMessagingAgentRenderPlan[], -): ReadonlyMap { - const groups = new Map(); - for (const entry of render) { - const group = groups.get(entry.target) ?? []; - group.push(entry); - groups.set(entry.target, group); - } - return groups; -} - -function isJsonRender( - render: SandboxMessagingAgentRenderPlan, -): render is SandboxMessagingJsonRenderPlan { - return render.kind === "json-fragment"; -} - -function isEnvLinesRender( - render: SandboxMessagingAgentRenderPlan, -): render is SandboxMessagingEnvLinesRenderPlan { - return render.kind === "env-lines"; -} - -function applyJsonFragments( - existing: string | undefined, - render: readonly SandboxMessagingJsonRenderPlan[], - target: string, -): string { - const format = target.endsWith(".yaml") || target.endsWith(".yml") ? "yaml" : "json"; - const root = parseStructuredConfig(existing, target, format); - for (const entry of render) { - setJsonPath(root, entry.path, entry.value); - } - return format === "yaml" ? YAML.stringify(root) : `${JSON.stringify(root, null, 2)}\n`; -} - -function parseStructuredConfig( - existing: string | undefined, - target: string, - format: "json" | "yaml", -): Record { - if (!existing || existing.trim().length === 0) return {}; - const parsed = format === "yaml" ? YAML.parse(existing) : (JSON.parse(existing) as unknown); - if (!isObject(parsed)) { - throw new Error(`Messaging agent config target ${target} must contain an object.`); - } - return parsed as Record; -} - -function setJsonPath( - root: Record, - path: string, - value: MessagingSerializableValue, -): void { - const segments = path.split(".").filter(Boolean); - if (segments.length === 0) { - throw new Error("Messaging render path must not be empty."); - } - let cursor: Record = root; - for (const segment of segments.slice(0, -1)) { - const next = cursor[segment]; - if (!isObject(next)) { - const created: Record = {}; - cursor[segment] = created; - cursor = created; - } else { - cursor = next as Record; - } - } - cursor[segments[segments.length - 1] as string] = value; -} - -function applyEnvLines( - existing: string | undefined, - render: readonly SandboxMessagingEnvLinesRenderPlan[], -): string { - const desired = new Map(); - const rawDesiredLines: string[] = []; - for (const entry of render) { - for (const line of entry.lines) { - const key = readEnvLineKey(line); - if (key) { - desired.set(key, line); - } else { - rawDesiredLines.push(line); - } - } - } - - const written = new Set(); - const output = (existing ?? "") - .split(/\n/) - .filter((line, index, lines) => line.length > 0 || index < lines.length - 1) - .map((line) => { - const key = readEnvLineKey(line); - if (!key || !desired.has(key)) return line; - written.add(key); - return desired.get(key) as string; - }); - - for (const [key, line] of desired) { - if (!written.has(key)) output.push(line); - } - output.push(...rawDesiredLines); - return output.length > 0 ? `${output.join("\n")}\n` : ""; -} - -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 applyHookBuildFileOutputs( - plan: SandboxMessagingPlan, - outputs: MessagingHookOutputMap, - runOpenshell: MessagingOpenShellRunner, -): string[] { - const appliedTargets: string[] = []; - for (const output of Object.values(outputs)) { - if (output.kind !== "build-file") continue; - const file = readHookBuildFile(output.value); - const target = resolveHookBuildFileTarget(file.path, plan.agent); - const contents = - file.merge !== undefined - ? applyStructuredMerge( - readSandboxFile(plan.sandboxName, target, runOpenshell), - file.merge, - target, - ) - : serializeHookBuildFileContent(file.content, target); - writeSandboxFile(plan.sandboxName, target, contents, runOpenshell, file.mode); - appliedTargets.push(target); - } - return appliedTargets; -} - -function readHookBuildFile(value: MessagingSerializableValue): { - readonly path: string; - readonly mode?: string; - readonly content?: MessagingSerializableValue; - readonly merge?: MessagingSerializableValue; -} { - if (!isObject(value) || typeof value.path !== "string" || value.path.trim().length === 0) { - throw new Error("Messaging build-file hook output must include a non-empty path."); - } - const file = value as Record; - const path = value.path; - const mode = value.mode; - if (file.content === undefined && file.merge === undefined) { - throw new Error(`Messaging build-file '${path}' must include content or merge.`); - } - if (mode !== undefined && typeof mode !== "string") { - throw new Error(`Messaging build-file '${path}' mode must be a string.`); - } - return { - path, - mode, - content: file.content, - merge: file.merge, - }; -} - -function applyStructuredMerge( - existing: string | undefined, - patch: MessagingSerializableValue, - target: string, -): string { - if (!isObject(patch)) { - throw new Error(`Messaging build-file merge for ${target} must be an object.`); - } - const format = target.endsWith(".yaml") || target.endsWith(".yml") ? "yaml" : "json"; - const root = parseStructuredConfig(existing, target, format); - mergeObjects(root, patch); - return format === "yaml" ? YAML.stringify(root) : `${JSON.stringify(root, null, 2)}\n`; -} - -function mergeObjects( - target: Record, - patch: Record, -): void { - for (const [key, value] of Object.entries(patch)) { - if (key === "__proto__" || key === "prototype" || key === "constructor") { - throw new Error(`Messaging build-file merge rejected unsafe object key '${key}'.`); - } - const existing = target[key]; - if (isObject(existing) && isObject(value)) { - mergeObjects( - existing as Record, - value as Record, - ); - continue; - } - validateSafeMergeValue(value); - target[key] = value; - } -} - -function validateSafeMergeValue(value: MessagingSerializableValue): void { - if (Array.isArray(value)) { - for (const entry of value) { - validateSafeMergeValue(entry); - } - return; - } - if (!isObject(value)) return; - for (const [key, entry] of Object.entries(value)) { - if (key === "__proto__" || key === "prototype" || key === "constructor") { - throw new Error(`Messaging build-file merge rejected unsafe object key '${key}'.`); - } - validateSafeMergeValue(entry as MessagingSerializableValue); - } -} - -function serializeHookBuildFileContent( - content: MessagingSerializableValue | undefined, - target: string, -): string { - if (content === undefined) return ""; - if (typeof content === "string") return content.endsWith("\n") ? content : `${content}\n`; - if (target.endsWith(".yaml") || target.endsWith(".yml")) return YAML.stringify(content); - return `${JSON.stringify(content, null, 2)}\n`; -} - -function resolveHookBuildFileTarget(path: string, agent: MessagingAgentId): string { - if (path.startsWith("/")) return path; - if (path === "openclaw.json") return resolveSandboxAgentConfigTarget(path, "openclaw"); - if (path === "config.yaml" && agent === "hermes") { - return resolveSandboxAgentConfigTarget("~/.hermes/config.yaml", agent); - } - if (path === ".env" && agent === "hermes") { - return resolveSandboxAgentConfigTarget("~/.hermes/.env", agent); - } - if (agent === "openclaw") return `/sandbox/.openclaw/${path}`; - if (agent === "hermes") return `/sandbox/.hermes/${path}`; - throw new Error(`Cannot resolve messaging build-file target '${path}' for ${agent}.`); -} - -function resolveSandboxAgentConfigTarget(target: string, agent: MessagingAgentId): string { - if (target.startsWith("/")) return target; - if (agent === "openclaw" && target === "openclaw.json") { - return "/sandbox/.openclaw/openclaw.json"; - } - if (target.startsWith("~/.openclaw/")) { - return `/sandbox/.openclaw/${target.slice("~/.openclaw/".length)}`; - } - if (target.startsWith("~/.hermes/")) { - return `/sandbox/.hermes/${target.slice("~/.hermes/".length)}`; - } - throw new Error(`Cannot resolve messaging agent config target '${target}' for ${agent}.`); -} - -function readSandboxFile( - sandboxName: string, - target: string, - runOpenshell: MessagingOpenShellRunner, -): string | undefined { - const result = runOpenshell( - ["sandbox", "exec", "--name", sandboxName, "--", "cat", target], - { - ignoreError: true, - stdio: ["ignore", "pipe", "pipe"], - }, - ); - const status = result.status ?? 0; - return status === 0 ? String(result.stdout ?? "") : undefined; -} - -function writeSandboxFile( - sandboxName: string, - target: string, - contents: string, - runOpenshell: MessagingOpenShellRunner, - mode?: string, -): void { - const result = runOpenshell( - [ - "sandbox", - "exec", - "--name", - sandboxName, - "--", - "sh", - "-c", - mode - ? 'mkdir -p "$(dirname "$1")" && cat > "$1" && chmod "$2" "$1"' - : 'mkdir -p "$(dirname "$1")" && cat > "$1"', - "sh", - target, - ...(mode ? [mode] : []), - ], - { - input: contents, - stdio: ["pipe", "pipe", "pipe"], - }, - ); - const status = result.status ?? 0; - if (status !== 0) { - throw new Error( - `Failed to apply messaging agent config '${target}': ${compactOutput(result)}`, - ); - } -} - -function readCredentialEnv(env: NodeJS.ProcessEnv, envKey: string): string | null { - const raw = env[envKey]; - if (typeof raw !== "string") return null; - const normalized = raw.replace(/\r/g, "").trim(); - return normalized || null; -} - -function providerExistsInGateway( - providerName: string, - runOpenshell: MessagingOpenShellRunner, -): boolean { - const result = runOpenshell(["provider", "get", providerName], { - ignoreError: true, - stdio: ["ignore", "ignore", "ignore"], - }); - return (result.status ?? 0) === 0; -} - -function buildProviderArgs( - action: "create" | "update", - providerName: string, - credentialEnv: string, -): string[] { - return action === "create" - ? [ - "provider", - "create", - "--name", - providerName, - "--type", - "generic", - "--credential", - credentialEnv, - ] - : ["provider", "update", providerName, "--credential", credentialEnv]; -} - -function toReuseEntry(binding: MessagingCredentialBindingLike): MessagingCredentialReuseEntry { - return { - channelId: binding.channelId, - credentialId: binding.credentialId, - providerName: binding.providerName, - envKey: binding.providerEnvKey, - }; -} - -function toMissingEntry(binding: MessagingCredentialBindingLike): MessagingMissingCredentialEntry { - return { - channelId: binding.channelId, - credentialId: binding.credentialId, - providerName: binding.providerName, - envKey: binding.providerEnvKey, - }; -} - -function compactOutput(result: { readonly stdout?: unknown; readonly stderr?: unknown }): string { - const output = redact(`${String(result.stderr ?? "")}${String(result.stdout ?? "")}`) - .replace(/\r/g, "") - .trim(); - return output || "OpenShell command failed."; -} - -function uniqueStrings(values: readonly string[]): string[] { - return [...new Set(values.filter(Boolean))]; -} diff --git a/src/lib/messaging/channels/telegram/hooks/fakes.test.ts b/src/lib/messaging/channels/telegram/hooks/fakes.test.ts deleted file mode 100644 index 26595f9e62..0000000000 --- a/src/lib/messaging/channels/telegram/hooks/fakes.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { describe, expect, it } from "vitest"; - -import { MessagingHookRegistry, runMessagingHook } from "../../../hooks"; -import { telegramManifest } from "../manifest"; -import { - FAKE_TELEGRAM_HOOK_REGISTRATIONS, - TELEGRAM_GET_ME_REACHABILITY_HOOK_ID, -} from "./fakes"; - -describe("Telegram fake hook implementations", () => { - it("declares the reachability hook without exposing the token in outputs", async () => { - const registry = new MessagingHookRegistry(FAKE_TELEGRAM_HOOK_REGISTRATIONS); - const hook = telegramManifest.hooks.find((entry) => entry.phase === "reachability-check"); - - if (!hook) throw new Error("missing Telegram reachability hook"); - - await expect( - runMessagingHook(hook, registry, { - channelId: "telegram", - inputs: { - botToken: "123456:telegram-token", - }, - }), - ).resolves.toEqual({ - hookId: "telegram-reachability", - handlerId: TELEGRAM_GET_ME_REACHABILITY_HOOK_ID, - phase: "reachability-check", - outputs: {}, - }); - }); -}); diff --git a/src/lib/messaging/channels/telegram/hooks/fakes.ts b/src/lib/messaging/channels/telegram/hooks/fakes.ts deleted file mode 100644 index be72c6385b..0000000000 --- a/src/lib/messaging/channels/telegram/hooks/fakes.ts +++ /dev/null @@ -1,21 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import type { MessagingHookHandler, MessagingHookRegistration } from "../../../hooks"; - -export const TELEGRAM_GET_ME_REACHABILITY_HOOK_ID = "telegram.getMeReachability"; - -export const fakeTelegramGetMeReachabilityHook: MessagingHookHandler = (context) => { - const token = context.inputs?.botToken; - if (typeof token !== "string" || token.length === 0) { - throw new Error("Telegram reachability check requires botToken."); - } - return {}; -}; - -export const FAKE_TELEGRAM_HOOK_REGISTRATIONS: readonly MessagingHookRegistration[] = [ - { - id: TELEGRAM_GET_ME_REACHABILITY_HOOK_ID, - handler: fakeTelegramGetMeReachabilityHook, - }, -] as const; diff --git a/src/lib/messaging/channels/telegram/hooks/get-me-reachability.test.ts b/src/lib/messaging/channels/telegram/hooks/get-me-reachability.test.ts new file mode 100644 index 0000000000..44754d53d8 --- /dev/null +++ b/src/lib/messaging/channels/telegram/hooks/get-me-reachability.test.ts @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { MessagingHookRegistry, runMessagingHook } from "../../../hooks"; +import { telegramManifest } from "../manifest"; +import { + createTelegramGetMeReachabilityHook, + TELEGRAM_GET_ME_REACHABILITY_HOOK_ID, +} from "./get-me-reachability"; + +describe("Telegram getMe reachability hook implementation", () => { + it("calls Telegram getMe without exposing the token in outputs", async () => { + const urls: string[] = []; + const registry = new MessagingHookRegistry([ + { + id: TELEGRAM_GET_ME_REACHABILITY_HOOK_ID, + handler: createTelegramGetMeReachabilityHook({ + apiBaseUrl: "https://telegram.test", + fetch: async (url) => { + urls.push(url); + return { + ok: true, + status: 200, + async json() { + return { ok: true, result: { id: 42, is_bot: true } }; + }, + async text() { + return ""; + }, + }; + }, + }), + }, + ]); + const hook = telegramManifest.hooks.find((entry) => entry.phase === "reachability-check"); + + if (!hook) throw new Error("missing Telegram reachability hook"); + + await expect( + runMessagingHook(hook, registry, { + channelId: "telegram", + inputs: { + botToken: "123456:telegram-token", + }, + }), + ).resolves.toEqual({ + hookId: "telegram-reachability", + handlerId: TELEGRAM_GET_ME_REACHABILITY_HOOK_ID, + phase: "reachability-check", + outputs: {}, + }); + expect(urls).toEqual(["https://telegram.test/bot123456:telegram-token/getMe"]); + }); + + it("fails closed when Telegram rejects the token", async () => { + const registry = new MessagingHookRegistry([ + { + id: TELEGRAM_GET_ME_REACHABILITY_HOOK_ID, + handler: createTelegramGetMeReachabilityHook({ + fetch: async () => ({ + ok: false, + status: 401, + statusText: "Unauthorized", + async json() { + return { ok: false }; + }, + async text() { + return "unauthorized"; + }, + }), + }), + }, + ]); + const hook = telegramManifest.hooks.find((entry) => entry.phase === "reachability-check"); + + if (!hook) throw new Error("missing Telegram reachability hook"); + + await expect( + runMessagingHook(hook, registry, { + channelId: "telegram", + inputs: { + botToken: "bad-token", + }, + }), + ).rejects.toThrow("Telegram reachability check failed with HTTP 401 Unauthorized."); + }); +}); diff --git a/src/lib/messaging/channels/telegram/hooks/get-me-reachability.ts b/src/lib/messaging/channels/telegram/hooks/get-me-reachability.ts new file mode 100644 index 0000000000..eb4729582e --- /dev/null +++ b/src/lib/messaging/channels/telegram/hooks/get-me-reachability.ts @@ -0,0 +1,91 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { normalizeCredentialValue } from "../../../../credentials/store"; +import type { MessagingHookHandler, MessagingHookRegistration } from "../../../hooks/types"; + +export const TELEGRAM_GET_ME_REACHABILITY_HOOK_ID = "telegram.getMeReachability"; + +interface TelegramFetchResponse { + readonly ok: boolean; + readonly status: number; + readonly statusText?: string; + json(): Promise; + text(): Promise; +} + +type TelegramFetch = (url: string) => Promise; + +export interface TelegramGetMeReachabilityHookOptions { + readonly fetch?: TelegramFetch; + readonly apiBaseUrl?: string; +} + +export function createTelegramGetMeReachabilityHook( + options: TelegramGetMeReachabilityHookOptions = {}, +): MessagingHookHandler { + return async (context) => { + const rawToken = context.inputs?.botToken; + const token = normalizeCredentialValue(typeof rawToken === "string" ? rawToken : ""); + if (!token) { + throw new Error("Telegram reachability check requires botToken."); + } + + const response = await fetchTelegramGetMe(token, options).catch(() => { + throw new Error("Telegram reachability check failed: Bot API request failed."); + }); + if (!response.ok) { + throw new Error( + `Telegram reachability check failed with HTTP ${response.status}${ + response.statusText ? ` ${response.statusText}` : "" + }.`, + ); + } + + const payload = await readTelegramJson(response); + if (!isObject(payload) || payload.ok !== true) { + throw new Error("Telegram reachability check failed: Bot API rejected the token."); + } + + return {}; + }; +} + +export function createTelegramHookRegistrations( + options: TelegramGetMeReachabilityHookOptions = {}, +): readonly MessagingHookRegistration[] { + return [ + { + id: TELEGRAM_GET_ME_REACHABILITY_HOOK_ID, + handler: createTelegramGetMeReachabilityHook(options), + }, + ] as const; +} + +async function fetchTelegramGetMe( + token: string, + options: TelegramGetMeReachabilityHookOptions, +): Promise { + const fetchImpl = options.fetch ?? defaultFetch; + const baseUrl = (options.apiBaseUrl ?? "https://api.telegram.org").replace(/\/+$/, ""); + return fetchImpl(`${baseUrl}/bot${token}/getMe`); +} + +async function defaultFetch(url: string): Promise { + if (typeof fetch !== "function") { + throw new Error("Telegram reachability check requires global fetch."); + } + return fetch(url) as Promise; +} + +async function readTelegramJson(response: TelegramFetchResponse): Promise { + try { + return await response.json(); + } catch (_error) { + return {}; + } +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/lib/messaging/channels/telegram/hooks/index.ts b/src/lib/messaging/channels/telegram/hooks/index.ts new file mode 100644 index 0000000000..bafffe4fcb --- /dev/null +++ b/src/lib/messaging/channels/telegram/hooks/index.ts @@ -0,0 +1,4 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export * from "./get-me-reachability"; diff --git a/src/lib/messaging/channels/wechat/hooks/fakes.test.ts b/src/lib/messaging/channels/wechat/hooks/fakes.test.ts deleted file mode 100644 index 428e9f7fac..0000000000 --- a/src/lib/messaging/channels/wechat/hooks/fakes.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { describe, expect, it } from "vitest"; - -import { MessagingHookRegistry, runMessagingHook } from "../../../hooks"; -import { wechatManifest } from "../manifest"; -import { - FAKE_WECHAT_HOOK_REGISTRATIONS, - WECHAT_ILINK_LOGIN_HOOK_ID, - WECHAT_SEED_OPENCLAW_ACCOUNT_HOOK_ID, -} from "./fakes"; - -describe("WeChat fake hook implementations", () => { - it("uses fake registrations with the same handler ids declared by the manifest", () => { - expect(FAKE_WECHAT_HOOK_REGISTRATIONS.map((registration) => registration.id)).toEqual([ - WECHAT_ILINK_LOGIN_HOOK_ID, - WECHAT_SEED_OPENCLAW_ACCOUNT_HOOK_ID, - ]); - expect(wechatManifest.hooks.map((hook) => hook.handler)).toEqual([ - WECHAT_ILINK_LOGIN_HOOK_ID, - WECHAT_SEED_OPENCLAW_ACCOUNT_HOOK_ID, - ]); - }); - - it("shows the host-QR hook output shape without running real QR login", async () => { - const registry = new MessagingHookRegistry(FAKE_WECHAT_HOOK_REGISTRATIONS); - const hostQrHook = wechatManifest.hooks[0]; - - if (!hostQrHook) throw new Error("missing WeChat host-QR hook"); - - await expect( - runMessagingHook(hostQrHook, registry, { - channelId: "wechat", - }), - ).resolves.toMatchObject({ - handlerId: WECHAT_ILINK_LOGIN_HOOK_ID, - outputs: { - botToken: { - kind: "secret", - }, - accountId: { - kind: "config", - value: "fake-wechat-account", - }, - baseUrl: { - kind: "config", - }, - userId: { - kind: "config", - }, - }, - }); - }); - - it("shows the account-seed hook output shape without writing files", async () => { - const registry = new MessagingHookRegistry(FAKE_WECHAT_HOOK_REGISTRATIONS); - const seedHook = wechatManifest.hooks[1]; - - if (!seedHook) throw new Error("missing WeChat seed hook"); - - await expect( - runMessagingHook(seedHook, registry, { - channelId: "wechat", - inputs: { - "wechatConfig.accountId": "fake-wechat-account", - "wechatConfig.baseUrl": "https://ilinkai.wechat.example", - "wechatConfig.userId": "fake-wechat-user", - "credential.wechatBotToken.placeholder": "openshell:resolve:env:WECHAT_BOT_TOKEN", - }, - }), - ).resolves.toMatchObject({ - handlerId: WECHAT_SEED_OPENCLAW_ACCOUNT_HOOK_ID, - outputs: { - openclawWeixinAccountsIndex: { - kind: "build-file", - value: { - path: "openclaw-weixin/accounts.json", - content: ["fake-wechat-account"], - }, - }, - openclawWeixinAccountFile: { - kind: "build-file", - value: { - path: "openclaw-weixin/accounts/fake-wechat-account.json", - content: { - token: "openshell:resolve:env:WECHAT_BOT_TOKEN", - baseUrl: "https://ilinkai.wechat.example", - userId: "fake-wechat-user", - }, - }, - }, - openclawConfigPatch: { - kind: "build-file", - value: { - path: "openclaw.json", - merge: { - plugins: { - entries: { - "openclaw-weixin": { - enabled: true, - }, - }, - }, - channels: { - "openclaw-weixin": { - accounts: { - "fake-wechat-account": { - enabled: true, - }, - }, - }, - }, - }, - }, - }, - }, - }); - }); -}); diff --git a/src/lib/messaging/channels/wechat/hooks/fakes.ts b/src/lib/messaging/channels/wechat/hooks/fakes.ts deleted file mode 100644 index e7876f3bcc..0000000000 --- a/src/lib/messaging/channels/wechat/hooks/fakes.ts +++ /dev/null @@ -1,137 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import type { - MessagingHookHandler, - MessagingHookInputMap, - MessagingHookRegistration, -} from "../../../hooks"; - -export const WECHAT_ILINK_LOGIN_HOOK_ID = "wechat.ilinkLogin"; -export const WECHAT_SEED_OPENCLAW_ACCOUNT_HOOK_ID = "wechat.seedOpenClawAccount"; - -const FAKE_WECHAT_ACCOUNT_ID = "fake-wechat-account"; -const FAKE_WECHAT_BASE_URL = "https://ilinkai.wechat.example"; -const FAKE_WECHAT_USER_ID = "fake-wechat-user"; -const FAKE_WECHAT_TOKEN_PLACEHOLDER = "openshell:resolve:env:WECHAT_BOT_TOKEN"; -const FAKE_WECHAT_SAVED_AT = "2026-01-01T00:00:00.000Z"; -const WECHAT_PLUGIN_ID = "openclaw-weixin"; -const WECHAT_PLUGIN_INSTALL_PATH = "/sandbox/.openclaw/extensions/openclaw-weixin"; -const WECHAT_PLUGIN_SPEC = "@tencent-weixin/openclaw-weixin@2.4.2"; - -export const fakeWechatIlinkLoginHook: MessagingHookHandler = () => ({ - outputs: { - botToken: { - kind: "secret", - value: "fake-wechat-token", - }, - accountId: { - kind: "config", - value: FAKE_WECHAT_ACCOUNT_ID, - }, - baseUrl: { - kind: "config", - value: FAKE_WECHAT_BASE_URL, - }, - userId: { - kind: "config", - value: FAKE_WECHAT_USER_ID, - }, - }, -}); - -export const fakeWechatSeedOpenClawAccountHook: MessagingHookHandler = (context) => { - const accountId = inputString( - context.inputs, - "wechatConfig.accountId", - FAKE_WECHAT_ACCOUNT_ID, - ); - const baseUrl = inputString(context.inputs, "wechatConfig.baseUrl", FAKE_WECHAT_BASE_URL); - const userId = inputString(context.inputs, "wechatConfig.userId", FAKE_WECHAT_USER_ID); - const token = inputString( - context.inputs, - "credential.wechatBotToken.placeholder", - FAKE_WECHAT_TOKEN_PLACEHOLDER, - ); - - return { - outputs: { - openclawWeixinAccountsIndex: { - kind: "build-file", - value: { - path: "openclaw-weixin/accounts.json", - mode: "0600", - content: [accountId], - }, - }, - openclawWeixinAccountFile: { - kind: "build-file", - value: { - path: `openclaw-weixin/accounts/${accountId}.json`, - mode: "0600", - content: { - token, - savedAt: FAKE_WECHAT_SAVED_AT, - baseUrl, - userId, - }, - }, - }, - openclawConfigPatch: { - kind: "build-file", - value: { - path: "openclaw.json", - merge: { - plugins: { - installs: { - [WECHAT_PLUGIN_ID]: { - source: "npm", - spec: WECHAT_PLUGIN_SPEC, - installPath: WECHAT_PLUGIN_INSTALL_PATH, - }, - }, - load: { - paths: [WECHAT_PLUGIN_INSTALL_PATH], - }, - entries: { - [WECHAT_PLUGIN_ID]: { - enabled: true, - }, - }, - }, - channels: { - [WECHAT_PLUGIN_ID]: { - channelConfigUpdatedAt: FAKE_WECHAT_SAVED_AT, - accounts: { - [accountId]: { - enabled: true, - }, - }, - }, - }, - }, - }, - }, - }, - }; -}; - -function inputString( - inputs: MessagingHookInputMap | undefined, - key: string, - fallback: string, -): string { - const value = inputs?.[key]; - return typeof value === "string" && value.trim() ? value : fallback; -} - -export const FAKE_WECHAT_HOOK_REGISTRATIONS: readonly MessagingHookRegistration[] = [ - { - id: WECHAT_ILINK_LOGIN_HOOK_ID, - handler: fakeWechatIlinkLoginHook, - }, - { - id: WECHAT_SEED_OPENCLAW_ACCOUNT_HOOK_ID, - handler: fakeWechatSeedOpenClawAccountHook, - }, -] as const; diff --git a/src/lib/messaging/channels/wechat/hooks/ilink-login.ts b/src/lib/messaging/channels/wechat/hooks/ilink-login.ts new file mode 100644 index 0000000000..ccc4006f66 --- /dev/null +++ b/src/lib/messaging/channels/wechat/hooks/ilink-login.ts @@ -0,0 +1,122 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + normalizeCredentialValue, + saveCredential as saveProcessCredential, +} from "../../../../credentials/store"; +import { + runWechatHostQrLogin, + type WechatLoginResult, +} from "../../../../../ext/wechat/login"; +import type { + MessagingHookHandler, + MessagingHookOutputMap, + MessagingHookRegistration, +} from "../../../hooks/types"; +import type { MessagingSerializableValue } from "../../../manifest"; + +export const WECHAT_ILINK_LOGIN_HOOK_ID = "wechat.ilinkLogin"; + +export interface WechatIlinkLoginHookOptions { + readonly env?: NodeJS.ProcessEnv; + readonly runLogin?: () => Promise; + readonly saveCredential?: (key: string, value: string) => void; +} + +export function createWechatIlinkLoginHook( + options: WechatIlinkLoginHookOptions = {}, +): MessagingHookHandler { + return async (context) => { + const runLogin = options.runLogin ?? (() => runWechatHostQrLogin()); + const result = await runLogin(); + if (result.kind !== "ok") { + throw new Error(`WeChat host QR login failed: ${wechatFailureReason(result)}.`); + } + + const env = options.env ?? process.env; + const saveCredential = options.saveCredential ?? saveProcessCredential; + const { token, accountId, baseUrl, userId } = result.credentials; + + saveCredential("WECHAT_BOT_TOKEN", token); + env.WECHAT_BOT_TOKEN = token; + env.WECHAT_ACCOUNT_ID = accountId; + if (baseUrl) env.WECHAT_BASE_URL = baseUrl; + if (userId) env.WECHAT_USER_ID = userId; + + const outputs: Record = { + botToken: { + kind: "secret", + value: token, + }, + accountId: { + kind: "config", + value: accountId, + }, + }; + + if (baseUrl) { + outputs.baseUrl = { + kind: "config", + value: baseUrl, + }; + } + if (userId) { + outputs.userId = { + kind: "config", + value: userId, + }; + } + if (declaresOutput(context.outputDeclarations, "allowedIds")) { + const allowedIds = mergeCsvValues(readString(context.inputs?.allowedIds), userId); + if (allowedIds) { + env.WECHAT_ALLOWED_IDS = allowedIds; + outputs.allowedIds = { + kind: "config", + value: allowedIds, + }; + } + } + + return { outputs }; + }; +} + +export function createWechatIlinkLoginHookRegistration( + options: WechatIlinkLoginHookOptions = {}, +): MessagingHookRegistration { + return { + id: WECHAT_ILINK_LOGIN_HOOK_ID, + handler: createWechatIlinkLoginHook(options), + }; +} + +function wechatFailureReason(result: Exclude): string { + if (result.kind === "timeout") return "QR login timed out"; + if (result.kind === "expired") return "QR expired too many times"; + if (result.kind === "aborted") return "login aborted"; + return result.message || "unknown error"; +} + +function declaresOutput( + declarations: readonly { readonly id: string }[] | undefined, + outputId: string, +): boolean { + return (declarations ?? []).some((declaration) => declaration.id === outputId); +} + +function readString(value: MessagingSerializableValue | undefined): string { + return typeof value === "string" ? value : ""; +} + +function mergeCsvValues(existing: string, next: string): string { + const values = new Set( + normalizeCredentialValue(existing) + .split(",") + .map((value) => value.trim()) + .filter(Boolean), + ); + const normalized = normalizeCredentialValue(next); + if (normalized) values.add(normalized); + return Array.from(values).join(","); +} diff --git a/src/lib/messaging/channels/wechat/hooks/implementations.test.ts b/src/lib/messaging/channels/wechat/hooks/implementations.test.ts new file mode 100644 index 0000000000..d2ba41becd --- /dev/null +++ b/src/lib/messaging/channels/wechat/hooks/implementations.test.ts @@ -0,0 +1,166 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { MessagingHookRegistry, runMessagingHook } from "../../../hooks"; +import { wechatManifest } from "../manifest"; +import { createWechatIlinkLoginHook, WECHAT_ILINK_LOGIN_HOOK_ID } from "./ilink-login"; +import { + createWechatSeedOpenClawAccountHook, + WECHAT_PLUGIN_SPEC, + WECHAT_SEED_OPENCLAW_ACCOUNT_HOOK_ID, +} from "./seed-openclaw-account"; + +describe("WeChat hook implementations", () => { + it("runs host QR enrollment and stages token plus non-secret account metadata", async () => { + const env: NodeJS.ProcessEnv = {}; + const saved: Array<{ readonly key: string; readonly value: string }> = []; + const registry = new MessagingHookRegistry([ + { + id: WECHAT_ILINK_LOGIN_HOOK_ID, + handler: createWechatIlinkLoginHook({ + env, + saveCredential: (key, value) => saved.push({ key, value }), + runLogin: async () => ({ + kind: "ok", + credentials: { + token: "wechat-token", + accountId: "wechat-account", + baseUrl: "https://ilinkai.wechat.example", + userId: "wechat-user", + }, + }), + }), + }, + ]); + const hook = wechatManifest.hooks[0]; + + if (!hook) throw new Error("missing WeChat host QR hook"); + + await expect( + runMessagingHook(hook, registry, { + channelId: "wechat", + inputs: { + allowedIds: "friend-one", + }, + }), + ).resolves.toMatchObject({ + handlerId: WECHAT_ILINK_LOGIN_HOOK_ID, + outputs: { + botToken: { + kind: "secret", + value: "wechat-token", + }, + accountId: { + kind: "config", + value: "wechat-account", + }, + allowedIds: { + kind: "config", + value: "friend-one,wechat-user", + }, + }, + }); + expect(saved).toEqual([{ key: "WECHAT_BOT_TOKEN", value: "wechat-token" }]); + expect(env).toMatchObject({ + WECHAT_BOT_TOKEN: "wechat-token", + WECHAT_ACCOUNT_ID: "wechat-account", + WECHAT_BASE_URL: "https://ilinkai.wechat.example", + WECHAT_USER_ID: "wechat-user", + WECHAT_ALLOWED_IDS: "friend-one,wechat-user", + }); + }); + + it("turns QR failures into hook failures without writing credentials", async () => { + const env: NodeJS.ProcessEnv = {}; + const saved: Array<{ readonly key: string; readonly value: string }> = []; + const registry = new MessagingHookRegistry([ + { + id: WECHAT_ILINK_LOGIN_HOOK_ID, + handler: createWechatIlinkLoginHook({ + env, + saveCredential: (key, value) => saved.push({ key, value }), + runLogin: async () => ({ kind: "timeout" }), + }), + }, + ]); + const hook = wechatManifest.hooks[0]; + + if (!hook) throw new Error("missing WeChat host QR hook"); + + await expect( + runMessagingHook(hook, registry, { + channelId: "wechat", + }), + ).rejects.toThrow("WeChat host QR login failed: QR login timed out."); + expect(saved).toEqual([]); + expect(env.WECHAT_BOT_TOKEN).toBeUndefined(); + }); + + it("generates OpenClaw account seed build-file outputs from captured metadata", async () => { + const registry = new MessagingHookRegistry([ + { + id: WECHAT_SEED_OPENCLAW_ACCOUNT_HOOK_ID, + handler: createWechatSeedOpenClawAccountHook({ + now: () => "2026-05-25T00:00:00.000Z", + }), + }, + ]); + const hook = wechatManifest.hooks[1]; + + if (!hook) throw new Error("missing WeChat seed hook"); + + await expect( + runMessagingHook(hook, registry, { + channelId: "wechat", + inputs: { + "wechatConfig.accountId": "wechat-account", + "wechatConfig.baseUrl": "https://ilinkai.wechat.example", + "wechatConfig.userId": "wechat-user", + "credential.wechatBotToken.placeholder": "openshell:resolve:env:WECHAT_BOT_TOKEN", + }, + }), + ).resolves.toMatchObject({ + handlerId: WECHAT_SEED_OPENCLAW_ACCOUNT_HOOK_ID, + outputs: { + openclawWeixinAccountFile: { + kind: "build-file", + value: { + path: "openclaw-weixin/accounts/wechat-account.json", + content: { + token: "openshell:resolve:env:WECHAT_BOT_TOKEN", + savedAt: "2026-05-25T00:00:00.000Z", + baseUrl: "https://ilinkai.wechat.example", + userId: "wechat-user", + }, + }, + }, + openclawConfigPatch: { + kind: "build-file", + value: { + merge: { + plugins: { + installs: { + "openclaw-weixin": { + spec: WECHAT_PLUGIN_SPEC, + }, + }, + }, + channels: { + "openclaw-weixin": { + channelConfigUpdatedAt: "2026-05-25T00:00:00.000Z", + accounts: { + "wechat-account": { + enabled: true, + }, + }, + }, + }, + }, + }, + }, + }, + }); + }); +}); diff --git a/src/lib/messaging/channels/wechat/hooks/index.ts b/src/lib/messaging/channels/wechat/hooks/index.ts new file mode 100644 index 0000000000..796ab03f2b --- /dev/null +++ b/src/lib/messaging/channels/wechat/hooks/index.ts @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { MessagingHookRegistration } from "../../../hooks/types"; +import { + createWechatIlinkLoginHookRegistration, + type WechatIlinkLoginHookOptions, +} from "./ilink-login"; +import { + createWechatSeedOpenClawAccountHookRegistration, + type WechatSeedOpenClawAccountHookOptions, +} from "./seed-openclaw-account"; + +export * from "./ilink-login"; +export * from "./seed-openclaw-account"; + +export interface WechatHookOptions { + readonly ilinkLogin?: WechatIlinkLoginHookOptions; + readonly seedOpenClawAccount?: WechatSeedOpenClawAccountHookOptions; +} + +export function createWechatHookRegistrations( + options: WechatHookOptions = {}, +): readonly MessagingHookRegistration[] { + return [ + createWechatIlinkLoginHookRegistration(options.ilinkLogin), + createWechatSeedOpenClawAccountHookRegistration(options.seedOpenClawAccount), + ] as const; +} diff --git a/src/lib/messaging/channels/wechat/hooks/seed-openclaw-account.ts b/src/lib/messaging/channels/wechat/hooks/seed-openclaw-account.ts new file mode 100644 index 0000000000..0bc0beb9d5 --- /dev/null +++ b/src/lib/messaging/channels/wechat/hooks/seed-openclaw-account.ts @@ -0,0 +1,137 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { + MessagingHookHandler, + MessagingHookInputMap, + MessagingHookOutputMap, + MessagingHookRegistration, +} from "../../../hooks/types"; + +export const WECHAT_SEED_OPENCLAW_ACCOUNT_HOOK_ID = "wechat.seedOpenClawAccount"; +export const WECHAT_TOKEN_PLACEHOLDER = "openshell:resolve:env:WECHAT_BOT_TOKEN"; +export const WECHAT_PLUGIN_ID = "openclaw-weixin"; +export const WECHAT_PLUGIN_INSTALL_PATH = "/sandbox/.openclaw/extensions/openclaw-weixin"; +export const WECHAT_PLUGIN_SPEC = "@tencent-weixin/openclaw-weixin@2.4.3"; + +export interface WechatSeedOpenClawAccountHookOptions { + readonly now?: () => Date | string; + readonly pluginInstallPath?: string; + readonly pluginSpec?: string; +} + +export function createWechatSeedOpenClawAccountHook( + options: WechatSeedOpenClawAccountHookOptions = {}, +): MessagingHookHandler { + return (context) => ({ + outputs: buildWechatSeedOpenClawAccountOutputs(context.inputs, options), + }); +} + +export function createWechatSeedOpenClawAccountHookRegistration( + options: WechatSeedOpenClawAccountHookOptions = {}, +): MessagingHookRegistration { + return { + id: WECHAT_SEED_OPENCLAW_ACCOUNT_HOOK_ID, + handler: createWechatSeedOpenClawAccountHook(options), + }; +} + +export function buildWechatSeedOpenClawAccountOutputs( + inputs: MessagingHookInputMap | undefined, + options: WechatSeedOpenClawAccountHookOptions = {}, +): MessagingHookOutputMap { + const accountId = requiredInputString(inputs, "wechatConfig.accountId"); + const baseUrl = optionalInputString(inputs, "wechatConfig.baseUrl"); + const userId = optionalInputString(inputs, "wechatConfig.userId"); + const token = optionalInputString( + inputs, + "credential.wechatBotToken.placeholder", + ) || WECHAT_TOKEN_PLACEHOLDER; + const savedAt = isoTimestamp(options.now); + const pluginInstallPath = options.pluginInstallPath ?? WECHAT_PLUGIN_INSTALL_PATH; + const pluginSpec = options.pluginSpec ?? WECHAT_PLUGIN_SPEC; + + return { + openclawWeixinAccountsIndex: { + kind: "build-file", + value: { + path: "openclaw-weixin/accounts.json", + mode: "0600", + content: [accountId], + }, + }, + openclawWeixinAccountFile: { + kind: "build-file", + value: { + path: `openclaw-weixin/accounts/${accountId}.json`, + mode: "0600", + content: { + token, + savedAt, + ...(baseUrl ? { baseUrl } : {}), + ...(userId ? { userId } : {}), + }, + }, + }, + openclawConfigPatch: { + kind: "build-file", + value: { + path: "openclaw.json", + merge: { + plugins: { + installs: { + [WECHAT_PLUGIN_ID]: { + source: "npm", + spec: pluginSpec, + installPath: pluginInstallPath, + }, + }, + load: { + paths: [pluginInstallPath], + }, + entries: { + [WECHAT_PLUGIN_ID]: { + enabled: true, + }, + }, + }, + channels: { + [WECHAT_PLUGIN_ID]: { + channelConfigUpdatedAt: savedAt, + accounts: { + [accountId]: { + enabled: true, + }, + }, + }, + }, + }, + }, + }, + }; +} + +function requiredInputString( + inputs: MessagingHookInputMap | undefined, + key: string, +): string { + const value = optionalInputString(inputs, key); + if (!value) { + throw new Error(`WeChat account seeding requires ${key}.`); + } + return value; +} + +function optionalInputString( + inputs: MessagingHookInputMap | undefined, + key: string, +): string { + const value = inputs?.[key]; + return typeof value === "string" ? value.trim() : ""; +} + +function isoTimestamp(now: WechatSeedOpenClawAccountHookOptions["now"]): string { + const value = now?.() ?? new Date(); + return typeof value === "string" ? value : value.toISOString(); +} diff --git a/src/lib/messaging/channels/wechat/manifest.ts b/src/lib/messaging/channels/wechat/manifest.ts index 6fb465983d..249dc9423f 100644 --- a/src/lib/messaging/channels/wechat/manifest.ts +++ b/src/lib/messaging/channels/wechat/manifest.ts @@ -109,6 +109,7 @@ export const wechatManifest = { id: "wechat-host-qr", phase: "enroll", handler: "wechat.ilinkLogin", + inputs: ["allowedIds"], outputs: [ { id: "botToken", @@ -128,6 +129,10 @@ export const wechatManifest = { id: "userId", kind: "config", }, + { + id: "allowedIds", + kind: "config", + }, ], onFailure: "skip-channel", }, diff --git a/src/lib/messaging/compiler/manifest-compiler.test.ts b/src/lib/messaging/compiler/manifest-compiler.test.ts index 0254fe81a3..48d4e67178 100644 --- a/src/lib/messaging/compiler/manifest-compiler.test.ts +++ b/src/lib/messaging/compiler/manifest-compiler.test.ts @@ -4,10 +4,7 @@ import { describe, expect, it } from "vitest"; import { createBuiltInChannelManifestRegistry } from "../channels"; -import { FAKE_TELEGRAM_HOOK_REGISTRATIONS } from "../channels/telegram/hooks/fakes"; -import { FAKE_WECHAT_HOOK_REGISTRATIONS } from "../channels/wechat/hooks/fakes"; -import { MessagingHookRegistry } from "../hooks"; -import { FAKE_COMMON_HOOK_REGISTRATIONS } from "../hooks/common"; +import { createBuiltInMessagingHookRegistry, MessagingHookRegistry } from "../hooks"; import { ChannelManifestRegistry, type ChannelManifest, @@ -16,15 +13,57 @@ import { import { ManifestCompiler } from "./manifest-compiler"; const ALL_CHANNELS = ["telegram", "discord", "wechat", "slack", "whatsapp"] as const; +const TEST_CREDENTIALS: Readonly> = { + TELEGRAM_BOT_TOKEN: "123456:test-telegram-token", + DISCORD_BOT_TOKEN: "test-discord-token", + WECHAT_BOT_TOKEN: "test-wechat-token", + SLACK_BOT_TOKEN: "xoxb-test-slack-token", + SLACK_APP_TOKEN: "xapp-test-slack-token", +}; +const TEST_WECHAT_LOGIN = { + token: "test-wechat-token", + accountId: "test-wechat-account", + baseUrl: "https://ilinkai.wechat.example", + userId: "test-wechat-user", +} as const; function compiler(): ManifestCompiler { return new ManifestCompiler( createBuiltInChannelManifestRegistry(), - new MessagingHookRegistry([ - ...FAKE_COMMON_HOOK_REGISTRATIONS, - ...FAKE_TELEGRAM_HOOK_REGISTRATIONS, - ...FAKE_WECHAT_HOOK_REGISTRATIONS, - ]), + createBuiltInMessagingHookRegistry({ + common: { + env: {}, + getCredential: (key) => TEST_CREDENTIALS[key] ?? null, + saveCredential: () => {}, + prompt: async () => "unused", + log: () => {}, + }, + telegram: { + fetch: async () => ({ + ok: true, + status: 200, + async json() { + return { ok: true }; + }, + async text() { + return ""; + }, + }), + }, + wechat: { + ilinkLogin: { + env: {}, + saveCredential: () => {}, + runLogin: async () => ({ + kind: "ok", + credentials: TEST_WECHAT_LOGIN, + }), + }, + seedOpenClawAccount: { + now: () => "2026-01-01T00:00:00.000Z", + }, + }, + }), ); } @@ -248,7 +287,7 @@ describe("ManifestCompiler", () => { }); expect(wechat?.inputs.find((input) => input.inputId === "accountId")).toMatchObject({ kind: "config", - value: "fake-wechat-account", + value: "test-wechat-account", }); expect(wechat?.inputs.find((input) => input.inputId === "baseUrl")).toMatchObject({ kind: "config", @@ -264,6 +303,10 @@ describe("ManifestCompiler", () => { throw new Error("token-paste hook should not run"); }, }, + { + id: "telegram.getMeReachability", + handler: () => ({}), + }, ]); const plan = await new ManifestCompiler( createBuiltInChannelManifestRegistry(), diff --git a/src/lib/messaging/compiler/workflow-planner.test.ts b/src/lib/messaging/compiler/workflow-planner.test.ts index 3fb501ad59..1a23195ab1 100644 --- a/src/lib/messaging/compiler/workflow-planner.test.ts +++ b/src/lib/messaging/compiler/workflow-planner.test.ts @@ -4,20 +4,60 @@ import { describe, expect, it } from "vitest"; import { createBuiltInChannelManifestRegistry } from "../channels"; -import { FAKE_TELEGRAM_HOOK_REGISTRATIONS } from "../channels/telegram/hooks/fakes"; -import { FAKE_WECHAT_HOOK_REGISTRATIONS } from "../channels/wechat/hooks/fakes"; -import { MessagingHookRegistry } from "../hooks"; -import { FAKE_COMMON_HOOK_REGISTRATIONS } from "../hooks/common"; +import { createBuiltInMessagingHookRegistry, MessagingHookRegistry } from "../hooks"; import { MessagingWorkflowPlanner } from "./workflow-planner"; +const TEST_CREDENTIALS: Readonly> = { + TELEGRAM_BOT_TOKEN: "123456:test-telegram-token", + DISCORD_BOT_TOKEN: "test-discord-token", + WECHAT_BOT_TOKEN: "test-wechat-token", + SLACK_BOT_TOKEN: "xoxb-test-slack-token", + SLACK_APP_TOKEN: "xapp-test-slack-token", +}; +const TEST_WECHAT_LOGIN = { + token: "test-wechat-token", + accountId: "test-wechat-account", + baseUrl: "https://ilinkai.wechat.example", + userId: "test-wechat-user", +} as const; + function planner(): MessagingWorkflowPlanner { return new MessagingWorkflowPlanner( createBuiltInChannelManifestRegistry(), - new MessagingHookRegistry([ - ...FAKE_COMMON_HOOK_REGISTRATIONS, - ...FAKE_TELEGRAM_HOOK_REGISTRATIONS, - ...FAKE_WECHAT_HOOK_REGISTRATIONS, - ]), + createBuiltInMessagingHookRegistry({ + common: { + env: {}, + getCredential: (key) => TEST_CREDENTIALS[key] ?? null, + saveCredential: () => {}, + prompt: async () => "unused", + log: () => {}, + }, + telegram: { + fetch: async () => ({ + ok: true, + status: 200, + async json() { + return { ok: true }; + }, + async text() { + return ""; + }, + }), + }, + wechat: { + ilinkLogin: { + env: {}, + saveCredential: () => {}, + runLogin: async () => ({ + kind: "ok", + credentials: TEST_WECHAT_LOGIN, + }), + }, + seedOpenClawAccount: { + now: () => "2026-01-01T00:00:00.000Z", + }, + }, + }), ); } @@ -97,7 +137,7 @@ describe("MessagingWorkflowPlanner", () => { ?.inputs.find((input) => input.inputId === "accountId"), ).toMatchObject({ kind: "config", - value: "fake-wechat-account", + value: "test-wechat-account", }); expect(plan.networkPolicy.entries.map((entry) => entry.channelId)).toEqual([ "telegram", @@ -144,7 +184,7 @@ describe("MessagingWorkflowPlanner", () => { if (output.kind === "secret") { outputs[output.id] = { kind: "secret", - value: `fake-${context.channelId}-${output.id}`, + value: `test-${context.channelId}-${output.id}`, }; } } diff --git a/src/lib/messaging/hooks/builtins.ts b/src/lib/messaging/hooks/builtins.ts new file mode 100644 index 0000000000..0cd04cd3d7 --- /dev/null +++ b/src/lib/messaging/hooks/builtins.ts @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + createTelegramHookRegistrations, + type TelegramGetMeReachabilityHookOptions, +} from "../channels/telegram/hooks"; +import { createWechatHookRegistrations, type WechatHookOptions } from "../channels/wechat/hooks"; +import { createCommonHookRegistrations, type TokenPasteHookOptions } from "./common"; +import { MessagingHookRegistry } from "./registry"; +import type { MessagingHookRegistration } from "./types"; + +export interface BuiltInMessagingHookOptions { + readonly common?: TokenPasteHookOptions; + readonly telegram?: TelegramGetMeReachabilityHookOptions; + readonly wechat?: WechatHookOptions; +} + +export function createBuiltInMessagingHookRegistrations( + options: BuiltInMessagingHookOptions = {}, +): readonly MessagingHookRegistration[] { + return [ + ...createCommonHookRegistrations(options.common), + ...createTelegramHookRegistrations(options.telegram), + ...createWechatHookRegistrations(options.wechat), + ]; +} + +export function createBuiltInMessagingHookRegistry( + options: BuiltInMessagingHookOptions = {}, +): MessagingHookRegistry { + return new MessagingHookRegistry(createBuiltInMessagingHookRegistrations(options)); +} diff --git a/src/lib/messaging/hooks/common/token-paste.test.ts b/src/lib/messaging/hooks/common/token-paste.test.ts index a652c4c210..74971af0b8 100644 --- a/src/lib/messaging/hooks/common/token-paste.test.ts +++ b/src/lib/messaging/hooks/common/token-paste.test.ts @@ -8,12 +8,13 @@ import { runMessagingHook } from "../hook-runner"; import { MessagingHookRegistry } from "../registry"; import { COMMON_TOKEN_PASTE_HOOK_HANDLER_ID, - FAKE_COMMON_HOOK_REGISTRATIONS, + COMMON_HOOK_REGISTRATIONS, + createTokenPasteHook, } from "./token-paste"; describe("common token-paste hook implementation", () => { it("uses the shared handler id declared by token-paste channel manifests", () => { - expect(FAKE_COMMON_HOOK_REGISTRATIONS.map((registration) => registration.id)).toEqual([ + expect(COMMON_HOOK_REGISTRATIONS.map((registration) => registration.id)).toEqual([ COMMON_TOKEN_PASTE_HOOK_HANDLER_ID, ]); expect(telegramManifest.hooks[0]?.handler).toBe(COMMON_TOKEN_PASTE_HOOK_HANDLER_ID); @@ -21,7 +22,17 @@ describe("common token-paste hook implementation", () => { }); it("shows the single-token enrollment output shape", async () => { - const registry = new MessagingHookRegistry(FAKE_COMMON_HOOK_REGISTRATIONS); + const registry = new MessagingHookRegistry([ + { + id: COMMON_TOKEN_PASTE_HOOK_HANDLER_ID, + handler: createTokenPasteHook({ + env: {}, + getCredential: () => "123456:test-telegram-token", + saveCredential: () => {}, + log: () => {}, + }), + }, + ]); const hook = telegramManifest.hooks[0]; if (!hook) throw new Error("missing Telegram token-paste hook"); @@ -36,14 +47,29 @@ describe("common token-paste hook implementation", () => { outputs: { botToken: { kind: "secret", - value: "fake-telegram-botToken", + value: "123456:test-telegram-token", }, }, }); }); it("shows the multi-token enrollment output shape", async () => { - const registry = new MessagingHookRegistry(FAKE_COMMON_HOOK_REGISTRATIONS); + const registry = new MessagingHookRegistry([ + { + id: COMMON_TOKEN_PASTE_HOOK_HANDLER_ID, + handler: createTokenPasteHook({ + env: {}, + getCredential: (key) => + key === "SLACK_BOT_TOKEN" + ? "xoxb-test-slack-token" + : key === "SLACK_APP_TOKEN" + ? "xapp-test-slack-token" + : null, + saveCredential: () => {}, + log: () => {}, + }), + }, + ]); const hook = slackManifest.hooks[0]; if (!hook) throw new Error("missing Slack token-paste hook"); @@ -58,13 +84,93 @@ describe("common token-paste hook implementation", () => { outputs: { botToken: { kind: "secret", - value: "fake-slack-botToken", + value: "xoxb-test-slack-token", }, appToken: { kind: "secret", - value: "fake-slack-appToken", + value: "xapp-test-slack-token", }, }, }); }); + + it("prompts only for missing token outputs and stages them for provider upsert", async () => { + const env: NodeJS.ProcessEnv = { + SLACK_BOT_TOKEN: "xoxb-existing", + }; + const prompts: Array<{ readonly question: string; readonly secret: boolean }> = []; + const saved: Array<{ readonly key: string; readonly value: string }> = []; + const registry = new MessagingHookRegistry([ + { + id: COMMON_TOKEN_PASTE_HOOK_HANDLER_ID, + handler: createTokenPasteHook({ + env, + getCredential: () => null, + saveCredential: (key, value) => saved.push({ key, value }), + log: () => {}, + prompt: async (question, options) => { + prompts.push({ question, secret: options?.secret === true }); + return "xapp-prompted"; + }, + }), + }, + ]); + const hook = slackManifest.hooks[0]; + + if (!hook) throw new Error("missing Slack token-paste hook"); + + await expect( + runMessagingHook(hook, registry, { + channelId: "slack", + }), + ).resolves.toMatchObject({ + outputs: { + botToken: { + kind: "secret", + value: "xoxb-existing", + }, + appToken: { + kind: "secret", + value: "xapp-prompted", + }, + }, + }); + expect(prompts).toEqual([ + { + question: " Slack App Token (Socket Mode): ", + secret: true, + }, + ]); + expect(saved).toEqual([ + { key: "SLACK_BOT_TOKEN", value: "xoxb-existing" }, + { key: "SLACK_APP_TOKEN", value: "xapp-prompted" }, + ]); + expect(env.SLACK_APP_TOKEN).toBe("xapp-prompted"); + }); + + it("rejects invalid pasted token formats before staging credentials", async () => { + const saved: Array<{ readonly key: string; readonly value: string }> = []; + const registry = new MessagingHookRegistry([ + { + id: COMMON_TOKEN_PASTE_HOOK_HANDLER_ID, + handler: createTokenPasteHook({ + env: {}, + getCredential: () => null, + saveCredential: (key, value) => saved.push({ key, value }), + log: () => {}, + prompt: async () => "not-a-slack-token", + }), + }, + ]); + const hook = slackManifest.hooks[0]; + + if (!hook) throw new Error("missing Slack token-paste hook"); + + await expect( + runMessagingHook(hook, registry, { + channelId: "slack", + }), + ).rejects.toThrow("Invalid token format for SLACK_BOT_TOKEN"); + expect(saved).toEqual([]); + }); }); diff --git a/src/lib/messaging/hooks/common/token-paste.ts b/src/lib/messaging/hooks/common/token-paste.ts index 58cfbb23a2..e3a5783fdb 100644 --- a/src/lib/messaging/hooks/common/token-paste.ts +++ b/src/lib/messaging/hooks/common/token-paste.ts @@ -6,26 +6,136 @@ import type { MessagingHookOutputMap, MessagingHookRegistration, } from "../types"; +import { + getCredential, + normalizeCredentialValue, + prompt as promptCredential, + saveCredential, +} from "../../../credentials/store"; +import { getChannelDef } from "../../../sandbox/channels"; +import type { ChannelHookOutputSpec } from "../../manifest"; export const COMMON_TOKEN_PASTE_HOOK_HANDLER_ID = "common.tokenPaste"; -export const fakeTokenPasteHook: MessagingHookHandler = (context) => { - const outputs: Record = {}; +export interface TokenPasteField { + readonly envKey: string; + readonly label: string; + readonly help?: string; + readonly format?: RegExp; + readonly formatHint?: string; +} - for (const output of context.outputDeclarations ?? []) { - if (output.kind !== "secret") continue; - outputs[output.id] = { - kind: "secret", - value: `fake-${context.channelId}-${output.id}`, +export interface TokenPasteHookOptions { + readonly env?: NodeJS.ProcessEnv; + readonly getCredential?: (key: string) => string | null; + readonly saveCredential?: (key: string, value: string) => void; + readonly prompt?: (question: string, options?: { readonly secret?: boolean }) => Promise; + readonly log?: (message: string) => void; + readonly resolveField?: ( + channelId: string, + output: ChannelHookOutputSpec, + ) => TokenPasteField | null; +} + +export function createTokenPasteHook(options: TokenPasteHookOptions = {}): MessagingHookHandler { + return async (context) => { + const outputs: Record = {}; + + for (const output of context.outputDeclarations ?? []) { + if (output.kind !== "secret") continue; + const field = resolveTokenPasteField(context.channelId, output, options); + if (!field) { + throw new Error( + `No token-paste field registered for ${context.channelId}.${output.id}`, + ); + } + const token = await resolveTokenValue(field, options); + outputs[output.id] = { + kind: "secret", + value: token, + }; + } + + return { outputs }; + }; +} + +export function createCommonHookRegistrations( + options: TokenPasteHookOptions = {}, +): readonly MessagingHookRegistration[] { + return [ + { + id: COMMON_TOKEN_PASTE_HOOK_HANDLER_ID, + handler: createTokenPasteHook(options), + }, + ] as const; +} + +async function resolveTokenValue( + field: TokenPasteField, + options: TokenPasteHookOptions, +): Promise { + const env = options.env ?? process.env; + const readCredential = + options.getCredential ?? + ((key: string) => normalizeCredentialValue(env[key]) || getCredential(key)); + const writeCredential = options.saveCredential ?? saveCredential; + const prompt = options.prompt ?? promptCredential; + const log = options.log ?? ((message: string) => console.log(message)); + + let token = normalizeCredentialValue(env[field.envKey]) || readCredential(field.envKey); + if (!token) { + if (field.help) log(` ${field.help}`); + token = normalizeCredentialValue(await prompt(` ${field.label}: `, { secret: true })); + } + if (!token) { + throw new Error(`No token entered for ${field.envKey}.`); + } + if (field.format && !field.format.test(token)) { + throw new Error( + `Invalid token format for ${field.envKey}. ${ + field.formatHint || "Check the token and try again." + }`, + ); + } + + writeCredential(field.envKey, token); + env[field.envKey] = token; + return token; +} + +function resolveTokenPasteField( + channelId: string, + output: ChannelHookOutputSpec, + options: TokenPasteHookOptions, +): TokenPasteField | null { + const custom = options.resolveField?.(channelId, output); + if (custom) return custom; + + const channel = getChannelDef(channelId); + if (!channel) return null; + if (output.id === "botToken" && "envKey" in channel && channel.envKey) { + return { + envKey: channel.envKey, + label: channel.label, + help: channel.help, + format: channel.tokenFormat, + formatHint: channel.tokenFormatHint, + }; + } + if (output.id === "appToken" && "appTokenEnvKey" in channel && channel.appTokenEnvKey) { + return { + envKey: channel.appTokenEnvKey, + label: channel.appTokenLabel ?? `${channel.label} App Token`, + help: channel.appTokenHelp, + format: channel.appTokenFormat, + formatHint: channel.appTokenFormatHint, }; } + return null; +} - return { outputs }; -}; +export const tokenPasteHook = createTokenPasteHook(); -export const FAKE_COMMON_HOOK_REGISTRATIONS: readonly MessagingHookRegistration[] = [ - { - id: COMMON_TOKEN_PASTE_HOOK_HANDLER_ID, - handler: fakeTokenPasteHook, - }, -] as const; +export const COMMON_HOOK_REGISTRATIONS: readonly MessagingHookRegistration[] = + createCommonHookRegistrations(); diff --git a/src/lib/messaging/hooks/hook-runner.test.ts b/src/lib/messaging/hooks/hook-runner.test.ts index 1fd22ec86e..34253ab9d6 100644 --- a/src/lib/messaging/hooks/hook-runner.test.ts +++ b/src/lib/messaging/hooks/hook-runner.test.ts @@ -4,7 +4,11 @@ import { describe, expect, it } from "vitest"; import type { ChannelHookSpec } from "../manifest"; -import { MessagingHookRegistry, runMessagingHook } from "./index"; +import { + createBuiltInMessagingHookRegistry, + MessagingHookRegistry, + runMessagingHook, +} from "./index"; const HOST_QR_HOOK = { id: "wechat-host-qr", @@ -25,7 +29,44 @@ const HOST_QR_HOOK = { } as const satisfies ChannelHookSpec; describe("MessagingHookRegistry", () => { - it("registers fake handlers by stable handler id", async () => { + it("constructs the production built-in hook registry", () => { + const registry = createBuiltInMessagingHookRegistry({ + common: { + prompt: async () => "unused", + }, + telegram: { + fetch: async () => ({ + ok: true, + status: 200, + async json() { + return { ok: true }; + }, + async text() { + return ""; + }, + }), + }, + wechat: { + ilinkLogin: { + runLogin: async () => ({ + kind: "timeout", + }), + }, + seedOpenClawAccount: { + now: () => "2026-01-01T00:00:00.000Z", + }, + }, + }); + + expect(registry.listIds()).toEqual([ + "common.tokenPaste", + "telegram.getMeReachability", + "wechat.ilinkLogin", + "wechat.seedOpenClawAccount", + ]); + }); + + it("registers handlers by stable handler id", async () => { const registry = new MessagingHookRegistry([ { id: "wechat.ilinkLogin", @@ -65,7 +106,7 @@ describe("MessagingHookRegistry", () => { }); }); - it("passes hook metadata, phase, and serializable inputs to fake handlers", async () => { + it("passes hook metadata, phase, and serializable inputs to registered handlers", async () => { const calls: unknown[] = []; const hook = { id: "wechat-seed-openclaw-account", diff --git a/src/lib/messaging/hooks/index.ts b/src/lib/messaging/hooks/index.ts index 70def342e3..a90e7d2d8a 100644 --- a/src/lib/messaging/hooks/index.ts +++ b/src/lib/messaging/hooks/index.ts @@ -4,4 +4,5 @@ export * from "./hook-runner"; export * from "./registry"; export * from "./common"; +export * from "./builtins"; export type * from "./types"; From 4e783550018e4fc1392413072cc2c2c4161afdab Mon Sep 17 00:00:00 2001 From: San Dang Date: Mon, 25 May 2026 18:58:19 +0700 Subject: [PATCH 09/20] refactor(messaging): move policy keys into manifests --- src/lib/messaging/channels/manifests.test.ts | 8 +++- .../messaging/channels/telegram/manifest.ts | 2 +- src/lib/messaging/channels/wechat/manifest.ts | 2 +- .../compiler/engines/policy-resolver.ts | 42 +++++++------------ .../compiler/manifest-compiler.test.ts | 14 +++---- src/lib/messaging/manifest/types.test.ts | 2 +- src/lib/messaging/manifest/types.ts | 16 +++++-- 7 files changed, 45 insertions(+), 41 deletions(-) diff --git a/src/lib/messaging/channels/manifests.test.ts b/src/lib/messaging/channels/manifests.test.ts index e13c93b631..56d61b6a8c 100644 --- a/src/lib/messaging/channels/manifests.test.ts +++ b/src/lib/messaging/channels/manifests.test.ts @@ -36,6 +36,12 @@ function renderJson(manifest: ChannelManifest): string { return JSON.stringify(manifest.render); } +function policyPresetNames(manifest: ChannelManifest): string[] { + return (manifest.policyPresets ?? []).map((preset) => + typeof preset === "string" ? preset : preset.name, + ); +} + function expectTokenPasteEnrollHook(manifest: ChannelManifest, outputIds: readonly string[]): void { expect(manifest.hooks).toContainEqual({ id: `${manifest.id}-token-paste`, @@ -86,7 +92,7 @@ describe("built-in channel manifests", () => { for (const [channelId, manifest] of Object.entries(manifests)) { const legacy = KNOWN_CHANNELS[channelId]; expect(manifest.description).toBe(legacy.description); - expect(manifest.policyPresets).toEqual([channelId]); + expect(policyPresetNames(manifest)).toEqual([channelId]); expect(manifest.supportedAgents).toEqual(["openclaw", "hermes"]); expect(manifest.auth.mode).toBe(legacy.loginMethod ?? "token-paste"); } diff --git a/src/lib/messaging/channels/telegram/manifest.ts b/src/lib/messaging/channels/telegram/manifest.ts index 0aa9906acd..e5faf9dffc 100644 --- a/src/lib/messaging/channels/telegram/manifest.ts +++ b/src/lib/messaging/channels/telegram/manifest.ts @@ -56,7 +56,7 @@ export const telegramManifest = { placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", }, ], - policyPresets: ["telegram"], + policyPresets: [{ name: "telegram", policyKeys: ["telegram_bot"] }], render: [ { id: "telegram-openclaw-account", diff --git a/src/lib/messaging/channels/wechat/manifest.ts b/src/lib/messaging/channels/wechat/manifest.ts index 249dc9423f..f728331cf2 100644 --- a/src/lib/messaging/channels/wechat/manifest.ts +++ b/src/lib/messaging/channels/wechat/manifest.ts @@ -65,7 +65,7 @@ export const wechatManifest = { placeholder: "openshell:resolve:env:WECHAT_BOT_TOKEN", }, ], - policyPresets: ["wechat"], + policyPresets: [{ name: "wechat", policyKeys: ["wechat_bridge"] }], render: [ { id: "wechat-hermes-env", diff --git a/src/lib/messaging/compiler/engines/policy-resolver.ts b/src/lib/messaging/compiler/engines/policy-resolver.ts index 4f178fb319..63f3bf48d1 100644 --- a/src/lib/messaging/compiler/engines/policy-resolver.ts +++ b/src/lib/messaging/compiler/engines/policy-resolver.ts @@ -3,29 +3,13 @@ import type { ChannelManifest, - MessagingAgentId, + ChannelPolicyPresetReference, + ChannelPolicyPresetSpec, SandboxMessagingNetworkPolicyEntryPlan, SandboxMessagingNetworkPolicyPlan, } from "../../manifest"; import type { ManifestCompilerContext } from "../types"; -const BUILTIN_POLICY_KEYS: Readonly> = { - telegram: ["telegram_bot"], - discord: ["discord"], - slack: ["slack"], - wechat: ["wechat_bridge"], - whatsapp: ["whatsapp"], -}; - -const AGENT_POLICY_KEY_ALIASES: Readonly< - Record>> -> = { - openclaw: {}, - hermes: { - wechat: ["wechat_bridge"], - }, -}; - export function planNetworkPolicy( manifests: readonly ChannelManifest[], context: ManifestCompilerContext, @@ -41,27 +25,31 @@ function planManifestPolicyEntries( manifest: ChannelManifest, context: ManifestCompilerContext, ): SandboxMessagingNetworkPolicyEntryPlan[] { - return (manifest.policyPresets ?? []).map((presetName) => { - const agentAlias = AGENT_POLICY_KEY_ALIASES[context.agent][presetName]; - if (agentAlias) { + return (manifest.policyPresets ?? []).map((preset) => { + const policy = normalizePolicyPreset(preset); + const agentPolicyKeys = policy.agentPolicyKeys?.[context.agent]; + if (agentPolicyKeys) { return { channelId: manifest.id, - presetName, - policyKeys: agentAlias, + presetName: policy.name, + policyKeys: agentPolicyKeys, source: "agent-alias", }; } - const builtinKeys = BUILTIN_POLICY_KEYS[presetName]; return { channelId: manifest.id, - presetName, - policyKeys: builtinKeys ?? [presetName], - source: builtinKeys ? "builtin" : "manifest", + presetName: policy.name, + policyKeys: policy.policyKeys ?? [policy.name], + source: "manifest", }; }); } +function normalizePolicyPreset(preset: ChannelPolicyPresetReference): ChannelPolicyPresetSpec { + return typeof preset === "string" ? { name: preset } : preset; +} + function unique(values: readonly string[]): string[] { 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 48d4e67178..a49f01ac04 100644 --- a/src/lib/messaging/compiler/manifest-compiler.test.ts +++ b/src/lib/messaging/compiler/manifest-compiler.test.ts @@ -149,31 +149,31 @@ describe("ManifestCompiler", () => { channelId: "telegram", presetName: "telegram", policyKeys: ["telegram_bot"], - source: "builtin", + source: "manifest", }, { channelId: "discord", presetName: "discord", policyKeys: ["discord"], - source: "builtin", + source: "manifest", }, { channelId: "wechat", presetName: "wechat", policyKeys: ["wechat_bridge"], - source: "builtin", + source: "manifest", }, { channelId: "slack", presetName: "slack", policyKeys: ["slack"], - source: "builtin", + source: "manifest", }, { channelId: "whatsapp", presetName: "whatsapp", policyKeys: ["whatsapp"], - source: "builtin", + source: "manifest", }, ]); expect(plan.agentRender.map((render) => `${render.channelId}:${render.kind}`)).toEqual([ @@ -230,7 +230,7 @@ describe("ManifestCompiler", () => { ).toEqual(expect.arrayContaining(["proxyUrl", "allowedIds.telegram.values"])); }); - it("compiles Hermes render and WeChat agent policy alias intent", async () => { + it("compiles Hermes render and manifest-owned WeChat policy intent", async () => { const plan = await compiler().compile({ sandboxName: "demo", agent: "hermes", @@ -243,7 +243,7 @@ describe("ManifestCompiler", () => { channelId: "wechat", presetName: "wechat", policyKeys: ["wechat_bridge"], - source: "agent-alias", + source: "manifest", }); expect(plan.agentRender.map((render) => `${render.channelId}:${render.target}`)).toEqual([ "telegram:~/.hermes/.env", diff --git a/src/lib/messaging/manifest/types.test.ts b/src/lib/messaging/manifest/types.test.ts index 820bf742f6..c616b6571b 100644 --- a/src/lib/messaging/manifest/types.test.ts +++ b/src/lib/messaging/manifest/types.test.ts @@ -235,7 +235,7 @@ const telegramPlan = { channelId: "telegram", presetName: "telegram", policyKeys: ["telegram_bot"], - source: "builtin", + source: "manifest", }, ], }, diff --git a/src/lib/messaging/manifest/types.ts b/src/lib/messaging/manifest/types.ts index 5d27160856..684300f16e 100644 --- a/src/lib/messaging/manifest/types.ts +++ b/src/lib/messaging/manifest/types.ts @@ -37,13 +37,23 @@ export interface ChannelManifest { readonly auth: ChannelAuthSpec; readonly inputs: readonly ChannelInputSpec[]; readonly credentials: readonly ChannelCredentialSpec[]; - /** Built-in policy presets needed when this channel is active. */ - readonly policyPresets?: readonly string[]; + /** Policy presets needed when this channel is active. */ + readonly policyPresets?: readonly ChannelPolicyPresetReference[]; readonly render: readonly ChannelRenderSpec[]; readonly state: ChannelStateSpec; readonly hooks: readonly ChannelHookSpec[]; } +/** Manifest-owned network policy preset metadata. */ +export type ChannelPolicyPresetReference = string | ChannelPolicyPresetSpec; + +/** Concrete network policy keys may differ from the operator-facing preset name. */ +export interface ChannelPolicyPresetSpec { + readonly name: string; + readonly policyKeys?: readonly string[]; + readonly agentPolicyKeys?: Partial>; +} + /** How a channel obtains credential or session material. */ export type ChannelAuthMode = "none" | "token-paste" | "host-qr" | "in-sandbox-qr"; @@ -234,7 +244,7 @@ export interface SandboxMessagingNetworkPolicyEntryPlan { readonly channelId: MessagingChannelId; readonly presetName: string; readonly policyKeys: readonly string[]; - readonly source: "builtin" | "agent-alias" | "manifest"; + readonly source: "agent-alias" | "manifest"; } /** Compiled render output for supported target formats. */ From 4b0b67fd12c6b5284989d4de1fc3a4b99c3ed544 Mon Sep 17 00:00:00 2001 From: San Dang Date: Mon, 25 May 2026 19:24:34 +0700 Subject: [PATCH 10/20] fix(messaging): include policy keys in applier context --- src/lib/messaging/applier/policy.ts | 13 ++++- .../messaging/applier/setup-applier.test.ts | 47 +++++++++++++++++-- src/lib/messaging/applier/types.ts | 14 +++++- 3 files changed, 69 insertions(+), 5 deletions(-) diff --git a/src/lib/messaging/applier/policy.ts b/src/lib/messaging/applier/policy.ts index 31460845d5..33a91a1389 100644 --- a/src/lib/messaging/applier/policy.ts +++ b/src/lib/messaging/applier/policy.ts @@ -9,12 +9,23 @@ export function applyPolicyAtOpenShell( options: MessagingPolicyApplyOptions, ): MessagingPolicyApplyResult { const activePresets = uniqueStrings(plan.networkPolicy.presets); - if (activePresets.length > 0 && !options.applyPresets(plan.sandboxName, activePresets)) { + const activePolicyKeys = uniqueStrings( + plan.networkPolicy.entries.flatMap((entry) => entry.policyKeys), + ); + if ( + activePresets.length > 0 && + !options.applyPresets(plan.sandboxName, activePresets, { + agent: plan.agent, + entries: plan.networkPolicy.entries, + policyKeys: activePolicyKeys, + }) + ) { throw new Error(`Failed to apply messaging policy preset(s): ${activePresets.join(", ")}`); } return { appliedPresets: activePresets, + appliedPolicyKeys: activePolicyKeys, }; } diff --git a/src/lib/messaging/applier/setup-applier.test.ts b/src/lib/messaging/applier/setup-applier.test.ts index d6f55ef385..f1293872dc 100644 --- a/src/lib/messaging/applier/setup-applier.test.ts +++ b/src/lib/messaging/applier/setup-applier.test.ts @@ -7,9 +7,13 @@ import { createBuiltInChannelManifestRegistry } from "../channels"; import { MessagingWorkflowPlanner } from "../compiler"; import { createBuiltInMessagingHookRegistry, runMessagingHook } from "../hooks"; import type { ChannelHookSpec } from "../manifest"; -import type { SandboxMessagingPlan } from "../manifest"; +import type { MessagingAgentId, SandboxMessagingPlan } from "../manifest"; import { MessagingSetupApplier } from "./setup-applier"; -import { MESSAGING_SETUP_APPLIER_ENV_KEY, type MessagingOpenShellRunner } from "./types"; +import { + MESSAGING_SETUP_APPLIER_ENV_KEY, + type MessagingOpenShellRunner, + type MessagingPolicyApplyContext, +} from "./types"; const TEST_CREDENTIALS: Readonly> = { TELEGRAM_BOT_TOKEN: "123456:test-telegram-token", @@ -88,11 +92,12 @@ function planner(): MessagingWorkflowPlanner { async function planOnboard( env: Readonly>, selectedChannels: readonly string[], + agent: MessagingAgentId = "openclaw", ): Promise { return withEnv(env, () => planner().planOnboard({ sandboxName: "demo", - agent: "openclaw", + agent, isInteractive: false, selectedChannels, }), @@ -507,6 +512,42 @@ describe("MessagingSetupApplier", () => { expect(policyCalls).toEqual([["demo", "telegram"]]); expect(result).toEqual({ appliedPresets: ["telegram"], + appliedPolicyKeys: ["telegram_bot"], + }); + }); + + it("passes concrete policy keys for agent-aware preset application", async () => { + const plan = await planOnboard( + { + DISCORD_BOT_TOKEN: "test-discord-token", + WECHAT_BOT_TOKEN: "test-wechat-token", + WECHAT_ACCOUNT_ID: "wechat-account", + SLACK_BOT_TOKEN: "xoxb-slack-token", + SLACK_APP_TOKEN: "xapp-slack-token", + }, + ["discord", "wechat", "slack"], + "hermes", + ); + const policyCalls: string[][] = []; + let applyContext: MessagingPolicyApplyContext | null = null; + + const result = MessagingSetupApplier.applyPolicyAtOpenShell(plan, { + applyPresets: (sandboxName, presetNames, context) => { + policyCalls.push([sandboxName, ...presetNames]); + applyContext = context; + return true; + }, + }); + + expect(policyCalls).toEqual([["demo", "discord", "wechat", "slack"]]); + expect(applyContext).toEqual({ + agent: "hermes", + entries: plan.networkPolicy.entries, + policyKeys: ["discord", "wechat_bridge", "slack"], + }); + expect(result).toEqual({ + appliedPresets: ["discord", "wechat", "slack"], + appliedPolicyKeys: ["discord", "wechat_bridge", "slack"], }); }); }); diff --git a/src/lib/messaging/applier/types.ts b/src/lib/messaging/applier/types.ts index 96847394e0..ff62fdb177 100644 --- a/src/lib/messaging/applier/types.ts +++ b/src/lib/messaging/applier/types.ts @@ -7,6 +7,7 @@ import type { ChannelHookPhase, MessagingAgentId, MessagingChannelId, + SandboxMessagingNetworkPolicyEntryPlan, SandboxMessagingHookReferencePlan, SandboxMessagingPlan, } from "../manifest"; @@ -86,12 +87,23 @@ export interface MessagingCredentialApplyResult { readonly sandboxCreateProviderArgs: readonly string[]; } +export interface MessagingPolicyApplyContext { + readonly agent: MessagingAgentId; + readonly entries: readonly SandboxMessagingNetworkPolicyEntryPlan[]; + readonly policyKeys: readonly string[]; +} + export interface MessagingPolicyApplyOptions { - readonly applyPresets: (sandboxName: string, presetNames: string[]) => boolean; + readonly applyPresets: ( + sandboxName: string, + presetNames: string[], + context: MessagingPolicyApplyContext, + ) => boolean; } export interface MessagingPolicyApplyResult { readonly appliedPresets: readonly string[]; + readonly appliedPolicyKeys: readonly string[]; } export type MessagingSerializablePlan = SandboxMessagingPlan; From 7e6464d4e149e5fa5b5e467e220f3aef13397d45 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Mon, 25 May 2026 09:19:27 -0700 Subject: [PATCH 11/20] fix(messaging): harden channel enrollment and build paths --- src/lib/messaging/applier/agent-config.ts | 54 ++++++++++++++--- .../messaging/applier/setup-applier.test.ts | 58 +++++++++++++++++++ .../wechat/hooks/implementations.test.ts | 9 +++ .../wechat/hooks/seed-openclaw-account.ts | 12 ++++ .../compiler/manifest-compiler.test.ts | 43 ++++++++++++++ .../messaging/compiler/manifest-compiler.ts | 52 +++++++++-------- 6 files changed, 194 insertions(+), 34 deletions(-) diff --git a/src/lib/messaging/applier/agent-config.ts b/src/lib/messaging/applier/agent-config.ts index dc74e22172..00c5c5b44b 100644 --- a/src/lib/messaging/applier/agent-config.ts +++ b/src/lib/messaging/applier/agent-config.ts @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { posix as path } from "node:path"; + import YAML from "yaml"; import { redact } from "../../security/redact"; @@ -240,6 +242,7 @@ function setJsonPath( } let cursor: Record = root; for (const segment of segments.slice(0, -1)) { + assertSafeObjectKey(segment, "Messaging render path"); const next = cursor[segment]; if (!isObject(next)) { const created: Record = {}; @@ -249,7 +252,9 @@ function setJsonPath( cursor = next as Record; } } - cursor[segments[segments.length - 1] as string] = value; + const finalSegment = segments[segments.length - 1] as string; + assertSafeObjectKey(finalSegment, "Messaging render path"); + cursor[finalSegment] = value; } function applyEnvLines( @@ -405,18 +410,43 @@ function serializeHookBuildFileContent( return `${JSON.stringify(content, null, 2)}\n`; } -function resolveHookBuildFileTarget(path: string, agent: MessagingAgentId): string { - if (path.startsWith("/")) return path; - if (path === "openclaw.json") return resolveSandboxAgentConfigTarget(path, "openclaw"); - if (path === "config.yaml" && agent === "hermes") { +function resolveHookBuildFileTarget(filePath: string, agent: MessagingAgentId): string { + const normalizedPath = normalizeRelativeBuildFilePath(filePath); + if (normalizedPath === "openclaw.json") { + return resolveSandboxAgentConfigTarget(normalizedPath, "openclaw"); + } + if (normalizedPath === "config.yaml" && agent === "hermes") { return resolveSandboxAgentConfigTarget("~/.hermes/config.yaml", agent); } - if (path === ".env" && agent === "hermes") { + if (normalizedPath === ".env" && agent === "hermes") { return resolveSandboxAgentConfigTarget("~/.hermes/.env", agent); } - if (agent === "openclaw") return `/sandbox/.openclaw/${path}`; - if (agent === "hermes") return `/sandbox/.hermes/${path}`; - throw new Error(`Cannot resolve messaging build-file target '${path}' for ${agent}.`); + if (agent === "openclaw") return `/sandbox/.openclaw/${normalizedPath}`; + if (agent === "hermes") return `/sandbox/.hermes/${normalizedPath}`; + throw new Error(`Cannot resolve messaging build-file target '${filePath}' for ${agent}.`); +} + +function normalizeRelativeBuildFilePath(filePath: string): string { + if (filePath.trim().length === 0) { + throw new Error("Messaging build-file path must not be empty."); + } + if (filePath.startsWith("/") || filePath.includes("\\") || filePath.includes("\0")) { + throw new Error(`Messaging build-file path '${filePath}' must be a safe relative path.`); + } + const segments = filePath.split("/"); + if (segments.some((segment) => segment === "..")) { + throw new Error(`Messaging build-file path '${filePath}' must not traverse directories.`); + } + const normalizedPath = path.normalize(filePath); + if ( + normalizedPath === "." || + normalizedPath === ".." || + normalizedPath.startsWith("../") || + normalizedPath.startsWith("/") + ) { + throw new Error(`Messaging build-file path '${filePath}' must stay inside agent config.`); + } + return normalizedPath; } function resolveSandboxAgentConfigTarget(target: string, agent: MessagingAgentId): string { @@ -492,6 +522,12 @@ function compactOutput(result: { readonly stdout?: unknown; readonly stderr?: un return output || "OpenShell command failed."; } +function assertSafeObjectKey(key: string, context: string): void { + if (key === "__proto__" || key === "prototype" || key === "constructor") { + throw new Error(`${context} rejected unsafe object key '${key}'.`); + } +} + function isObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } diff --git a/src/lib/messaging/applier/setup-applier.test.ts b/src/lib/messaging/applier/setup-applier.test.ts index f1293872dc..fc15b1af20 100644 --- a/src/lib/messaging/applier/setup-applier.test.ts +++ b/src/lib/messaging/applier/setup-applier.test.ts @@ -496,6 +496,64 @@ describe("MessagingSetupApplier", () => { expect(({} as { polluted?: boolean }).polluted).toBeUndefined(); }); + it("rejects prototype-polluting JSON render paths", async () => { + const plan = await planOnboard({ TELEGRAM_BOT_TOKEN: "123456:telegram-token" }, [ + "telegram", + ]); + const unsafePlan = { + ...plan, + agentRender: [ + { + channelId: "telegram", + kind: "json-fragment", + agent: "openclaw", + target: "openclaw.json", + path: "__proto__.polluted", + value: true, + templateRefs: [], + }, + ], + } satisfies SandboxMessagingPlan; + const runOpenshell: MessagingOpenShellRunner = (args, options) => { + if (args.includes("cat") && options?.input === undefined) { + return { status: 0, stdout: "{}" }; + } + return { status: 0 }; + }; + + await expect( + MessagingSetupApplier.applyAgentConfigAtOpenShell(unsafePlan, { runOpenshell }), + ).rejects.toThrow("Messaging render path rejected unsafe object key '__proto__'"); + expect(({} as { polluted?: boolean }).polluted).toBeUndefined(); + }); + + it("rejects build-file hook outputs that traverse outside the agent config directory", async () => { + const plan = await planOnboard({ WECHAT_ACCOUNT_ID: "wechat-account" }, ["wechat"]); + const runOpenshell: MessagingOpenShellRunner = (args, options) => { + if (args.includes("cat") && options?.input === undefined) { + return { status: 0, stdout: "{}" }; + } + return { status: 0 }; + }; + + await expect( + MessagingSetupApplier.applyAgentConfigAtOpenShell(plan, { + runOpenshell, + runHook: () => ({ + outputs: { + openclawWeixinAccountFile: { + kind: "build-file", + value: { + path: "openclaw-weixin/accounts/../../openclaw.json", + content: {}, + }, + }, + }, + }), + }), + ).rejects.toThrow("must not traverse directories"); + }); + it("applies policy presets directly from the serializable plan", async () => { const plan = await planOnboard({ TELEGRAM_BOT_TOKEN: "123456:telegram-token" }, [ "telegram", diff --git a/src/lib/messaging/channels/wechat/hooks/implementations.test.ts b/src/lib/messaging/channels/wechat/hooks/implementations.test.ts index d2ba41becd..12a6fe5b49 100644 --- a/src/lib/messaging/channels/wechat/hooks/implementations.test.ts +++ b/src/lib/messaging/channels/wechat/hooks/implementations.test.ts @@ -7,6 +7,7 @@ import { MessagingHookRegistry, runMessagingHook } from "../../../hooks"; import { wechatManifest } from "../manifest"; import { createWechatIlinkLoginHook, WECHAT_ILINK_LOGIN_HOOK_ID } from "./ilink-login"; import { + buildWechatSeedOpenClawAccountOutputs, createWechatSeedOpenClawAccountHook, WECHAT_PLUGIN_SPEC, WECHAT_SEED_OPENCLAW_ACCOUNT_HOOK_ID, @@ -98,6 +99,14 @@ describe("WeChat hook implementations", () => { expect(env.WECHAT_BOT_TOKEN).toBeUndefined(); }); + it("rejects unsafe WeChat account ids before using them as build-file names", () => { + expect(() => + buildWechatSeedOpenClawAccountOutputs({ + "wechatConfig.accountId": "../../openclaw", + }), + ).toThrow("unsafe filename characters"); + }); + it("generates OpenClaw account seed build-file outputs from captured metadata", async () => { const registry = new MessagingHookRegistry([ { diff --git a/src/lib/messaging/channels/wechat/hooks/seed-openclaw-account.ts b/src/lib/messaging/channels/wechat/hooks/seed-openclaw-account.ts index 0bc0beb9d5..88abb55cb2 100644 --- a/src/lib/messaging/channels/wechat/hooks/seed-openclaw-account.ts +++ b/src/lib/messaging/channels/wechat/hooks/seed-openclaw-account.ts @@ -42,6 +42,7 @@ export function buildWechatSeedOpenClawAccountOutputs( options: WechatSeedOpenClawAccountHookOptions = {}, ): MessagingHookOutputMap { const accountId = requiredInputString(inputs, "wechatConfig.accountId"); + assertSafeWechatAccountId(accountId); const baseUrl = optionalInputString(inputs, "wechatConfig.baseUrl"); const userId = optionalInputString(inputs, "wechatConfig.userId"); const token = optionalInputString( @@ -112,6 +113,17 @@ export function buildWechatSeedOpenClawAccountOutputs( }; } +function assertSafeWechatAccountId(accountId: string): void { + if ( + accountId === "." || + accountId === ".." || + /[\\/\0-\x1F\x7F]/.test(accountId) || + accountId.includes("..") + ) { + throw new Error("WeChat account id contains unsafe filename characters."); + } +} + function requiredInputString( inputs: MessagingHookInputMap | undefined, key: string, diff --git a/src/lib/messaging/compiler/manifest-compiler.test.ts b/src/lib/messaging/compiler/manifest-compiler.test.ts index a49f01ac04..09d1aac3f5 100644 --- a/src/lib/messaging/compiler/manifest-compiler.test.ts +++ b/src/lib/messaging/compiler/manifest-compiler.test.ts @@ -295,6 +295,49 @@ describe("ManifestCompiler", () => { }); }); + it("disables a channel and suppresses side effects when enrollment opts to skip it", async () => { + const hooks = new MessagingHookRegistry([ + { + id: "common.tokenPaste", + handler: () => { + throw new Error("operator cancelled token entry"); + }, + }, + { + id: "telegram.getMeReachability", + handler: () => { + throw new Error("reachability should not run for skipped channels"); + }, + }, + ]); + const plan = await new ManifestCompiler( + createBuiltInChannelManifestRegistry(), + hooks, + ).compile({ + sandboxName: "demo", + agent: "openclaw", + workflow: "onboard", + isInteractive: true, + selectedChannels: ["telegram"], + configuredChannels: ["telegram"], + }); + + expect(plan.channels[0]).toMatchObject({ + channelId: "telegram", + active: false, + selected: true, + configured: false, + disabled: true, + }); + expect(plan.channels[0]?.hooks).toEqual([]); + expect(plan.credentialBindings).toEqual([]); + expect(plan.networkPolicy.entries).toEqual([]); + expect(plan.agentRender).toEqual([]); + expect(plan.buildSteps).toEqual([]); + expect(plan.stateUpdates).toEqual([]); + expect(plan.healthChecks).toEqual([]); + }); + it("skips token-paste and QR enrollment hooks for non-interactive create plans", async () => { const hooks = new MessagingHookRegistry([ { diff --git a/src/lib/messaging/compiler/manifest-compiler.ts b/src/lib/messaging/compiler/manifest-compiler.ts index 278318e020..2ff8cad62a 100644 --- a/src/lib/messaging/compiler/manifest-compiler.ts +++ b/src/lib/messaging/compiler/manifest-compiler.ts @@ -43,9 +43,10 @@ export class ManifestCompiler { const inputRegistry = new Map( channels.map((channel) => [channel.channelId, channel.inputs] as const), ); - const activeManifests = manifests.filter((manifest) => - isChannelActive(manifest.id, context), + const activeChannelIds = new Set( + channels.filter((channel) => channel.active).map((channel) => channel.channelId), ); + const activeManifests = manifests.filter((manifest) => activeChannelIds.has(manifest.id)); const credentialBindings = activeManifests.flatMap((manifest) => planCredentialBindings(manifest, context, inputRegistry.get(manifest.id) ?? []), ); @@ -106,7 +107,16 @@ export class ManifestCompiler { const selected = context.selectedChannels.includes(manifest.id); const configured = context.configuredChannels?.includes(manifest.id) ?? false; const disabled = context.disabledChannels?.includes(manifest.id) ?? false; - const active = !disabled && (selected || configured); + const requestedActive = !disabled && (selected || configured); + const resolvedInputs = await resolveChannelInputs(manifest, context, this.hooks, { + runEnrollment: + selected && + requestedActive && + isEnrollmentWorkflow(context.workflow) && + context.isInteractive, + runEnrollmentChecks: selected && requestedActive && isEnrollmentWorkflow(context.workflow), + }); + const active = requestedActive && !resolvedInputs.skipped; return { channelId: manifest.id, @@ -114,13 +124,9 @@ export class ManifestCompiler { authMode: manifest.auth.mode, active, selected, - configured, - disabled, - inputs: await resolveChannelInputs(manifest, context, this.hooks, { - runEnrollment: - selected && active && isEnrollmentWorkflow(context.workflow) && context.isInteractive, - runEnrollmentChecks: selected && active && isEnrollmentWorkflow(context.workflow), - }), + configured: configured && !resolvedInputs.skipped, + disabled: disabled || resolvedInputs.skipped, + inputs: resolvedInputs.inputs, hooks: active ? manifest.hooks .filter((hook) => isHookForAgent(hook, context.agent)) @@ -146,17 +152,6 @@ function isEnrollmentWorkflow(workflow: ManifestCompilerContext["workflow"]): bo return workflow === "onboard" || workflow === "add-channel"; } -function isChannelActive( - channelId: MessagingChannelId, - context: ManifestCompilerContext, -): boolean { - if (context.disabledChannels?.includes(channelId)) return false; - return ( - context.selectedChannels.includes(channelId) || - (context.configuredChannels ?? []).includes(channelId) - ); -} - function cloneHookReference( channelId: MessagingChannelId, hook: ChannelManifest["hooks"][number], @@ -178,7 +173,10 @@ async function resolveChannelInputs( context: ManifestCompilerContext, hooks: MessagingHookRegistry, options: { readonly runEnrollment: boolean; readonly runEnrollmentChecks: boolean }, -): Promise { +): Promise<{ + readonly inputs: SandboxMessagingInputReference[]; + readonly skipped: boolean; +}> { let inputs = manifest.inputs.map((input) => resolveChannelInput(manifest, input, context)); let hookInputs = buildCompilerHookInputs(manifest, inputs); inputs = applyCredentialAvailability(manifest, inputs, context); @@ -188,9 +186,13 @@ async function resolveChannelInputs( .filter((hook) => hook.phase === "enroll") : []; + let skipped = false; for (const hook of enrollmentHooks) { const result = await runCompilerHook(manifest, hook, hooks, hookInputs); - if (!result) continue; + if (!result) { + skipped = true; + break; + } hookInputs = mergeHookOutputsIntoInputs(manifest, hookInputs, result.outputs); inputs = applyCredentialAvailability( manifest, @@ -199,7 +201,7 @@ async function resolveChannelInputs( ); } - if (options.runEnrollmentChecks && hasRequiredInputsAvailable(manifest, inputs)) { + if (!skipped && options.runEnrollmentChecks && hasRequiredInputsAvailable(manifest, inputs)) { for (const hook of manifest.hooks .filter((entry) => isHookForAgent(entry, context.agent)) .filter((entry) => entry.phase === "reachability-check") @@ -208,7 +210,7 @@ async function resolveChannelInputs( } } - return inputs; + return { inputs, skipped }; } async function runCompilerHook( From ba7c0d5caca5750ca42f8434a399f168844e74a0 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Mon, 25 May 2026 09:30:05 -0700 Subject: [PATCH 12/20] fix(messaging): complete channel manifest hardening --- src/lib/messaging/applier/agent-config.ts | 61 ++++++++++++++----- .../messaging/applier/setup-applier.test.ts | 53 +++++++++++----- src/lib/messaging/channels/manifests.test.ts | 42 +++++++++++++ .../channels/wechat/hooks/health-check.ts | 23 +++++++ .../wechat/hooks/implementations.test.ts | 40 ++++++++++-- .../messaging/channels/wechat/hooks/index.ts | 3 + src/lib/messaging/channels/wechat/manifest.ts | 7 +++ .../compiler/manifest-compiler.test.ts | 3 + src/lib/messaging/hooks/hook-runner.test.ts | 1 + 9 files changed, 201 insertions(+), 32 deletions(-) create mode 100644 src/lib/messaging/channels/wechat/hooks/health-check.ts diff --git a/src/lib/messaging/applier/agent-config.ts b/src/lib/messaging/applier/agent-config.ts index 00c5c5b44b..0c663afaca 100644 --- a/src/lib/messaging/applier/agent-config.ts +++ b/src/lib/messaging/applier/agent-config.ts @@ -338,8 +338,11 @@ function readHookBuildFile(value: MessagingSerializableValue): { if (file.content === undefined && file.merge === undefined) { throw new Error(`Messaging build-file '${path}' must include content or merge.`); } - if (mode !== undefined && typeof mode !== "string") { - throw new Error(`Messaging build-file '${path}' mode must be a string.`); + if (mode !== undefined) { + if (typeof mode !== "string") { + throw new Error(`Messaging build-file '${path}' mode must be a string.`); + } + assertSafeFileMode(path, mode); } return { path, @@ -412,28 +415,32 @@ function serializeHookBuildFileContent( function resolveHookBuildFileTarget(filePath: string, agent: MessagingAgentId): string { const normalizedPath = normalizeRelativeBuildFilePath(filePath); + const root = sandboxAgentConfigRoot(agent); + let target: string; if (normalizedPath === "openclaw.json") { - return resolveSandboxAgentConfigTarget(normalizedPath, "openclaw"); - } - if (normalizedPath === "config.yaml" && agent === "hermes") { - return resolveSandboxAgentConfigTarget("~/.hermes/config.yaml", agent); - } - if (normalizedPath === ".env" && agent === "hermes") { - return resolveSandboxAgentConfigTarget("~/.hermes/.env", agent); - } - if (agent === "openclaw") return `/sandbox/.openclaw/${normalizedPath}`; - if (agent === "hermes") return `/sandbox/.hermes/${normalizedPath}`; - throw new Error(`Cannot resolve messaging build-file target '${filePath}' for ${agent}.`); + target = resolveSandboxAgentConfigTarget(normalizedPath, "openclaw"); + } else if (normalizedPath === "config.yaml" && agent === "hermes") { + target = resolveSandboxAgentConfigTarget("~/.hermes/config.yaml", agent); + } else if (normalizedPath === ".env" && agent === "hermes") { + target = resolveSandboxAgentConfigTarget("~/.hermes/.env", agent); + } else { + target = `${root}/${normalizedPath}`; + } + assertSandboxPathUnderRoot(target, root, filePath); + return target; } function normalizeRelativeBuildFilePath(filePath: string): string { if (filePath.trim().length === 0) { throw new Error("Messaging build-file path must not be empty."); } - if (filePath.startsWith("/") || filePath.includes("\\") || filePath.includes("\0")) { + if (filePath.startsWith("/") || filePath.includes("\\") || /[\0-\x1F\x7F]/.test(filePath)) { throw new Error(`Messaging build-file path '${filePath}' must be a safe relative path.`); } const segments = filePath.split("/"); + if (segments.some((segment) => segment.length === 0 || segment === ".")) { + throw new Error(`Messaging build-file path '${filePath}' must not contain empty segments.`); + } if (segments.some((segment) => segment === "..")) { throw new Error(`Messaging build-file path '${filePath}' must not traverse directories.`); } @@ -449,6 +456,32 @@ function normalizeRelativeBuildFilePath(filePath: string): string { return normalizedPath; } +function sandboxAgentConfigRoot(agent: MessagingAgentId): string { + if (agent === "openclaw") return "/sandbox/.openclaw"; + if (agent === "hermes") return "/sandbox/.hermes"; + throw new Error(`Cannot resolve messaging build-file root for ${agent}.`); +} + +function assertSandboxPathUnderRoot(target: string, root: string, sourcePath: string): void { + const relative = path.relative(root, target); + if (relative.length === 0 || relative.startsWith("..") || path.isAbsolute(relative)) { + throw new Error(`Messaging build-file path '${sourcePath}' must stay inside ${root}.`); + } +} + +function assertSafeFileMode(filePath: string, mode: string): void { + if (!/^[0-7]{3,4}$/.test(mode)) { + throw new Error(`Messaging build-file '${filePath}' mode must be an octal file mode.`); + } + if (mode.length === 4 && mode[0] !== "0") { + throw new Error(`Messaging build-file '${filePath}' mode must not set special bits.`); + } + const parsedMode = Number.parseInt(mode, 8); + if ((parsedMode & 0o022) !== 0) { + throw new Error(`Messaging build-file '${filePath}' mode must not be group/world writable.`); + } +} + function resolveSandboxAgentConfigTarget(target: string, agent: MessagingAgentId): string { if (target.startsWith("/")) return target; if (agent === "openclaw" && target === "openclaw.json") { diff --git a/src/lib/messaging/applier/setup-applier.test.ts b/src/lib/messaging/applier/setup-applier.test.ts index fc15b1af20..7c5d0afb93 100644 --- a/src/lib/messaging/applier/setup-applier.test.ts +++ b/src/lib/messaging/applier/setup-applier.test.ts @@ -527,7 +527,7 @@ describe("MessagingSetupApplier", () => { expect(({} as { polluted?: boolean }).polluted).toBeUndefined(); }); - it("rejects build-file hook outputs that traverse outside the agent config directory", async () => { + it("rejects unsafe build-file hook output paths and modes", async () => { const plan = await planOnboard({ WECHAT_ACCOUNT_ID: "wechat-account" }, ["wechat"]); const runOpenshell: MessagingOpenShellRunner = (args, options) => { if (args.includes("cat") && options?.input === undefined) { @@ -535,23 +535,48 @@ describe("MessagingSetupApplier", () => { } return { status: 0 }; }; + const unsafeFiles = [ + { + value: { path: "openclaw-weixin/accounts/../../openclaw.json", content: {} }, + error: "must not traverse directories", + }, + { + value: { path: "/tmp/openclaw.json", content: {} }, + error: "must be a safe relative path", + }, + { + value: { path: "openclaw-weixin//accounts.json", content: {} }, + error: "must not contain empty segments", + }, + { + value: { path: "openclaw-weixin/\u0001accounts.json", content: {} }, + error: "must be a safe relative path", + }, + { + value: { path: "openclaw-weixin/accounts.json", mode: "0777", content: {} }, + error: "must not be group/world writable", + }, + { + value: { path: "openclaw-weixin/accounts.json", mode: "u+s", content: {} }, + error: "mode must be an octal file mode", + }, + ]; - await expect( - MessagingSetupApplier.applyAgentConfigAtOpenShell(plan, { - runOpenshell, - runHook: () => ({ - outputs: { - openclawWeixinAccountFile: { - kind: "build-file", - value: { - path: "openclaw-weixin/accounts/../../openclaw.json", - content: {}, + for (const { value, error } of unsafeFiles) { + await expect( + MessagingSetupApplier.applyAgentConfigAtOpenShell(plan, { + runOpenshell, + runHook: () => ({ + outputs: { + openclawWeixinAccountFile: { + kind: "build-file", + value, }, }, - }, + }), }), - }), - ).rejects.toThrow("must not traverse directories"); + ).rejects.toThrow(error); + } }); it("applies policy presets directly from the serializable plan", async () => { diff --git a/src/lib/messaging/channels/manifests.test.ts b/src/lib/messaging/channels/manifests.test.ts index 56d61b6a8c..f09ab467cc 100644 --- a/src/lib/messaging/channels/manifests.test.ts +++ b/src/lib/messaging/channels/manifests.test.ts @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { readFileSync } from "node:fs"; + import { describe, expect, it } from "vitest"; import { @@ -80,6 +82,38 @@ describe("built-in channel manifests", () => { ]); }); + it("keeps built-in manifests fully JSON-serializable", () => { + expect(JSON.parse(JSON.stringify(BUILT_IN_CHANNEL_MANIFESTS))).toEqual( + BUILT_IN_CHANNEL_MANIFESTS, + ); + }); + + it("keeps manifest files declarative and free of side-effect imports", () => { + const manifestPaths = [ + "src/lib/messaging/channels/telegram/manifest.ts", + "src/lib/messaging/channels/discord/manifest.ts", + "src/lib/messaging/channels/wechat/manifest.ts", + "src/lib/messaging/channels/slack/manifest.ts", + "src/lib/messaging/channels/whatsapp/manifest.ts", + ]; + const forbiddenImports = [ + "credentials/store", + "state/registry", + "adapters/openshell", + "host-qr-handlers", + "ext/wechat", + "node:fs", + "node:child_process", + ]; + + for (const manifestPath of manifestPaths) { + const source = readFileSync(manifestPath, "utf8"); + for (const forbiddenImport of forbiddenImports) { + expect(source).not.toContain(forbiddenImport); + } + } + }); + it("matches current sandbox channel metadata for prompts, auth, and policy presets", () => { const manifests = { telegram: telegramManifest, @@ -328,6 +362,7 @@ describe("built-in channel manifests", () => { expect(wechatManifest.hooks.map((hook) => hook.handler)).toEqual([ "wechat.ilinkLogin", "wechat.seedOpenClawAccount", + "wechat.healthCheck", ]); expect(wechatManifest.hooks[1]?.outputs).toEqual( expect.arrayContaining([ @@ -341,6 +376,13 @@ describe("built-in channel manifests", () => { }), ]), ); + expect(wechatManifest.hooks[2]).toMatchObject({ + id: "wechat-health-check", + phase: "health-check", + handler: "wechat.healthCheck", + inputs: ["wechatConfig.accountId"], + onFailure: "abort", + }); }); it("declares WhatsApp as in-sandbox QR with no host-side token bindings", () => { diff --git a/src/lib/messaging/channels/wechat/hooks/health-check.ts b/src/lib/messaging/channels/wechat/hooks/health-check.ts new file mode 100644 index 0000000000..855b0208bc --- /dev/null +++ b/src/lib/messaging/channels/wechat/hooks/health-check.ts @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { MessagingHookHandler, MessagingHookRegistration } from "../../../hooks/types"; + +export const WECHAT_HEALTH_CHECK_HOOK_ID = "wechat.healthCheck"; + +export function createWechatHealthCheckHook(): MessagingHookHandler { + return (context) => { + const accountId = context.inputs?.["wechatConfig.accountId"]; + if (typeof accountId !== "string" || accountId.trim().length === 0) { + throw new Error("WeChat health check requires wechatConfig.accountId."); + } + return {}; + }; +} + +export function createWechatHealthCheckHookRegistration(): MessagingHookRegistration { + return { + id: WECHAT_HEALTH_CHECK_HOOK_ID, + handler: createWechatHealthCheckHook(), + }; +} diff --git a/src/lib/messaging/channels/wechat/hooks/implementations.test.ts b/src/lib/messaging/channels/wechat/hooks/implementations.test.ts index 12a6fe5b49..ca60b8f5ff 100644 --- a/src/lib/messaging/channels/wechat/hooks/implementations.test.ts +++ b/src/lib/messaging/channels/wechat/hooks/implementations.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest"; import { MessagingHookRegistry, runMessagingHook } from "../../../hooks"; import { wechatManifest } from "../manifest"; +import { createWechatHealthCheckHook, WECHAT_HEALTH_CHECK_HOOK_ID } from "./health-check"; import { createWechatIlinkLoginHook, WECHAT_ILINK_LOGIN_HOOK_ID } from "./ilink-login"; import { buildWechatSeedOpenClawAccountOutputs, @@ -100,11 +101,42 @@ describe("WeChat hook implementations", () => { }); it("rejects unsafe WeChat account ids before using them as build-file names", () => { - expect(() => - buildWechatSeedOpenClawAccountOutputs({ - "wechatConfig.accountId": "../../openclaw", + for (const accountId of ["../../openclaw", "nested/account", "control\u0001id"]) { + expect(() => + buildWechatSeedOpenClawAccountOutputs({ + "wechatConfig.accountId": accountId, + }), + ).toThrow("unsafe filename characters"); + } + }); + + it("declares a health-check hook that requires captured account metadata", async () => { + const hook = wechatManifest.hooks.find((entry) => entry.id === "wechat-health-check"); + const registry = new MessagingHookRegistry([ + { + id: WECHAT_HEALTH_CHECK_HOOK_ID, + handler: createWechatHealthCheckHook(), + }, + ]); + + if (!hook) throw new Error("missing WeChat health-check hook"); + + await expect( + runMessagingHook(hook, registry, { + channelId: "wechat", + inputs: { + "wechatConfig.accountId": "wechat-account", + }, + }), + ).resolves.toMatchObject({ + handlerId: WECHAT_HEALTH_CHECK_HOOK_ID, + outputs: {}, + }); + await expect( + runMessagingHook(hook, registry, { + channelId: "wechat", }), - ).toThrow("unsafe filename characters"); + ).rejects.toThrow("WeChat health check requires wechatConfig.accountId."); }); it("generates OpenClaw account seed build-file outputs from captured metadata", async () => { diff --git a/src/lib/messaging/channels/wechat/hooks/index.ts b/src/lib/messaging/channels/wechat/hooks/index.ts index 796ab03f2b..db09732c15 100644 --- a/src/lib/messaging/channels/wechat/hooks/index.ts +++ b/src/lib/messaging/channels/wechat/hooks/index.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import type { MessagingHookRegistration } from "../../../hooks/types"; +import { createWechatHealthCheckHookRegistration } from "./health-check"; import { createWechatIlinkLoginHookRegistration, type WechatIlinkLoginHookOptions, @@ -11,6 +12,7 @@ import { type WechatSeedOpenClawAccountHookOptions, } from "./seed-openclaw-account"; +export * from "./health-check"; export * from "./ilink-login"; export * from "./seed-openclaw-account"; @@ -25,5 +27,6 @@ export function createWechatHookRegistrations( return [ createWechatIlinkLoginHookRegistration(options.ilinkLogin), createWechatSeedOpenClawAccountHookRegistration(options.seedOpenClawAccount), + createWechatHealthCheckHookRegistration(), ] as const; } diff --git a/src/lib/messaging/channels/wechat/manifest.ts b/src/lib/messaging/channels/wechat/manifest.ts index f728331cf2..f6c245a123 100644 --- a/src/lib/messaging/channels/wechat/manifest.ts +++ b/src/lib/messaging/channels/wechat/manifest.ts @@ -166,5 +166,12 @@ export const wechatManifest = { ], onFailure: "abort", }, + { + id: "wechat-health-check", + phase: "health-check", + handler: "wechat.healthCheck", + inputs: ["wechatConfig.accountId"], + onFailure: "abort", + }, ], } as const satisfies ChannelManifest; diff --git a/src/lib/messaging/compiler/manifest-compiler.test.ts b/src/lib/messaging/compiler/manifest-compiler.test.ts index 09d1aac3f5..c31ab51300 100644 --- a/src/lib/messaging/compiler/manifest-compiler.test.ts +++ b/src/lib/messaging/compiler/manifest-compiler.test.ts @@ -223,6 +223,9 @@ describe("ManifestCompiler", () => { expect(plan.healthChecks.every((check) => check.requiredBefore === "lifecycle-success")).toBe( true, ); + expect(plan.healthChecks.find((check) => check.channelId === "wechat")?.hookIds).toEqual([ + "wechat-health-check", + ]); expect( plan.agentRender.find( (render) => render.channelId === "telegram" && render.kind === "json-fragment", diff --git a/src/lib/messaging/hooks/hook-runner.test.ts b/src/lib/messaging/hooks/hook-runner.test.ts index 34253ab9d6..570a66eb0c 100644 --- a/src/lib/messaging/hooks/hook-runner.test.ts +++ b/src/lib/messaging/hooks/hook-runner.test.ts @@ -63,6 +63,7 @@ describe("MessagingHookRegistry", () => { "telegram.getMeReachability", "wechat.ilinkLogin", "wechat.seedOpenClawAccount", + "wechat.healthCheck", ]); }); From 55c4b5717b5a05c058bcee4fec8cbb8dba0ee7ad Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Mon, 25 May 2026 09:40:47 -0700 Subject: [PATCH 13/20] fix(messaging): validate agent config render targets --- src/lib/messaging/applier/agent-config.ts | 58 ++++++++++++++----- .../messaging/applier/setup-applier.test.ts | 49 +++++++++++++++- 2 files changed, 92 insertions(+), 15 deletions(-) diff --git a/src/lib/messaging/applier/agent-config.ts b/src/lib/messaging/applier/agent-config.ts index 0c663afaca..351066cf60 100644 --- a/src/lib/messaging/applier/agent-config.ts +++ b/src/lib/messaging/applier/agent-config.ts @@ -413,8 +413,12 @@ function serializeHookBuildFileContent( return `${JSON.stringify(content, null, 2)}\n`; } +// Source-of-truth boundary for sandbox file writes: plans and hook outputs are +// serialized data that can outlive their producing manifest/hook code. Validate +// every target here before invoking OpenShell so future channels cannot bypass +// the agent-owned /sandbox config roots by returning raw paths. function resolveHookBuildFileTarget(filePath: string, agent: MessagingAgentId): string { - const normalizedPath = normalizeRelativeBuildFilePath(filePath); + const normalizedPath = normalizeRelativeAgentPath(filePath, "Messaging build-file path"); const root = sandboxAgentConfigRoot(agent); let target: string; if (normalizedPath === "openclaw.json") { @@ -426,23 +430,23 @@ function resolveHookBuildFileTarget(filePath: string, agent: MessagingAgentId): } else { target = `${root}/${normalizedPath}`; } - assertSandboxPathUnderRoot(target, root, filePath); + assertSandboxPathUnderRoot(target, root, filePath, "Messaging build-file path"); return target; } -function normalizeRelativeBuildFilePath(filePath: string): string { +function normalizeRelativeAgentPath(filePath: string, context: string): string { if (filePath.trim().length === 0) { - throw new Error("Messaging build-file path must not be empty."); + throw new Error(`${context} must not be empty.`); } if (filePath.startsWith("/") || filePath.includes("\\") || /[\0-\x1F\x7F]/.test(filePath)) { - throw new Error(`Messaging build-file path '${filePath}' must be a safe relative path.`); + throw new Error(`${context} '${filePath}' must be a safe relative path.`); } const segments = filePath.split("/"); if (segments.some((segment) => segment.length === 0 || segment === ".")) { - throw new Error(`Messaging build-file path '${filePath}' must not contain empty segments.`); + throw new Error(`${context} '${filePath}' must not contain empty segments.`); } if (segments.some((segment) => segment === "..")) { - throw new Error(`Messaging build-file path '${filePath}' must not traverse directories.`); + throw new Error(`${context} '${filePath}' must not traverse directories.`); } const normalizedPath = path.normalize(filePath); if ( @@ -451,7 +455,7 @@ function normalizeRelativeBuildFilePath(filePath: string): string { normalizedPath.startsWith("../") || normalizedPath.startsWith("/") ) { - throw new Error(`Messaging build-file path '${filePath}' must stay inside agent config.`); + throw new Error(`${context} '${filePath}' must stay inside agent config.`); } return normalizedPath; } @@ -462,10 +466,15 @@ function sandboxAgentConfigRoot(agent: MessagingAgentId): string { throw new Error(`Cannot resolve messaging build-file root for ${agent}.`); } -function assertSandboxPathUnderRoot(target: string, root: string, sourcePath: string): void { +function assertSandboxPathUnderRoot( + target: string, + root: string, + sourcePath: string, + context: string, +): void { const relative = path.relative(root, target); if (relative.length === 0 || relative.startsWith("..") || path.isAbsolute(relative)) { - throw new Error(`Messaging build-file path '${sourcePath}' must stay inside ${root}.`); + throw new Error(`${context} '${sourcePath}' must stay inside ${root}.`); } } @@ -483,15 +492,38 @@ function assertSafeFileMode(filePath: string, mode: string): void { } function resolveSandboxAgentConfigTarget(target: string, agent: MessagingAgentId): string { - if (target.startsWith("/")) return target; + const root = sandboxAgentConfigRoot(agent); + if (target.startsWith("/")) { + const normalizedTarget = path.normalize(target); + assertSandboxPathUnderRoot(normalizedTarget, root, target, "Messaging render target"); + return normalizedTarget; + } if (agent === "openclaw" && target === "openclaw.json") { return "/sandbox/.openclaw/openclaw.json"; } if (target.startsWith("~/.openclaw/")) { - return `/sandbox/.openclaw/${target.slice("~/.openclaw/".length)}`; + if (agent !== "openclaw") { + throw new Error(`Cannot apply OpenClaw messaging target '${target}' for ${agent}.`); + } + const suffix = normalizeRelativeAgentPath( + target.slice("~/.openclaw/".length), + "Messaging render target", + ); + const resolved = `${root}/${suffix}`; + assertSandboxPathUnderRoot(resolved, root, target, "Messaging render target"); + return resolved; } if (target.startsWith("~/.hermes/")) { - return `/sandbox/.hermes/${target.slice("~/.hermes/".length)}`; + if (agent !== "hermes") { + throw new Error(`Cannot apply Hermes messaging target '${target}' for ${agent}.`); + } + const suffix = normalizeRelativeAgentPath( + target.slice("~/.hermes/".length), + "Messaging render target", + ); + const resolved = `${root}/${suffix}`; + assertSandboxPathUnderRoot(resolved, root, target, "Messaging render target"); + return resolved; } throw new Error(`Cannot resolve messaging agent config target '${target}' for ${agent}.`); } diff --git a/src/lib/messaging/applier/setup-applier.test.ts b/src/lib/messaging/applier/setup-applier.test.ts index 7c5d0afb93..b1914e5361 100644 --- a/src/lib/messaging/applier/setup-applier.test.ts +++ b/src/lib/messaging/applier/setup-applier.test.ts @@ -7,7 +7,11 @@ import { createBuiltInChannelManifestRegistry } from "../channels"; import { MessagingWorkflowPlanner } from "../compiler"; import { createBuiltInMessagingHookRegistry, runMessagingHook } from "../hooks"; import type { ChannelHookSpec } from "../manifest"; -import type { MessagingAgentId, SandboxMessagingPlan } from "../manifest"; +import type { + MessagingAgentId, + MessagingSerializableObject, + SandboxMessagingPlan, +} from "../manifest"; import { MessagingSetupApplier } from "./setup-applier"; import { MESSAGING_SETUP_APPLIER_ENV_KEY, @@ -527,6 +531,44 @@ describe("MessagingSetupApplier", () => { expect(({} as { polluted?: boolean }).polluted).toBeUndefined(); }); + it("rejects render targets outside the selected agent config root", async () => { + const plan = await planOnboard({ TELEGRAM_BOT_TOKEN: "123456:telegram-token" }, [ + "telegram", + ]); + const runOpenshell: MessagingOpenShellRunner = (args, options) => { + if (args.includes("cat") && options?.input === undefined) { + return { status: 0, stdout: "{}" }; + } + return { status: 0 }; + }; + const unsafeTargets = [ + { target: "/tmp/openclaw.json", error: "must stay inside /sandbox/.openclaw" }, + { target: "~/.openclaw/../openclaw.json", error: "must not traverse directories" }, + { target: "~/.hermes/config.yaml", error: "Cannot apply Hermes messaging target" }, + ]; + + for (const { target, error } of unsafeTargets) { + const unsafePlan = { + ...plan, + agentRender: [ + { + channelId: "telegram", + kind: "json-fragment", + agent: "openclaw", + target, + path: "channels.telegram.enabled", + value: true, + templateRefs: [], + }, + ], + } satisfies SandboxMessagingPlan; + + await expect( + MessagingSetupApplier.applyAgentConfigAtOpenShell(unsafePlan, { runOpenshell }), + ).rejects.toThrow(error); + } + }); + it("rejects unsafe build-file hook output paths and modes", async () => { const plan = await planOnboard({ WECHAT_ACCOUNT_ID: "wechat-account" }, ["wechat"]); const runOpenshell: MessagingOpenShellRunner = (args, options) => { @@ -535,7 +577,10 @@ describe("MessagingSetupApplier", () => { } return { status: 0 }; }; - const unsafeFiles = [ + const unsafeFiles: Array<{ + readonly value: MessagingSerializableObject; + readonly error: string; + }> = [ { value: { path: "openclaw-weixin/accounts/../../openclaw.json", content: {} }, error: "must not traverse directories", From 812defe2827990584114edeaf7d6feeeb26e71d2 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Mon, 25 May 2026 10:36:22 -0700 Subject: [PATCH 14/20] fix(messaging): keep wechat hook dependencies injected --- src/lib/messaging/channels/manifests.test.ts | 6 +- .../channels/wechat/hooks/ilink-login.ts | 56 +++++++++++++------ .../wechat/hooks/implementations.test.ts | 18 ++++++ 3 files changed, 62 insertions(+), 18 deletions(-) diff --git a/src/lib/messaging/channels/manifests.test.ts b/src/lib/messaging/channels/manifests.test.ts index f09ab467cc..32e00fbb0c 100644 --- a/src/lib/messaging/channels/manifests.test.ts +++ b/src/lib/messaging/channels/manifests.test.ts @@ -88,11 +88,15 @@ describe("built-in channel manifests", () => { ); }); - it("keeps manifest files declarative and free of side-effect imports", () => { + it("keeps phase-1 manifest and hook files free of production side-effect imports", () => { const manifestPaths = [ "src/lib/messaging/channels/telegram/manifest.ts", "src/lib/messaging/channels/discord/manifest.ts", "src/lib/messaging/channels/wechat/manifest.ts", + "src/lib/messaging/channels/wechat/hooks/health-check.ts", + "src/lib/messaging/channels/wechat/hooks/ilink-login.ts", + "src/lib/messaging/channels/wechat/hooks/index.ts", + "src/lib/messaging/channels/wechat/hooks/seed-openclaw-account.ts", "src/lib/messaging/channels/slack/manifest.ts", "src/lib/messaging/channels/whatsapp/manifest.ts", ]; diff --git a/src/lib/messaging/channels/wechat/hooks/ilink-login.ts b/src/lib/messaging/channels/wechat/hooks/ilink-login.ts index ccc4006f66..77322519f7 100644 --- a/src/lib/messaging/channels/wechat/hooks/ilink-login.ts +++ b/src/lib/messaging/channels/wechat/hooks/ilink-login.ts @@ -1,14 +1,6 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { - normalizeCredentialValue, - saveCredential as saveProcessCredential, -} from "../../../../credentials/store"; -import { - runWechatHostQrLogin, - type WechatLoginResult, -} from "../../../../../ext/wechat/login"; import type { MessagingHookHandler, MessagingHookOutputMap, @@ -16,6 +8,20 @@ import type { } from "../../../hooks/types"; import type { MessagingSerializableValue } from "../../../manifest"; +export interface WechatLoginCredentials { + readonly token: string; + readonly accountId: string; + readonly baseUrl?: string; + readonly userId?: string; +} + +export type WechatLoginResult = + | { readonly kind: "ok"; readonly credentials: WechatLoginCredentials } + | { readonly kind: "timeout" } + | { readonly kind: "expired"; readonly reason?: string } + | { readonly kind: "aborted" } + | { readonly kind: "error"; readonly message?: string }; + export const WECHAT_ILINK_LOGIN_HOOK_ID = "wechat.ilinkLogin"; export interface WechatIlinkLoginHookOptions { @@ -28,14 +34,24 @@ export function createWechatIlinkLoginHook( options: WechatIlinkLoginHookOptions = {}, ): MessagingHookHandler { return async (context) => { - const runLogin = options.runLogin ?? (() => runWechatHostQrLogin()); + const runLogin = options.runLogin; + if (!runLogin) { + throw new Error( + "WeChat host QR login hook requires an injected runLogin implementation in phase 1.", + ); + } const result = await runLogin(); if (result.kind !== "ok") { throw new Error(`WeChat host QR login failed: ${wechatFailureReason(result)}.`); } const env = options.env ?? process.env; - const saveCredential = options.saveCredential ?? saveProcessCredential; + const saveCredential = options.saveCredential; + if (!saveCredential) { + throw new Error( + "WeChat host QR login hook requires an injected saveCredential implementation in phase 1.", + ); + } const { token, accountId, baseUrl, userId } = result.credentials; saveCredential("WECHAT_BOT_TOKEN", token); @@ -68,7 +84,7 @@ export function createWechatIlinkLoginHook( }; } if (declaresOutput(context.outputDeclarations, "allowedIds")) { - const allowedIds = mergeCsvValues(readString(context.inputs?.allowedIds), userId); + const allowedIds = mergeCsvValues(readString(context.inputs?.allowedIds), userId ?? ""); if (allowedIds) { env.WECHAT_ALLOWED_IDS = allowedIds; outputs.allowedIds = { @@ -110,13 +126,19 @@ function readString(value: MessagingSerializableValue | undefined): string { } function mergeCsvValues(existing: string, next: string): string { - const values = new Set( - normalizeCredentialValue(existing) - .split(",") - .map((value) => value.trim()) - .filter(Boolean), - ); + const values = new Set(normalizeCsvValues(existing)); const normalized = normalizeCredentialValue(next); if (normalized) values.add(normalized); return Array.from(values).join(","); } + +function normalizeCsvValues(value: string): string[] { + return normalizeCredentialValue(value) + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); +} + +function normalizeCredentialValue(value: string): string { + return value.replace(/\r/g, "").trim(); +} diff --git a/src/lib/messaging/channels/wechat/hooks/implementations.test.ts b/src/lib/messaging/channels/wechat/hooks/implementations.test.ts index ca60b8f5ff..5e21fb2991 100644 --- a/src/lib/messaging/channels/wechat/hooks/implementations.test.ts +++ b/src/lib/messaging/channels/wechat/hooks/implementations.test.ts @@ -15,6 +15,24 @@ import { } from "./seed-openclaw-account"; describe("WeChat hook implementations", () => { + it("requires injected host QR dependencies in phase 1", async () => { + const registry = new MessagingHookRegistry([ + { + id: WECHAT_ILINK_LOGIN_HOOK_ID, + handler: createWechatIlinkLoginHook(), + }, + ]); + const hook = wechatManifest.hooks[0]; + + if (!hook) throw new Error("missing WeChat host QR hook"); + + await expect( + runMessagingHook(hook, registry, { + channelId: "wechat", + }), + ).rejects.toThrow("requires an injected runLogin implementation"); + }); + it("runs host QR enrollment and stages token plus non-secret account metadata", async () => { const env: NodeJS.ProcessEnv = {}; const saved: Array<{ readonly key: string; readonly value: string }> = []; From b602fc71462737252cdf0041513ce7de430f4801 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Mon, 25 May 2026 10:52:37 -0700 Subject: [PATCH 15/20] fix(messaging): keep token paste dependencies injected --- src/lib/messaging/channels/manifests.test.ts | 1 + .../hooks/common/token-paste.test.ts | 18 +++++++++++++ src/lib/messaging/hooks/common/token-paste.ts | 25 +++++++++++-------- 3 files changed, 33 insertions(+), 11 deletions(-) diff --git a/src/lib/messaging/channels/manifests.test.ts b/src/lib/messaging/channels/manifests.test.ts index 32e00fbb0c..4a0706c88c 100644 --- a/src/lib/messaging/channels/manifests.test.ts +++ b/src/lib/messaging/channels/manifests.test.ts @@ -99,6 +99,7 @@ describe("built-in channel manifests", () => { "src/lib/messaging/channels/wechat/hooks/seed-openclaw-account.ts", "src/lib/messaging/channels/slack/manifest.ts", "src/lib/messaging/channels/whatsapp/manifest.ts", + "src/lib/messaging/hooks/common/token-paste.ts", ]; const forbiddenImports = [ "credentials/store", diff --git a/src/lib/messaging/hooks/common/token-paste.test.ts b/src/lib/messaging/hooks/common/token-paste.test.ts index 74971af0b8..9c56814451 100644 --- a/src/lib/messaging/hooks/common/token-paste.test.ts +++ b/src/lib/messaging/hooks/common/token-paste.test.ts @@ -21,6 +21,24 @@ describe("common token-paste hook implementation", () => { expect(slackManifest.hooks[0]?.handler).toBe(COMMON_TOKEN_PASTE_HOOK_HANDLER_ID); }); + it("requires an injected prompt when no env or credential value is available", async () => { + const registry = new MessagingHookRegistry([ + { + id: COMMON_TOKEN_PASTE_HOOK_HANDLER_ID, + handler: createTokenPasteHook({ env: {}, log: () => {} }), + }, + ]); + const hook = telegramManifest.hooks[0]; + + if (!hook) throw new Error("missing Telegram token-paste hook"); + + await expect( + runMessagingHook(hook, registry, { + channelId: "telegram", + }), + ).rejects.toThrow("requires an injected prompt implementation"); + }); + it("shows the single-token enrollment output shape", async () => { const registry = new MessagingHookRegistry([ { diff --git a/src/lib/messaging/hooks/common/token-paste.ts b/src/lib/messaging/hooks/common/token-paste.ts index e3a5783fdb..fdba275b03 100644 --- a/src/lib/messaging/hooks/common/token-paste.ts +++ b/src/lib/messaging/hooks/common/token-paste.ts @@ -6,12 +6,6 @@ import type { MessagingHookOutputMap, MessagingHookRegistration, } from "../types"; -import { - getCredential, - normalizeCredentialValue, - prompt as promptCredential, - saveCredential, -} from "../../../credentials/store"; import { getChannelDef } from "../../../sandbox/channels"; import type { ChannelHookOutputSpec } from "../../manifest"; @@ -76,11 +70,9 @@ async function resolveTokenValue( options: TokenPasteHookOptions, ): Promise { const env = options.env ?? process.env; - const readCredential = - options.getCredential ?? - ((key: string) => normalizeCredentialValue(env[key]) || getCredential(key)); - const writeCredential = options.saveCredential ?? saveCredential; - const prompt = options.prompt ?? promptCredential; + const readCredential = options.getCredential ?? (() => null); + const writeCredential = options.saveCredential ?? (() => {}); + const prompt = options.prompt ?? missingPhaseOnePrompt; const log = options.log ?? ((message: string) => console.log(message)); let token = normalizeCredentialValue(env[field.envKey]) || readCredential(field.envKey); @@ -104,6 +96,17 @@ async function resolveTokenValue( return token; } +async function missingPhaseOnePrompt(): Promise { + throw new Error( + "Token-paste hook requires an injected prompt implementation in phase 1.", + ); +} + +function normalizeCredentialValue(value: string | null | undefined): string { + if (typeof value !== "string") return ""; + return value.replace(/\r/g, "").trim(); +} + function resolveTokenPasteField( channelId: string, output: ChannelHookOutputSpec, From accf1e347fefa64bc7c2089bd662553060ef7ae7 Mon Sep 17 00:00:00 2001 From: San Dang Date: Tue, 26 May 2026 08:28:13 +0700 Subject: [PATCH 16/20] refactor(messaging): unify validation hook phase --- src/lib/messaging/channels/manifests.test.ts | 4 ++-- .../channels/telegram/hooks/get-me-reachability.test.ts | 6 +++--- src/lib/messaging/channels/telegram/manifest.ts | 2 +- .../channels/wechat/hooks/implementations.test.ts | 4 ++-- src/lib/messaging/channels/wechat/manifest.ts | 2 +- src/lib/messaging/compiler/engines/health-check-engine.ts | 4 ++-- src/lib/messaging/compiler/manifest-compiler.test.ts | 7 +++++-- src/lib/messaging/compiler/manifest-compiler.ts | 8 ++++---- src/lib/messaging/manifest/types.test.ts | 2 +- src/lib/messaging/manifest/types.ts | 7 +++---- 10 files changed, 24 insertions(+), 22 deletions(-) diff --git a/src/lib/messaging/channels/manifests.test.ts b/src/lib/messaging/channels/manifests.test.ts index 4a0706c88c..d1a0d5e522 100644 --- a/src/lib/messaging/channels/manifests.test.ts +++ b/src/lib/messaging/channels/manifests.test.ts @@ -196,7 +196,7 @@ describe("built-in channel manifests", () => { expectTokenPasteEnrollHook(telegramManifest, ["botToken"]); expect(telegramManifest.hooks).toContainEqual({ id: "telegram-reachability", - phase: "reachability-check", + phase: "validation", handler: "telegram.getMeReachability", inputs: ["botToken"], onFailure: "abort", @@ -383,7 +383,7 @@ describe("built-in channel manifests", () => { ); expect(wechatManifest.hooks[2]).toMatchObject({ id: "wechat-health-check", - phase: "health-check", + phase: "validation", handler: "wechat.healthCheck", inputs: ["wechatConfig.accountId"], onFailure: "abort", diff --git a/src/lib/messaging/channels/telegram/hooks/get-me-reachability.test.ts b/src/lib/messaging/channels/telegram/hooks/get-me-reachability.test.ts index 44754d53d8..aac9f35ae6 100644 --- a/src/lib/messaging/channels/telegram/hooks/get-me-reachability.test.ts +++ b/src/lib/messaging/channels/telegram/hooks/get-me-reachability.test.ts @@ -34,7 +34,7 @@ describe("Telegram getMe reachability hook implementation", () => { }), }, ]); - const hook = telegramManifest.hooks.find((entry) => entry.phase === "reachability-check"); + const hook = telegramManifest.hooks.find((entry) => entry.phase === "validation"); if (!hook) throw new Error("missing Telegram reachability hook"); @@ -48,7 +48,7 @@ describe("Telegram getMe reachability hook implementation", () => { ).resolves.toEqual({ hookId: "telegram-reachability", handlerId: TELEGRAM_GET_ME_REACHABILITY_HOOK_ID, - phase: "reachability-check", + phase: "validation", outputs: {}, }); expect(urls).toEqual(["https://telegram.test/bot123456:telegram-token/getMe"]); @@ -73,7 +73,7 @@ describe("Telegram getMe reachability hook implementation", () => { }), }, ]); - const hook = telegramManifest.hooks.find((entry) => entry.phase === "reachability-check"); + const hook = telegramManifest.hooks.find((entry) => entry.phase === "validation"); if (!hook) throw new Error("missing Telegram reachability hook"); diff --git a/src/lib/messaging/channels/telegram/manifest.ts b/src/lib/messaging/channels/telegram/manifest.ts index e5faf9dffc..c364949193 100644 --- a/src/lib/messaging/channels/telegram/manifest.ts +++ b/src/lib/messaging/channels/telegram/manifest.ts @@ -147,7 +147,7 @@ export const telegramManifest = { }, { id: "telegram-reachability", - phase: "reachability-check", + phase: "validation", handler: "telegram.getMeReachability", inputs: ["botToken"], onFailure: "abort", diff --git a/src/lib/messaging/channels/wechat/hooks/implementations.test.ts b/src/lib/messaging/channels/wechat/hooks/implementations.test.ts index 5e21fb2991..dcc6a6a155 100644 --- a/src/lib/messaging/channels/wechat/hooks/implementations.test.ts +++ b/src/lib/messaging/channels/wechat/hooks/implementations.test.ts @@ -128,7 +128,7 @@ describe("WeChat hook implementations", () => { } }); - it("declares a health-check hook that requires captured account metadata", async () => { + it("declares a validation hook that requires captured account metadata", async () => { const hook = wechatManifest.hooks.find((entry) => entry.id === "wechat-health-check"); const registry = new MessagingHookRegistry([ { @@ -137,7 +137,7 @@ describe("WeChat hook implementations", () => { }, ]); - if (!hook) throw new Error("missing WeChat health-check hook"); + if (!hook) throw new Error("missing WeChat validation hook"); await expect( runMessagingHook(hook, registry, { diff --git a/src/lib/messaging/channels/wechat/manifest.ts b/src/lib/messaging/channels/wechat/manifest.ts index f6c245a123..a14b5c466e 100644 --- a/src/lib/messaging/channels/wechat/manifest.ts +++ b/src/lib/messaging/channels/wechat/manifest.ts @@ -168,7 +168,7 @@ export const wechatManifest = { }, { id: "wechat-health-check", - phase: "health-check", + phase: "validation", handler: "wechat.healthCheck", inputs: ["wechatConfig.accountId"], onFailure: "abort", diff --git a/src/lib/messaging/compiler/engines/health-check-engine.ts b/src/lib/messaging/compiler/engines/health-check-engine.ts index d19952efbf..d69c0abecb 100644 --- a/src/lib/messaging/compiler/engines/health-check-engine.ts +++ b/src/lib/messaging/compiler/engines/health-check-engine.ts @@ -7,10 +7,10 @@ export function planHealthChecks(manifest: ChannelManifest): SandboxMessagingHea return [ { channelId: manifest.id, - phase: "health-check", + phase: "validation", requiredBefore: "lifecycle-success", hookIds: manifest.hooks - .filter((hook) => hook.phase === "health-check") + .filter((hook) => hook.phase === "validation") .map((hook) => hook.id), }, ]; diff --git a/src/lib/messaging/compiler/manifest-compiler.test.ts b/src/lib/messaging/compiler/manifest-compiler.test.ts index c31ab51300..6d94e49b88 100644 --- a/src/lib/messaging/compiler/manifest-compiler.test.ts +++ b/src/lib/messaging/compiler/manifest-compiler.test.ts @@ -226,6 +226,9 @@ describe("ManifestCompiler", () => { expect(plan.healthChecks.find((check) => check.channelId === "wechat")?.hookIds).toEqual([ "wechat-health-check", ]); + expect(plan.healthChecks.find((check) => check.channelId === "telegram")?.hookIds).toEqual([ + "telegram-reachability", + ]); expect( plan.agentRender.find( (render) => render.channelId === "telegram" && render.kind === "json-fragment", @@ -520,7 +523,7 @@ describe("ManifestCompiler", () => { }, { id: "matrix-host-probe", - phase: "reachability-check", + phase: "validation", handler: "matrix.probeHost", inputs: ["roomId"], onFailure: "abort", @@ -593,7 +596,7 @@ describe("ManifestCompiler", () => { ]); expect(plan.channels[0]?.hooks).toContainEqual( expect.objectContaining({ - phase: "reachability-check", + phase: "validation", handler: "matrix.probeHost", }), ); diff --git a/src/lib/messaging/compiler/manifest-compiler.ts b/src/lib/messaging/compiler/manifest-compiler.ts index 2ff8cad62a..85cd2e8b1c 100644 --- a/src/lib/messaging/compiler/manifest-compiler.ts +++ b/src/lib/messaging/compiler/manifest-compiler.ts @@ -114,7 +114,7 @@ export class ManifestCompiler { requestedActive && isEnrollmentWorkflow(context.workflow) && context.isInteractive, - runEnrollmentChecks: selected && requestedActive && isEnrollmentWorkflow(context.workflow), + runValidation: selected && requestedActive && isEnrollmentWorkflow(context.workflow), }); const active = requestedActive && !resolvedInputs.skipped; @@ -172,7 +172,7 @@ async function resolveChannelInputs( manifest: ChannelManifest, context: ManifestCompilerContext, hooks: MessagingHookRegistry, - options: { readonly runEnrollment: boolean; readonly runEnrollmentChecks: boolean }, + options: { readonly runEnrollment: boolean; readonly runValidation: boolean }, ): Promise<{ readonly inputs: SandboxMessagingInputReference[]; readonly skipped: boolean; @@ -201,10 +201,10 @@ async function resolveChannelInputs( ); } - if (!skipped && options.runEnrollmentChecks && hasRequiredInputsAvailable(manifest, inputs)) { + if (!skipped && options.runValidation && hasRequiredInputsAvailable(manifest, inputs)) { for (const hook of manifest.hooks .filter((entry) => isHookForAgent(entry, context.agent)) - .filter((entry) => entry.phase === "reachability-check") + .filter((entry) => entry.phase === "validation") .filter((entry) => hasDeclaredHookInputs(hookInputs, entry))) { await runCompilerHook(manifest, hook, hooks, hookInputs); } diff --git a/src/lib/messaging/manifest/types.test.ts b/src/lib/messaging/manifest/types.test.ts index c616b6571b..15d7ddf4f7 100644 --- a/src/lib/messaging/manifest/types.test.ts +++ b/src/lib/messaging/manifest/types.test.ts @@ -259,7 +259,7 @@ const telegramPlan = { healthChecks: [ { channelId: "telegram", - phase: "health-check", + phase: "validation", requiredBefore: "lifecycle-success", hookIds: [], }, diff --git a/src/lib/messaging/manifest/types.ts b/src/lib/messaging/manifest/types.ts index 684300f16e..98b9059ed4 100644 --- a/src/lib/messaging/manifest/types.ts +++ b/src/lib/messaging/manifest/types.ts @@ -145,10 +145,9 @@ export interface ChannelRebuildHydrationSpec { /** Lifecycle phase where a referenced hook may run. */ export type ChannelHookPhase = | "enroll" - | "reachability-check" + | "validation" | "apply" | "post-agent-install" - | "health-check" | "diagnostic" | "status"; @@ -334,10 +333,10 @@ export interface SandboxMessagingRebuildHydrationStateUpdatePlan { readonly env: string; } -/** Health gates that must run before a lifecycle can report success. */ +/** Validation gates that must run before a lifecycle can report success. */ export interface SandboxMessagingHealthCheckPlan { readonly channelId: MessagingChannelId; - readonly phase: "health-check"; + readonly phase: "validation"; readonly requiredBefore: "lifecycle-success"; readonly hookIds: readonly string[]; } From 5953299262777b0e7915414abded397934060f2d Mon Sep 17 00:00:00 2001 From: San Dang Date: Tue, 26 May 2026 08:46:07 +0700 Subject: [PATCH 17/20] revert(messaging): restore separate validation phases --- src/lib/messaging/channels/manifests.test.ts | 4 ++-- .../channels/telegram/hooks/get-me-reachability.test.ts | 6 +++--- src/lib/messaging/channels/telegram/manifest.ts | 2 +- .../channels/wechat/hooks/implementations.test.ts | 4 ++-- src/lib/messaging/channels/wechat/manifest.ts | 2 +- src/lib/messaging/compiler/engines/health-check-engine.ts | 4 ++-- src/lib/messaging/compiler/manifest-compiler.test.ts | 7 ++----- src/lib/messaging/compiler/manifest-compiler.ts | 8 ++++---- src/lib/messaging/manifest/types.test.ts | 2 +- src/lib/messaging/manifest/types.ts | 7 ++++--- 10 files changed, 22 insertions(+), 24 deletions(-) diff --git a/src/lib/messaging/channels/manifests.test.ts b/src/lib/messaging/channels/manifests.test.ts index d1a0d5e522..4a0706c88c 100644 --- a/src/lib/messaging/channels/manifests.test.ts +++ b/src/lib/messaging/channels/manifests.test.ts @@ -196,7 +196,7 @@ describe("built-in channel manifests", () => { expectTokenPasteEnrollHook(telegramManifest, ["botToken"]); expect(telegramManifest.hooks).toContainEqual({ id: "telegram-reachability", - phase: "validation", + phase: "reachability-check", handler: "telegram.getMeReachability", inputs: ["botToken"], onFailure: "abort", @@ -383,7 +383,7 @@ describe("built-in channel manifests", () => { ); expect(wechatManifest.hooks[2]).toMatchObject({ id: "wechat-health-check", - phase: "validation", + phase: "health-check", handler: "wechat.healthCheck", inputs: ["wechatConfig.accountId"], onFailure: "abort", diff --git a/src/lib/messaging/channels/telegram/hooks/get-me-reachability.test.ts b/src/lib/messaging/channels/telegram/hooks/get-me-reachability.test.ts index aac9f35ae6..44754d53d8 100644 --- a/src/lib/messaging/channels/telegram/hooks/get-me-reachability.test.ts +++ b/src/lib/messaging/channels/telegram/hooks/get-me-reachability.test.ts @@ -34,7 +34,7 @@ describe("Telegram getMe reachability hook implementation", () => { }), }, ]); - const hook = telegramManifest.hooks.find((entry) => entry.phase === "validation"); + const hook = telegramManifest.hooks.find((entry) => entry.phase === "reachability-check"); if (!hook) throw new Error("missing Telegram reachability hook"); @@ -48,7 +48,7 @@ describe("Telegram getMe reachability hook implementation", () => { ).resolves.toEqual({ hookId: "telegram-reachability", handlerId: TELEGRAM_GET_ME_REACHABILITY_HOOK_ID, - phase: "validation", + phase: "reachability-check", outputs: {}, }); expect(urls).toEqual(["https://telegram.test/bot123456:telegram-token/getMe"]); @@ -73,7 +73,7 @@ describe("Telegram getMe reachability hook implementation", () => { }), }, ]); - const hook = telegramManifest.hooks.find((entry) => entry.phase === "validation"); + const hook = telegramManifest.hooks.find((entry) => entry.phase === "reachability-check"); if (!hook) throw new Error("missing Telegram reachability hook"); diff --git a/src/lib/messaging/channels/telegram/manifest.ts b/src/lib/messaging/channels/telegram/manifest.ts index c364949193..e5faf9dffc 100644 --- a/src/lib/messaging/channels/telegram/manifest.ts +++ b/src/lib/messaging/channels/telegram/manifest.ts @@ -147,7 +147,7 @@ export const telegramManifest = { }, { id: "telegram-reachability", - phase: "validation", + phase: "reachability-check", handler: "telegram.getMeReachability", inputs: ["botToken"], onFailure: "abort", diff --git a/src/lib/messaging/channels/wechat/hooks/implementations.test.ts b/src/lib/messaging/channels/wechat/hooks/implementations.test.ts index dcc6a6a155..5e21fb2991 100644 --- a/src/lib/messaging/channels/wechat/hooks/implementations.test.ts +++ b/src/lib/messaging/channels/wechat/hooks/implementations.test.ts @@ -128,7 +128,7 @@ describe("WeChat hook implementations", () => { } }); - it("declares a validation hook that requires captured account metadata", async () => { + it("declares a health-check hook that requires captured account metadata", async () => { const hook = wechatManifest.hooks.find((entry) => entry.id === "wechat-health-check"); const registry = new MessagingHookRegistry([ { @@ -137,7 +137,7 @@ describe("WeChat hook implementations", () => { }, ]); - if (!hook) throw new Error("missing WeChat validation hook"); + if (!hook) throw new Error("missing WeChat health-check hook"); await expect( runMessagingHook(hook, registry, { diff --git a/src/lib/messaging/channels/wechat/manifest.ts b/src/lib/messaging/channels/wechat/manifest.ts index a14b5c466e..f6c245a123 100644 --- a/src/lib/messaging/channels/wechat/manifest.ts +++ b/src/lib/messaging/channels/wechat/manifest.ts @@ -168,7 +168,7 @@ export const wechatManifest = { }, { id: "wechat-health-check", - phase: "validation", + phase: "health-check", handler: "wechat.healthCheck", inputs: ["wechatConfig.accountId"], onFailure: "abort", diff --git a/src/lib/messaging/compiler/engines/health-check-engine.ts b/src/lib/messaging/compiler/engines/health-check-engine.ts index d69c0abecb..d19952efbf 100644 --- a/src/lib/messaging/compiler/engines/health-check-engine.ts +++ b/src/lib/messaging/compiler/engines/health-check-engine.ts @@ -7,10 +7,10 @@ export function planHealthChecks(manifest: ChannelManifest): SandboxMessagingHea return [ { channelId: manifest.id, - phase: "validation", + phase: "health-check", requiredBefore: "lifecycle-success", hookIds: manifest.hooks - .filter((hook) => hook.phase === "validation") + .filter((hook) => hook.phase === "health-check") .map((hook) => hook.id), }, ]; diff --git a/src/lib/messaging/compiler/manifest-compiler.test.ts b/src/lib/messaging/compiler/manifest-compiler.test.ts index 6d94e49b88..c31ab51300 100644 --- a/src/lib/messaging/compiler/manifest-compiler.test.ts +++ b/src/lib/messaging/compiler/manifest-compiler.test.ts @@ -226,9 +226,6 @@ describe("ManifestCompiler", () => { expect(plan.healthChecks.find((check) => check.channelId === "wechat")?.hookIds).toEqual([ "wechat-health-check", ]); - expect(plan.healthChecks.find((check) => check.channelId === "telegram")?.hookIds).toEqual([ - "telegram-reachability", - ]); expect( plan.agentRender.find( (render) => render.channelId === "telegram" && render.kind === "json-fragment", @@ -523,7 +520,7 @@ describe("ManifestCompiler", () => { }, { id: "matrix-host-probe", - phase: "validation", + phase: "reachability-check", handler: "matrix.probeHost", inputs: ["roomId"], onFailure: "abort", @@ -596,7 +593,7 @@ describe("ManifestCompiler", () => { ]); expect(plan.channels[0]?.hooks).toContainEqual( expect.objectContaining({ - phase: "validation", + phase: "reachability-check", handler: "matrix.probeHost", }), ); diff --git a/src/lib/messaging/compiler/manifest-compiler.ts b/src/lib/messaging/compiler/manifest-compiler.ts index 85cd2e8b1c..2ff8cad62a 100644 --- a/src/lib/messaging/compiler/manifest-compiler.ts +++ b/src/lib/messaging/compiler/manifest-compiler.ts @@ -114,7 +114,7 @@ export class ManifestCompiler { requestedActive && isEnrollmentWorkflow(context.workflow) && context.isInteractive, - runValidation: selected && requestedActive && isEnrollmentWorkflow(context.workflow), + runEnrollmentChecks: selected && requestedActive && isEnrollmentWorkflow(context.workflow), }); const active = requestedActive && !resolvedInputs.skipped; @@ -172,7 +172,7 @@ async function resolveChannelInputs( manifest: ChannelManifest, context: ManifestCompilerContext, hooks: MessagingHookRegistry, - options: { readonly runEnrollment: boolean; readonly runValidation: boolean }, + options: { readonly runEnrollment: boolean; readonly runEnrollmentChecks: boolean }, ): Promise<{ readonly inputs: SandboxMessagingInputReference[]; readonly skipped: boolean; @@ -201,10 +201,10 @@ async function resolveChannelInputs( ); } - if (!skipped && options.runValidation && hasRequiredInputsAvailable(manifest, inputs)) { + if (!skipped && options.runEnrollmentChecks && hasRequiredInputsAvailable(manifest, inputs)) { for (const hook of manifest.hooks .filter((entry) => isHookForAgent(entry, context.agent)) - .filter((entry) => entry.phase === "validation") + .filter((entry) => entry.phase === "reachability-check") .filter((entry) => hasDeclaredHookInputs(hookInputs, entry))) { await runCompilerHook(manifest, hook, hooks, hookInputs); } diff --git a/src/lib/messaging/manifest/types.test.ts b/src/lib/messaging/manifest/types.test.ts index 15d7ddf4f7..c616b6571b 100644 --- a/src/lib/messaging/manifest/types.test.ts +++ b/src/lib/messaging/manifest/types.test.ts @@ -259,7 +259,7 @@ const telegramPlan = { healthChecks: [ { channelId: "telegram", - phase: "validation", + phase: "health-check", requiredBefore: "lifecycle-success", hookIds: [], }, diff --git a/src/lib/messaging/manifest/types.ts b/src/lib/messaging/manifest/types.ts index 98b9059ed4..684300f16e 100644 --- a/src/lib/messaging/manifest/types.ts +++ b/src/lib/messaging/manifest/types.ts @@ -145,9 +145,10 @@ export interface ChannelRebuildHydrationSpec { /** Lifecycle phase where a referenced hook may run. */ export type ChannelHookPhase = | "enroll" - | "validation" + | "reachability-check" | "apply" | "post-agent-install" + | "health-check" | "diagnostic" | "status"; @@ -333,10 +334,10 @@ export interface SandboxMessagingRebuildHydrationStateUpdatePlan { readonly env: string; } -/** Validation gates that must run before a lifecycle can report success. */ +/** Health gates that must run before a lifecycle can report success. */ export interface SandboxMessagingHealthCheckPlan { readonly channelId: MessagingChannelId; - readonly phase: "validation"; + readonly phase: "health-check"; readonly requiredBefore: "lifecycle-success"; readonly hookIds: readonly string[]; } From 5daef1c1fb708364570b48ab602ad881e61de685 Mon Sep 17 00:00:00 2001 From: San Dang Date: Wed, 27 May 2026 15:08:19 +0700 Subject: [PATCH 18/20] test(messaging): fix Hermes env helper calls --- src/lib/messaging/channels/manifests.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/lib/messaging/channels/manifests.test.ts b/src/lib/messaging/channels/manifests.test.ts index 4a0706c88c..3f3a3c609b 100644 --- a/src/lib/messaging/channels/manifests.test.ts +++ b/src/lib/messaging/channels/manifests.test.ts @@ -169,6 +169,7 @@ describe("built-in channel manifests", () => { { telegram: ["123456789"] }, {}, {}, + {}, ); expect(getChannelTokenKeys(KNOWN_CHANNELS.telegram)).toEqual(["TELEGRAM_BOT_TOKEN"]); @@ -218,6 +219,7 @@ describe("built-in channel manifests", () => { }, }, {}, + {}, ); expect(getChannelTokenKeys(KNOWN_CHANNELS.discord)).toEqual(["DISCORD_BOT_TOKEN"]); @@ -264,6 +266,7 @@ describe("built-in channel manifests", () => { { slack: ["U0123456789"] }, {}, {}, + {}, ); expect(getChannelTokenKeys(KNOWN_CHANNELS.slack)).toEqual([ @@ -317,6 +320,7 @@ describe("built-in channel manifests", () => { baseUrl: "https://ilinkai.wechat.com", userId: "operator_self_id", }, + {}, ); expect(getChannelTokenKeys(KNOWN_CHANNELS.wechat)).toEqual(["WECHAT_BOT_TOKEN"]); @@ -393,7 +397,7 @@ 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"); const hermesRender = findRender(whatsappManifest, "whatsapp-hermes-env"); - const hermesLines = buildMessagingEnvLines(new Set(["whatsapp"]), {}, {}, {}); + const hermesLines = buildMessagingEnvLines(new Set(["whatsapp"]), {}, {}, {}, {}); expect(getChannelTokenKeys(KNOWN_CHANNELS.whatsapp)).toEqual([]); expect(whatsappManifest.auth.mode).toBe("in-sandbox-qr"); From 47de166ff7ee75ee43fb8c4eefc9c56d6f86a238 Mon Sep 17 00:00:00 2001 From: San Dang Date: Fri, 29 May 2026 12:59:12 +0700 Subject: [PATCH 19/20] refactor(messaging): simplify workflow planner --- src/lib/messaging/applier/agent-config.ts | 11 +- .../messaging/applier/openshell-provider.ts | 3 +- src/lib/messaging/applier/plan-filter.ts | 34 ++++ src/lib/messaging/applier/policy.ts | 8 +- .../messaging/applier/setup-applier.test.ts | 113 +++++++++++-- src/lib/messaging/applier/setup-applier.ts | 1 + .../compiler/manifest-compiler.test.ts | 61 +++++-- .../messaging/compiler/manifest-compiler.ts | 27 +-- .../compiler/workflow-planner.test.ts | 86 ++++++---- .../messaging/compiler/workflow-planner.ts | 159 ++---------------- src/lib/messaging/manifest/types.test.ts | 1 + src/lib/messaging/manifest/types.ts | 1 + 12 files changed, 281 insertions(+), 224 deletions(-) create mode 100644 src/lib/messaging/applier/plan-filter.ts diff --git a/src/lib/messaging/applier/agent-config.ts b/src/lib/messaging/applier/agent-config.ts index 351066cf60..fd4026df24 100644 --- a/src/lib/messaging/applier/agent-config.ts +++ b/src/lib/messaging/applier/agent-config.ts @@ -22,6 +22,7 @@ import type { MessagingHookApplyRunner, MessagingOpenShellRunner, } from "./types"; +import { enabledPlanChannels, filterEnabledPlanEntries } from "./plan-filter"; const AGENT_CONFIG_HOOK_PHASES = new Set([ "apply", @@ -32,7 +33,7 @@ export function listHookRequests( plan: SandboxMessagingPlan, phase?: ChannelHookPhase, ): MessagingHookApplyRequest[] { - return plan.channels.flatMap((channel) => + return enabledPlanChannels(plan).flatMap((channel) => channel.hooks .filter((hook) => !phase || hook.phase === phase) .map((hook) => toHookApplyRequest(plan, channel, hook)), @@ -64,7 +65,9 @@ export async function applyAgentConfigAtOpenShell( }); } - for (const [target, render] of groupRenderByTarget(plan.agentRender)) { + const enabledRender = filterEnabledPlanEntries(plan, plan.agentRender); + + for (const [target, render] of groupRenderByTarget(enabledRender)) { const resolvedTarget = resolveSandboxAgentConfigTarget(target, plan.agent); const kind = render[0]?.kind; if (!kind) continue; @@ -95,7 +98,7 @@ export async function applyAgentConfigAtOpenShell( appliedTargets: uniqueStrings(appliedTargets), appliedHooks, unresolvedTemplateRefs: uniqueStrings( - plan.agentRender.flatMap((render) => render.templateRefs), + enabledRender.flatMap((render) => render.templateRefs), ), }; } @@ -104,7 +107,7 @@ function hookRequestsForPhases( plan: SandboxMessagingPlan, phases: ReadonlySet, ): MessagingHookApplyRequest[] { - return plan.channels.flatMap((channel) => + return enabledPlanChannels(plan).flatMap((channel) => channel.hooks .filter((hook) => phases.has(hook.phase)) .map((hook) => toHookApplyRequest(plan, channel, hook)), diff --git a/src/lib/messaging/applier/openshell-provider.ts b/src/lib/messaging/applier/openshell-provider.ts index a4d42df8a4..20cea2219a 100644 --- a/src/lib/messaging/applier/openshell-provider.ts +++ b/src/lib/messaging/applier/openshell-provider.ts @@ -11,6 +11,7 @@ import type { MessagingCredentialApplyResult, MessagingOpenShellRunner, } from "./types"; +import { filterEnabledPlanEntries } from "./plan-filter"; type MessagingCredentialApplyEntry = MessagingCredentialApplyResult["upserted"][number]; type MessagingCredentialReuseEntry = MessagingCredentialApplyResult["reused"][number]; @@ -30,7 +31,7 @@ export function applyCredentialsAtOpenShell( const reused: MessagingCredentialReuseEntry[] = []; const missing: MessagingMissingCredentialEntry[] = []; - for (const binding of plan.credentialBindings) { + for (const binding of filterEnabledPlanEntries(plan, plan.credentialBindings)) { const credential = readCredentialEnv(env, binding.providerEnvKey); if (!credential) { if (providerExistsInGateway(binding.providerName, runOpenshell)) { diff --git a/src/lib/messaging/applier/plan-filter.ts b/src/lib/messaging/applier/plan-filter.ts new file mode 100644 index 0000000000..5e3a2d87d3 --- /dev/null +++ b/src/lib/messaging/applier/plan-filter.ts @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { + MessagingChannelId, + SandboxMessagingChannelPlan, + SandboxMessagingPlan, +} from "../manifest"; + +export function enabledPlanChannels( + plan: SandboxMessagingPlan, +): SandboxMessagingChannelPlan[] { + const disabled = disabledPlanChannelIds(plan); + return plan.channels.filter( + (channel) => + channel.active && !channel.disabled && !disabled.has(channel.channelId), + ); +} + +export function enabledPlanChannelIds(plan: SandboxMessagingPlan): Set { + return new Set(enabledPlanChannels(plan).map((channel) => channel.channelId)); +} + +export function filterEnabledPlanEntries( + plan: SandboxMessagingPlan, + entries: readonly T[], +): T[] { + const enabled = enabledPlanChannelIds(plan); + return entries.filter((entry) => enabled.has(entry.channelId)); +} + +function disabledPlanChannelIds(plan: SandboxMessagingPlan): Set { + return new Set(plan.disabledChannels); +} diff --git a/src/lib/messaging/applier/policy.ts b/src/lib/messaging/applier/policy.ts index 33a91a1389..5a839639bf 100644 --- a/src/lib/messaging/applier/policy.ts +++ b/src/lib/messaging/applier/policy.ts @@ -2,21 +2,23 @@ // SPDX-License-Identifier: Apache-2.0 import type { SandboxMessagingPlan } from "../manifest"; +import { filterEnabledPlanEntries } from "./plan-filter"; import type { MessagingPolicyApplyOptions, MessagingPolicyApplyResult } from "./types"; export function applyPolicyAtOpenShell( plan: SandboxMessagingPlan, options: MessagingPolicyApplyOptions, ): MessagingPolicyApplyResult { - const activePresets = uniqueStrings(plan.networkPolicy.presets); + const activeEntries = filterEnabledPlanEntries(plan, plan.networkPolicy.entries); + const activePresets = uniqueStrings(activeEntries.map((entry) => entry.presetName)); const activePolicyKeys = uniqueStrings( - plan.networkPolicy.entries.flatMap((entry) => entry.policyKeys), + activeEntries.flatMap((entry) => entry.policyKeys), ); if ( activePresets.length > 0 && !options.applyPresets(plan.sandboxName, activePresets, { agent: plan.agent, - entries: plan.networkPolicy.entries, + entries: activeEntries, policyKeys: activePolicyKeys, }) ) { diff --git a/src/lib/messaging/applier/setup-applier.test.ts b/src/lib/messaging/applier/setup-applier.test.ts index b1914e5361..320b323390 100644 --- a/src/lib/messaging/applier/setup-applier.test.ts +++ b/src/lib/messaging/applier/setup-applier.test.ts @@ -93,24 +93,26 @@ function planner(): MessagingWorkflowPlanner { ); } -async function planOnboard( +async function buildOnboardPlan( env: Readonly>, selectedChannels: readonly string[], agent: MessagingAgentId = "openclaw", ): Promise { return withEnv(env, () => - planner().planOnboard({ + planner().buildPlan({ sandboxName: "demo", agent, + workflow: "onboard", isInteractive: false, selectedChannels, + configuredChannels: selectedChannels, }), ); } describe("MessagingSetupApplier", () => { it("stores a serializable SandboxMessagingPlan in env without rejecting repeated aliases", async () => { - const plan = await planOnboard({ TELEGRAM_BOT_TOKEN: "123456:telegram-token" }, [ + const plan = await buildOnboardPlan({ TELEGRAM_BOT_TOKEN: "123456:telegram-token" }, [ "telegram", ]); const repeated = { value: "same" }; @@ -146,7 +148,7 @@ describe("MessagingSetupApplier", () => { }); it("lists hook requests by phase without executing hook implementations", async () => { - const plan = await planOnboard({ WECHAT_ACCOUNT_ID: "wechat-account" }, ["wechat"]); + const plan = await buildOnboardPlan({ WECHAT_ACCOUNT_ID: "wechat-account" }, ["wechat"]); expect(MessagingSetupApplier.listHookRequests(plan, "enroll")).toEqual([ expect.objectContaining({ @@ -169,7 +171,7 @@ describe("MessagingSetupApplier", () => { }); it("upserts OpenShell generic providers from plan credential bindings", async () => { - const plan = await planOnboard( + const plan = await buildOnboardPlan( { TELEGRAM_BOT_TOKEN: "123456:telegram-token", SLACK_BOT_TOKEN: "xoxb-slack-token", @@ -243,7 +245,7 @@ describe("MessagingSetupApplier", () => { }); it("redacts OpenShell provider failure output", async () => { - const plan = await planOnboard({ TELEGRAM_BOT_TOKEN: "tokensecretvalue" }, [ + const plan = await buildOnboardPlan({ TELEGRAM_BOT_TOKEN: "tokensecretvalue" }, [ "telegram", ]); const runOpenshell: MessagingOpenShellRunner = (args) => { @@ -271,7 +273,7 @@ describe("MessagingSetupApplier", () => { }); it("applies agent config render plans into sandbox files through OpenShell", async () => { - const plan = await planOnboard({ TELEGRAM_BOT_TOKEN: "123456:telegram-token" }, [ + const plan = await buildOnboardPlan({ TELEGRAM_BOT_TOKEN: "123456:telegram-token" }, [ "telegram", ]); const files: Record = { @@ -340,8 +342,91 @@ describe("MessagingSetupApplier", () => { ); }); + it("excludes disabled channels at the applier boundary", async () => { + const plan = await withEnv( + { + TELEGRAM_BOT_TOKEN: "123456:telegram-token", + SLACK_BOT_TOKEN: "xoxb-slack-token", + SLACK_APP_TOKEN: "xapp-slack-token", + }, + () => + planner().buildPlan({ + sandboxName: "demo", + agent: "openclaw", + workflow: "rebuild", + isInteractive: false, + configuredChannels: ["telegram", "slack"], + disabledChannels: ["telegram"], + }), + ); + expect(plan.disabledChannels).toEqual(["telegram"]); + expect(plan.credentialBindings.map((binding) => binding.channelId)).toEqual([ + "telegram", + "slack", + "slack", + ]); + expect(plan.networkPolicy.entries.map((entry) => entry.channelId)).toEqual([ + "telegram", + "slack", + ]); + expect( + MessagingSetupApplier.listHookRequests(plan).map((request) => request.channelId), + ).toEqual(["slack"]); + + const providerCalls: string[][] = []; + const credentialResult = MessagingSetupApplier.applyCredentialsAtOpenShell(plan, { + env: { + TELEGRAM_BOT_TOKEN: "123456:telegram-token", + SLACK_BOT_TOKEN: "xoxb-slack-token", + SLACK_APP_TOKEN: "xapp-slack-token", + }, + runOpenshell: (args) => { + providerCalls.push([...args]); + if (args[0] === "provider" && args[1] === "get") return { status: 1 }; + return { status: 0 }; + }, + }); + expect(providerCalls.some((args) => args.includes("demo-telegram-bridge"))).toBe(false); + expect(credentialResult.providerNames).toEqual(["demo-slack-bridge", "demo-slack-app"]); + + const policyCalls: string[][] = []; + const policyResult = MessagingSetupApplier.applyPolicyAtOpenShell(plan, { + applyPresets: (sandboxName, presetNames, context) => { + policyCalls.push([sandboxName, ...presetNames]); + expect(context.entries.map((entry) => entry.channelId)).toEqual(["slack"]); + return true; + }, + }); + expect(policyCalls).toEqual([["demo", "slack"]]); + expect(policyResult.appliedPolicyKeys).toEqual(["slack"]); + + const files: Record = { + "/sandbox/.openclaw/openclaw.json": "{}", + }; + await MessagingSetupApplier.applyAgentConfigAtOpenShell(plan, { + runOpenshell: (args, options) => { + const target = String(args.at(-1)); + if (args.includes("cat") && options?.input === undefined) { + return { status: files[target] === undefined ? 1 : 0, stdout: files[target] ?? "" }; + } + if (options?.input !== undefined) { + files[target] = options.input; + return { status: 0 }; + } + return { status: 1 }; + }, + }); + const openclawConfig = JSON.parse(files["/sandbox/.openclaw/openclaw.json"] ?? "{}"); + expect(openclawConfig.channels.telegram).toBeUndefined(); + expect(openclawConfig.channels.slack.accounts.default).toMatchObject({ + botToken: "xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN", + appToken: "xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN", + enabled: true, + }); + }); + it("runs post-install hook implementations and writes their build-file outputs", async () => { - const plan = await planOnboard( + const plan = await buildOnboardPlan( { WECHAT_ACCOUNT_ID: "wechat-account", WECHAT_BASE_URL: "https://ilinkai.wechat.example", @@ -459,7 +544,7 @@ describe("MessagingSetupApplier", () => { }); it("rejects prototype-polluting build-file merge keys", async () => { - const plan = await planOnboard({ WECHAT_ACCOUNT_ID: "wechat-account" }, ["wechat"]); + const plan = await buildOnboardPlan({ WECHAT_ACCOUNT_ID: "wechat-account" }, ["wechat"]); const files: Record = { "/sandbox/.openclaw/openclaw.json": "{}", }; @@ -501,7 +586,7 @@ describe("MessagingSetupApplier", () => { }); it("rejects prototype-polluting JSON render paths", async () => { - const plan = await planOnboard({ TELEGRAM_BOT_TOKEN: "123456:telegram-token" }, [ + const plan = await buildOnboardPlan({ TELEGRAM_BOT_TOKEN: "123456:telegram-token" }, [ "telegram", ]); const unsafePlan = { @@ -532,7 +617,7 @@ describe("MessagingSetupApplier", () => { }); it("rejects render targets outside the selected agent config root", async () => { - const plan = await planOnboard({ TELEGRAM_BOT_TOKEN: "123456:telegram-token" }, [ + const plan = await buildOnboardPlan({ TELEGRAM_BOT_TOKEN: "123456:telegram-token" }, [ "telegram", ]); const runOpenshell: MessagingOpenShellRunner = (args, options) => { @@ -570,7 +655,7 @@ describe("MessagingSetupApplier", () => { }); it("rejects unsafe build-file hook output paths and modes", async () => { - const plan = await planOnboard({ WECHAT_ACCOUNT_ID: "wechat-account" }, ["wechat"]); + const plan = await buildOnboardPlan({ WECHAT_ACCOUNT_ID: "wechat-account" }, ["wechat"]); const runOpenshell: MessagingOpenShellRunner = (args, options) => { if (args.includes("cat") && options?.input === undefined) { return { status: 0, stdout: "{}" }; @@ -625,7 +710,7 @@ describe("MessagingSetupApplier", () => { }); it("applies policy presets directly from the serializable plan", async () => { - const plan = await planOnboard({ TELEGRAM_BOT_TOKEN: "123456:telegram-token" }, [ + const plan = await buildOnboardPlan({ TELEGRAM_BOT_TOKEN: "123456:telegram-token" }, [ "telegram", ]); const policyCalls: string[][] = []; @@ -645,7 +730,7 @@ describe("MessagingSetupApplier", () => { }); it("passes concrete policy keys for agent-aware preset application", async () => { - const plan = await planOnboard( + const plan = await buildOnboardPlan( { DISCORD_BOT_TOKEN: "test-discord-token", WECHAT_BOT_TOKEN: "test-wechat-token", diff --git a/src/lib/messaging/applier/setup-applier.ts b/src/lib/messaging/applier/setup-applier.ts index 658b17e238..e0b5e11834 100644 --- a/src/lib/messaging/applier/setup-applier.ts +++ b/src/lib/messaging/applier/setup-applier.ts @@ -111,6 +111,7 @@ function assertSandboxMessagingPlan(value: unknown): asserts value is SandboxMes typeof value.agent !== "string" || typeof value.workflow !== "string" || !Array.isArray(value.channels) || + !Array.isArray(value.disabledChannels) || !Array.isArray(value.credentialBindings) || !isObject(value.networkPolicy) || !Array.isArray(value.agentRender) || diff --git a/src/lib/messaging/compiler/manifest-compiler.test.ts b/src/lib/messaging/compiler/manifest-compiler.test.ts index c31ab51300..bd1bc4c9fc 100644 --- a/src/lib/messaging/compiler/manifest-compiler.test.ts +++ b/src/lib/messaging/compiler/manifest-compiler.test.ts @@ -298,7 +298,7 @@ describe("ManifestCompiler", () => { }); }); - it("disables a channel and suppresses side effects when enrollment opts to skip it", async () => { + it("disables a channel when enrollment opts to skip it", async () => { const hooks = new MessagingHookRegistry([ { id: "common.tokenPaste", @@ -332,13 +332,29 @@ describe("ManifestCompiler", () => { configured: false, disabled: true, }); - expect(plan.channels[0]?.hooks).toEqual([]); - expect(plan.credentialBindings).toEqual([]); - expect(plan.networkPolicy.entries).toEqual([]); - expect(plan.agentRender).toEqual([]); + expect(plan.disabledChannels).toEqual(["telegram"]); + expect(plan.channels[0]?.hooks.map((hook) => hook.id)).toEqual([ + "telegram-token-paste", + "telegram-reachability", + ]); + expect(plan.credentialBindings.map((binding) => binding.channelId)).toEqual([ + "telegram", + ]); + expect(plan.networkPolicy.entries.map((entry) => entry.channelId)).toEqual([ + "telegram", + ]); + expect(plan.agentRender.map((render) => render.channelId)).toEqual([ + "telegram", + "telegram", + ]); expect(plan.buildSteps).toEqual([]); - expect(plan.stateUpdates).toEqual([]); - expect(plan.healthChecks).toEqual([]); + expect(plan.stateUpdates.map((entry) => entry.channelId)).toEqual([ + "telegram", + "telegram", + "telegram", + "telegram", + ]); + expect(plan.healthChecks.map((entry) => entry.channelId)).toEqual(["telegram"]); }); it("skips token-paste and QR enrollment hooks for non-interactive create plans", async () => { @@ -428,6 +444,7 @@ describe("ManifestCompiler", () => { "agent", "workflow", "channels", + "disabledChannels", "credentialBindings", "networkPolicy", "agentRender", @@ -437,7 +454,7 @@ describe("ManifestCompiler", () => { ] satisfies Array); }); - it("records disabled configured channels without planning side effects for them", async () => { + it("records disabled configured channels and leaves applier exclusion to disabledChannels", async () => { const plan = await compiler().compile({ sandboxName: "demo", agent: "openclaw", @@ -455,13 +472,29 @@ describe("ManifestCompiler", () => { configured: true, disabled: true, }); - expect(plan.credentialBindings).toEqual([]); - expect(plan.networkPolicy.entries).toEqual([]); - expect(plan.agentRender).toEqual([]); + expect(plan.disabledChannels).toEqual(["telegram"]); + expect(plan.credentialBindings.map((binding) => binding.channelId)).toEqual([ + "telegram", + ]); + expect(plan.networkPolicy.entries.map((entry) => entry.channelId)).toEqual([ + "telegram", + ]); + expect(plan.agentRender.map((render) => render.channelId)).toEqual([ + "telegram", + "telegram", + ]); expect(plan.buildSteps).toEqual([]); - expect(plan.stateUpdates).toEqual([]); - expect(plan.healthChecks).toEqual([]); - expect(plan.channels[0]?.hooks).toEqual([]); + expect(plan.stateUpdates.map((entry) => entry.channelId)).toEqual([ + "telegram", + "telegram", + "telegram", + "telegram", + ]); + expect(plan.healthChecks.map((entry) => entry.channelId)).toEqual(["telegram"]); + expect(plan.channels[0]?.hooks.map((hook) => hook.id)).toEqual([ + "telegram-token-paste", + "telegram-reachability", + ]); }); it("compiles a non-built-in channel manifest through the same generic path", async () => { diff --git a/src/lib/messaging/compiler/manifest-compiler.ts b/src/lib/messaging/compiler/manifest-compiler.ts index 2ff8cad62a..2ef3755f54 100644 --- a/src/lib/messaging/compiler/manifest-compiler.ts +++ b/src/lib/messaging/compiler/manifest-compiler.ts @@ -36,29 +36,28 @@ export class ManifestCompiler { async compile(context: ManifestCompilerContext): Promise { const manifests = this.resolveManifests(requestedChannelIds(context), context); - const channels = []; + const channels: SandboxMessagingChannelPlan[] = []; for (const manifest of manifests) { channels.push(await this.compileChannel(manifest, context)); } const inputRegistry = new Map( channels.map((channel) => [channel.channelId, channel.inputs] as const), ); - const activeChannelIds = new Set( - channels.filter((channel) => channel.active).map((channel) => channel.channelId), - ); - const activeManifests = manifests.filter((manifest) => activeChannelIds.has(manifest.id)); - const credentialBindings = activeManifests.flatMap((manifest) => + const disabledChannels = channels + .filter((channel) => channel.disabled) + .map((channel) => channel.channelId); + const credentialBindings = manifests.flatMap((manifest) => planCredentialBindings(manifest, context, inputRegistry.get(manifest.id) ?? []), ); - const networkPolicy = planNetworkPolicy(activeManifests, context); - const agentRender = activeManifests.flatMap((manifest) => + const networkPolicy = planNetworkPolicy(manifests, context); + const agentRender = manifests.flatMap((manifest) => planAgentRender(manifest, context), ); - const buildSteps = activeManifests.flatMap((manifest) => + const buildSteps = manifests.flatMap((manifest) => planBuildSteps(manifest, context.agent), ); - const stateUpdates = activeManifests.flatMap((manifest) => planStateUpdates(manifest)); - const healthChecks = activeManifests.flatMap((manifest) => planHealthChecks(manifest)); + const stateUpdates = manifests.flatMap((manifest) => planStateUpdates(manifest)); + const healthChecks = manifests.flatMap((manifest) => planHealthChecks(manifest)); return { schemaVersion: 1, @@ -66,6 +65,7 @@ export class ManifestCompiler { agent: context.agent, workflow: context.workflow, channels, + disabledChannels, credentialBindings, networkPolicy, agentRender, @@ -107,7 +107,8 @@ export class ManifestCompiler { const selected = context.selectedChannels.includes(manifest.id); const configured = context.configuredChannels?.includes(manifest.id) ?? false; const disabled = context.disabledChannels?.includes(manifest.id) ?? false; - const requestedActive = !disabled && (selected || configured); + const requested = selected || configured; + const requestedActive = !disabled && requested; const resolvedInputs = await resolveChannelInputs(manifest, context, this.hooks, { runEnrollment: selected && @@ -127,7 +128,7 @@ export class ManifestCompiler { configured: configured && !resolvedInputs.skipped, disabled: disabled || resolvedInputs.skipped, inputs: resolvedInputs.inputs, - hooks: active + hooks: requested ? manifest.hooks .filter((hook) => isHookForAgent(hook, context.agent)) .map((hook) => cloneHookReference(manifest.id, hook)) diff --git a/src/lib/messaging/compiler/workflow-planner.test.ts b/src/lib/messaging/compiler/workflow-planner.test.ts index 1a23195ab1..645c261f6a 100644 --- a/src/lib/messaging/compiler/workflow-planner.test.ts +++ b/src/lib/messaging/compiler/workflow-planner.test.ts @@ -102,15 +102,18 @@ async function withEnv( } describe("MessagingWorkflowPlanner", () => { - it("plans onboard as selected, configured, active channels with enrollment inputs", async () => { - const plan = await planner().planOnboard({ + it("builds onboard plans from selected and configured channels", async () => { + const plan = await planner().buildPlan({ sandboxName: "demo", agent: "openclaw", + workflow: "onboard", isInteractive: true, selectedChannels: ["wechat", "telegram"], + configuredChannels: ["wechat", "telegram"], }); expect(plan.workflow).toBe("onboard"); + expect(plan.disabledChannels).toEqual([]); expect(plan.channels.map((channel) => channel.channelId)).toEqual([ "telegram", "wechat", @@ -145,17 +148,19 @@ describe("MessagingWorkflowPlanner", () => { ]); }); - it("plans add-channel as a configured active target and clears stale disabled state", async () => { - const plan = await planner().planAddChannel({ + it("builds add-channel plans from caller-owned channel state", async () => { + const plan = await planner().buildPlan({ sandboxName: "demo", agent: "openclaw", + workflow: "add-channel", isInteractive: true, - channelId: "slack", - configuredChannels: ["telegram"], - disabledChannels: ["telegram", "slack"], + selectedChannels: ["slack"], + configuredChannels: ["telegram", "slack"], + disabledChannels: ["telegram"], }); expect(plan.workflow).toBe("add-channel"); + expect(plan.disabledChannels).toEqual(["telegram"]); expect(plan.channels.find((channel) => channel.channelId === "telegram")).toMatchObject({ configured: true, disabled: true, @@ -168,7 +173,10 @@ describe("MessagingWorkflowPlanner", () => { active: true, selected: true, }); - expect(plan.networkPolicy.entries.map((entry) => entry.channelId)).toEqual(["slack"]); + expect(plan.networkPolicy.entries.map((entry) => entry.channelId)).toEqual([ + "telegram", + "slack", + ]); }); it("runs add-channel enrollment only for the selected channel", async () => { @@ -195,12 +203,13 @@ describe("MessagingWorkflowPlanner", () => { const plan = await new MessagingWorkflowPlanner( createBuiltInChannelManifestRegistry(), hooks, - ).planAddChannel({ + ).buildPlan({ sandboxName: "demo", agent: "openclaw", + workflow: "add-channel", isInteractive: true, - channelId: "slack", - configuredChannels: ["telegram"], + selectedChannels: ["slack"], + configuredChannels: ["telegram", "slack"], }); expect(plan.channels.find((channel) => channel.channelId === "telegram")).toMatchObject({ @@ -215,16 +224,19 @@ describe("MessagingWorkflowPlanner", () => { ).toBe(true); }); - it("plans stop-channel by keeping configured state and disabling only that channel", async () => { - const plan = await planner().planStopChannel({ + it("records disabled configured channels for stop-channel plans", async () => { + const plan = await planner().buildPlan({ sandboxName: "demo", agent: "openclaw", + workflow: "stop-channel", isInteractive: false, - channelId: "telegram", + selectedChannels: ["telegram"], configuredChannels: ["telegram", "slack"], + disabledChannels: ["telegram"], }); expect(plan.workflow).toBe("stop-channel"); + expect(plan.disabledChannels).toEqual(["telegram"]); expect(plan.channels.find((channel) => channel.channelId === "telegram")).toMatchObject({ configured: true, disabled: true, @@ -237,20 +249,23 @@ describe("MessagingWorkflowPlanner", () => { active: true, selected: false, }); - expect(plan.networkPolicy.entries.map((entry) => entry.channelId)).toEqual(["slack"]); + expect(plan.networkPolicy.entries.map((entry) => entry.channelId)).toEqual([ + "telegram", + "slack", + ]); expect( plan.credentialBindings.some((binding) => binding.channelId === "telegram"), - ).toBe(false); + ).toBe(true); }); - it("plans start-channel by preserving configured state and making the channel active", async () => { - const plan = await planner().planStartChannel({ + it("records re-enabled channels for start-channel plans", async () => { + const plan = await planner().buildPlan({ sandboxName: "demo", agent: "openclaw", + workflow: "start-channel", isInteractive: false, - channelId: "telegram", + selectedChannels: ["telegram"], configuredChannels: ["telegram", "slack"], - disabledChannels: ["telegram"], credentialAvailability: { TELEGRAM_BOT_TOKEN: true, SLACK_BOT_TOKEN: true, @@ -259,6 +274,7 @@ describe("MessagingWorkflowPlanner", () => { }); expect(plan.workflow).toBe("start-channel"); + expect(plan.disabledChannels).toEqual([]); expect(plan.channels.find((channel) => channel.channelId === "telegram")).toMatchObject({ configured: true, disabled: false, @@ -271,17 +287,18 @@ describe("MessagingWorkflowPlanner", () => { ]); }); - it("plans remove-channel by deleting configured and disabled state", async () => { - const plan = await planner().planRemoveChannel({ + it("builds remove-channel plans from the post-removal configured state", async () => { + const plan = await planner().buildPlan({ sandboxName: "demo", agent: "openclaw", + workflow: "remove-channel", isInteractive: false, - channelId: "telegram", - configuredChannels: ["telegram", "wechat", "slack"], - disabledChannels: ["telegram", "wechat"], + configuredChannels: ["wechat", "slack"], + disabledChannels: ["wechat"], }); expect(plan.workflow).toBe("remove-channel"); + expect(plan.disabledChannels).toEqual(["wechat"]); expect(plan.channels.map((channel) => channel.channelId)).toEqual(["wechat", "slack"]); expect(plan.channels.find((channel) => channel.channelId === "telegram")).toBeUndefined(); expect(plan.channels.find((channel) => channel.channelId === "wechat")).toMatchObject({ @@ -289,19 +306,21 @@ describe("MessagingWorkflowPlanner", () => { disabled: true, active: false, }); - expect(plan.networkPolicy.entries.map((entry) => entry.channelId)).toEqual(["slack"]); + expect(plan.networkPolicy.entries.map((entry) => entry.channelId)).toEqual(["wechat", "slack"]); }); - it("plans rebuild from configured and disabled registry snapshots", async () => { - const plan = await planner().planRebuild({ + it("builds rebuild plans from configured and disabled registry snapshots", async () => { + const plan = await planner().buildPlan({ sandboxName: "demo", agent: "openclaw", + workflow: "rebuild", isInteractive: false, configuredChannels: ["telegram", "discord", "wechat"], disabledChannels: ["discord"], }); expect(plan.workflow).toBe("rebuild"); + expect(plan.disabledChannels).toEqual(["discord"]); expect(plan.channels.map((channel) => channel.channelId)).toEqual([ "telegram", "discord", @@ -315,17 +334,20 @@ describe("MessagingWorkflowPlanner", () => { }); expect(plan.networkPolicy.entries.map((entry) => entry.channelId)).toEqual([ "telegram", + "discord", "wechat", ]); }); it("reports unsupported channels deterministically before compiling", async () => { await expect( - planner().planOnboard({ + planner().buildPlan({ sandboxName: "demo", agent: "openclaw", + workflow: "onboard", isInteractive: false, selectedChannels: ["slack", "discord"], + configuredChannels: ["slack", "discord"], supportedChannelIds: ["telegram"], }), ).rejects.toThrow("Unsupported messaging channel(s) for openclaw: discord, slack"); @@ -337,11 +359,13 @@ describe("MessagingWorkflowPlanner", () => { TELEGRAM_BOT_TOKEN: "123456:raw-telegram-token", }, async () => { - const plan = await planner().planAddChannel({ + const plan = await planner().buildPlan({ sandboxName: "demo", agent: "openclaw", + workflow: "add-channel", isInteractive: false, - channelId: "telegram", + selectedChannels: ["telegram"], + configuredChannels: ["telegram"], }); const serialized = JSON.stringify(plan); diff --git a/src/lib/messaging/compiler/workflow-planner.ts b/src/lib/messaging/compiler/workflow-planner.ts index d10b6ba6f2..5de203f2f9 100644 --- a/src/lib/messaging/compiler/workflow-planner.ts +++ b/src/lib/messaging/compiler/workflow-planner.ts @@ -15,26 +15,18 @@ import type { MessagingCompilerCredentialAvailability, } from "./types"; -export interface MessagingWorkflowPlannerBaseContext { +export interface MessagingWorkflowPlannerBuildContext { readonly sandboxName: string; readonly agent: MessagingAgentId; + readonly workflow: MessagingCompilerWorkflow; readonly isInteractive: boolean; + readonly selectedChannels?: readonly MessagingChannelId[]; readonly configuredChannels?: readonly MessagingChannelId[]; readonly disabledChannels?: readonly MessagingChannelId[]; readonly supportedChannelIds?: readonly MessagingChannelId[]; readonly credentialAvailability?: MessagingCompilerCredentialAvailability; } -export interface MessagingWorkflowPlannerOnboardContext - extends MessagingWorkflowPlannerBaseContext { - readonly selectedChannels: readonly MessagingChannelId[]; -} - -export interface MessagingWorkflowPlannerChannelContext - extends MessagingWorkflowPlannerBaseContext { - readonly channelId: MessagingChannelId; -} - export class MessagingWorkflowPlanner { private readonly compiler: ManifestCompiler; @@ -45,131 +37,25 @@ export class MessagingWorkflowPlanner { this.compiler = new ManifestCompiler(registry, hooks); } - async planOnboard( - context: MessagingWorkflowPlannerOnboardContext, + async buildPlan( + context: MessagingWorkflowPlannerBuildContext, ): Promise { const selectedChannels = uniqueChannels(context.selectedChannels); - this.assertSupportedChannels(selectedChannels, context); - - return this.compileWorkflow(context, { - workflow: "onboard", - selectedChannels, - configuredChannels: selectedChannels, - disabledChannels: [], - }); - } - - async planAddChannel( - context: MessagingWorkflowPlannerChannelContext, - ): Promise { - const configuredChannels = addChannels(context.configuredChannels, [context.channelId]); - const disabledChannels = removeChannels( - onlyConfiguredChannels(context.disabledChannels, configuredChannels), - [context.channelId], - ); - this.assertSupportedChannels([...configuredChannels, context.channelId], context); - - return this.compileWorkflow(context, { - workflow: "add-channel", - selectedChannels: [context.channelId], - configuredChannels, - disabledChannels, - }); - } - - async planRemoveChannel( - context: MessagingWorkflowPlannerChannelContext, - ): Promise { - const configuredChannels = removeChannels(context.configuredChannels, [context.channelId]); - const disabledChannels = removeChannels( - onlyConfiguredChannels(context.disabledChannels, configuredChannels), - [context.channelId], - ); - this.assertSupportedChannels([...configuredChannels, context.channelId], context); - - return this.compileWorkflow(context, { - workflow: "remove-channel", - selectedChannels: [], - configuredChannels, - disabledChannels, - }); - } - - async planStartChannel( - context: MessagingWorkflowPlannerChannelContext, - ): Promise { - const configuredChannels = uniqueChannels(context.configuredChannels); - const selectedChannels = configuredChannels.includes(context.channelId) - ? [context.channelId] - : []; - const disabledChannels = removeChannels( - onlyConfiguredChannels(context.disabledChannels, configuredChannels), - [context.channelId], - ); - this.assertSupportedChannels([...configuredChannels, context.channelId], context); - - return this.compileWorkflow(context, { - workflow: "start-channel", - selectedChannels, - configuredChannels, - disabledChannels, - }); - } - - async planStopChannel( - context: MessagingWorkflowPlannerChannelContext, - ): Promise { - const configuredChannels = uniqueChannels(context.configuredChannels); - const selectedChannels = configuredChannels.includes(context.channelId) - ? [context.channelId] - : []; - const disabledChannels = configuredChannels.includes(context.channelId) - ? addChannels(onlyConfiguredChannels(context.disabledChannels, configuredChannels), [ - context.channelId, - ]) - : onlyConfiguredChannels(context.disabledChannels, configuredChannels); - this.assertSupportedChannels([...configuredChannels, context.channelId], context); - - return this.compileWorkflow(context, { - workflow: "stop-channel", - selectedChannels, - configuredChannels, - disabledChannels, - }); - } - - async planRebuild( - context: MessagingWorkflowPlannerBaseContext, - ): Promise { const configuredChannels = uniqueChannels(context.configuredChannels); const disabledChannels = onlyConfiguredChannels(context.disabledChannels, configuredChannels); - this.assertSupportedChannels(configuredChannels, context); - - return this.compileWorkflow(context, { - workflow: "rebuild", - selectedChannels: [], - configuredChannels, - disabledChannels, - }); - } + this.assertSupportedChannels( + [...selectedChannels, ...configuredChannels, ...disabledChannels], + context, + ); - private compileWorkflow( - context: MessagingWorkflowPlannerBaseContext, - workflow: { - readonly workflow: MessagingCompilerWorkflow; - readonly selectedChannels: readonly MessagingChannelId[]; - readonly configuredChannels: readonly MessagingChannelId[]; - readonly disabledChannels: readonly MessagingChannelId[]; - }, - ): Promise { const compilerContext: ManifestCompilerContext = { sandboxName: context.sandboxName, agent: context.agent, isInteractive: context.isInteractive, - workflow: workflow.workflow, - selectedChannels: workflow.selectedChannels, - configuredChannels: workflow.configuredChannels, - disabledChannels: workflow.disabledChannels, + workflow: context.workflow, + selectedChannels, + configuredChannels, + disabledChannels, supportedChannelIds: context.supportedChannelIds, credentialAvailability: context.credentialAvailability, }; @@ -179,7 +65,7 @@ export class MessagingWorkflowPlanner { private assertSupportedChannels( channelIds: readonly MessagingChannelId[], context: Pick< - MessagingWorkflowPlannerBaseContext, + MessagingWorkflowPlannerBuildContext, "agent" | "supportedChannelIds" >, ): void { @@ -197,7 +83,7 @@ export class MessagingWorkflowPlanner { private supportedChannelIds( context: Pick< - MessagingWorkflowPlannerBaseContext, + MessagingWorkflowPlannerBuildContext, "agent" | "supportedChannelIds" >, ): MessagingChannelId[] { @@ -220,21 +106,6 @@ function uniqueChannels( return [...new Set(channelIds ?? [])]; } -function addChannels( - current: readonly MessagingChannelId[] | undefined, - additions: readonly MessagingChannelId[], -): MessagingChannelId[] { - return uniqueChannels([...(current ?? []), ...additions]); -} - -function removeChannels( - current: readonly MessagingChannelId[] | undefined, - removals: readonly MessagingChannelId[], -): MessagingChannelId[] { - const remove = new Set(removals); - return uniqueChannels(current).filter((channelId) => !remove.has(channelId)); -} - function onlyConfiguredChannels( channelIds: readonly MessagingChannelId[] | undefined, configuredChannels: readonly MessagingChannelId[], diff --git a/src/lib/messaging/manifest/types.test.ts b/src/lib/messaging/manifest/types.test.ts index c616b6571b..9a37caa158 100644 --- a/src/lib/messaging/manifest/types.test.ts +++ b/src/lib/messaging/manifest/types.test.ts @@ -217,6 +217,7 @@ const telegramPlan = { hooks: [], }, ], + disabledChannels: [], credentialBindings: [ { channelId: "telegram", diff --git a/src/lib/messaging/manifest/types.ts b/src/lib/messaging/manifest/types.ts index 684300f16e..1f9c0623aa 100644 --- a/src/lib/messaging/manifest/types.ts +++ b/src/lib/messaging/manifest/types.ts @@ -180,6 +180,7 @@ export interface SandboxMessagingPlan { readonly agent: MessagingAgentId; readonly workflow: MessagingCompilerWorkflow; readonly channels: readonly SandboxMessagingChannelPlan[]; + readonly disabledChannels: readonly MessagingChannelId[]; readonly credentialBindings: readonly SandboxMessagingCredentialBindingPlan[]; readonly networkPolicy: SandboxMessagingNetworkPolicyPlan; readonly agentRender: readonly SandboxMessagingAgentRenderPlan[]; From 7d05b57fb711f6aac77c9d375363f3bdead90d76 Mon Sep 17 00:00:00 2001 From: San Dang Date: Fri, 29 May 2026 13:53:08 +0700 Subject: [PATCH 20/20] refactor(messaging): simplify channel compiler context --- .../messaging/applier/setup-applier.test.ts | 5 ++--- .../compiler/manifest-compiler.test.ts | 16 ++++++-------- .../messaging/compiler/manifest-compiler.ts | 8 +++---- src/lib/messaging/compiler/types.ts | 3 +-- .../compiler/workflow-planner.test.ts | 22 +++++++------------ .../messaging/compiler/workflow-planner.ts | 8 +------ 6 files changed, 23 insertions(+), 39 deletions(-) diff --git a/src/lib/messaging/applier/setup-applier.test.ts b/src/lib/messaging/applier/setup-applier.test.ts index 320b323390..b86c7fa28c 100644 --- a/src/lib/messaging/applier/setup-applier.test.ts +++ b/src/lib/messaging/applier/setup-applier.test.ts @@ -95,7 +95,7 @@ function planner(): MessagingWorkflowPlanner { async function buildOnboardPlan( env: Readonly>, - selectedChannels: readonly string[], + configuredChannels: readonly string[], agent: MessagingAgentId = "openclaw", ): Promise { return withEnv(env, () => @@ -104,8 +104,7 @@ async function buildOnboardPlan( agent, workflow: "onboard", isInteractive: false, - selectedChannels, - configuredChannels: selectedChannels, + configuredChannels, }), ); } diff --git a/src/lib/messaging/compiler/manifest-compiler.test.ts b/src/lib/messaging/compiler/manifest-compiler.test.ts index bd1bc4c9fc..237a0b94bc 100644 --- a/src/lib/messaging/compiler/manifest-compiler.test.ts +++ b/src/lib/messaging/compiler/manifest-compiler.test.ts @@ -118,7 +118,7 @@ describe("ManifestCompiler", () => { agent: "openclaw", workflow: "onboard", isInteractive: true, - selectedChannels: ["slack", "telegram", "wechat", "discord", "whatsapp"], + configuredChannels: ["slack", "telegram", "wechat", "discord", "whatsapp"], credentialAvailability: { TELEGRAM_BOT_TOKEN: true, DISCORD_BOT_TOKEN: true, @@ -239,7 +239,7 @@ describe("ManifestCompiler", () => { agent: "hermes", workflow: "rebuild", isInteractive: false, - selectedChannels: ALL_CHANNELS, + configuredChannels: ALL_CHANNELS, }); expect(plan.networkPolicy.entries.find((entry) => entry.channelId === "wechat")).toEqual({ @@ -274,7 +274,7 @@ describe("ManifestCompiler", () => { agent: "openclaw", workflow: "onboard", isInteractive: true, - selectedChannels: ["wechat", "telegram"], + configuredChannels: ["wechat", "telegram"], }); const telegram = plan.channels.find((channel) => channel.channelId === "telegram"); @@ -321,7 +321,6 @@ describe("ManifestCompiler", () => { agent: "openclaw", workflow: "onboard", isInteractive: true, - selectedChannels: ["telegram"], configuredChannels: ["telegram"], }); @@ -378,7 +377,7 @@ describe("ManifestCompiler", () => { agent: "openclaw", workflow: "onboard", isInteractive: false, - selectedChannels: ["telegram"], + configuredChannels: ["telegram"], credentialAvailability: { TELEGRAM_BOT_TOKEN: true, }, @@ -402,7 +401,7 @@ describe("ManifestCompiler", () => { agent: "openclaw", workflow: "onboard", isInteractive: false, - selectedChannels: ["telegram"], + configuredChannels: ["telegram"], }); expect(plan.channels[0]?.inputs.find((input) => input.inputId === "botToken")).toMatchObject({ @@ -424,7 +423,7 @@ describe("ManifestCompiler", () => { agent: "openclaw", workflow: "onboard", isInteractive: false, - selectedChannels: ["telegram"], + configuredChannels: ["telegram"], credentialAvailability: { TELEGRAM_BOT_TOKEN: true, }, @@ -460,7 +459,6 @@ describe("ManifestCompiler", () => { agent: "openclaw", workflow: "stop-channel", isInteractive: false, - selectedChannels: [], configuredChannels: ["telegram"], disabledChannels: ["telegram"], }); @@ -595,7 +593,7 @@ describe("ManifestCompiler", () => { agent: "openclaw", workflow: "onboard", isInteractive: true, - selectedChannels: ["matrix"], + configuredChannels: ["matrix"], }); expect(plan.channels.map((channel) => channel.channelId)).toEqual(["matrix"]); diff --git a/src/lib/messaging/compiler/manifest-compiler.ts b/src/lib/messaging/compiler/manifest-compiler.ts index 2ef3755f54..dd6863236a 100644 --- a/src/lib/messaging/compiler/manifest-compiler.ts +++ b/src/lib/messaging/compiler/manifest-compiler.ts @@ -104,10 +104,10 @@ export class ManifestCompiler { manifest: ChannelManifest, context: ManifestCompilerContext, ): Promise { - const selected = context.selectedChannels.includes(manifest.id); - const configured = context.configuredChannels?.includes(manifest.id) ?? false; + const configured = context.configuredChannels.includes(manifest.id); const disabled = context.disabledChannels?.includes(manifest.id) ?? false; - const requested = selected || configured; + const selected = configured; + const requested = configured; const requestedActive = !disabled && requested; const resolvedInputs = await resolveChannelInputs(manifest, context, this.hooks, { runEnrollment: @@ -142,7 +142,7 @@ function isHookForAgent(hook: ChannelHookSpec, agent: ManifestCompilerContext["a } function requestedChannelIds(context: ManifestCompilerContext): MessagingChannelId[] { - return uniqueChannels([...context.selectedChannels, ...(context.configuredChannels ?? [])]); + return uniqueChannels(context.configuredChannels); } function uniqueChannels(channelIds: readonly MessagingChannelId[]): MessagingChannelId[] { diff --git a/src/lib/messaging/compiler/types.ts b/src/lib/messaging/compiler/types.ts index 3dbd030c6d..9cb47f0fcf 100644 --- a/src/lib/messaging/compiler/types.ts +++ b/src/lib/messaging/compiler/types.ts @@ -16,8 +16,7 @@ export interface ManifestCompilerContext { readonly agent: MessagingAgentId; readonly workflow: MessagingCompilerWorkflow; readonly isInteractive: boolean; - readonly selectedChannels: readonly MessagingChannelId[]; - readonly configuredChannels?: readonly MessagingChannelId[]; + readonly configuredChannels: readonly MessagingChannelId[]; readonly disabledChannels?: readonly MessagingChannelId[]; readonly supportedChannelIds?: readonly MessagingChannelId[]; readonly credentialAvailability?: MessagingCompilerCredentialAvailability; diff --git a/src/lib/messaging/compiler/workflow-planner.test.ts b/src/lib/messaging/compiler/workflow-planner.test.ts index 645c261f6a..2ad6252f59 100644 --- a/src/lib/messaging/compiler/workflow-planner.test.ts +++ b/src/lib/messaging/compiler/workflow-planner.test.ts @@ -102,13 +102,12 @@ async function withEnv( } describe("MessagingWorkflowPlanner", () => { - it("builds onboard plans from selected and configured channels", async () => { + it("builds onboard plans from configured channels", async () => { const plan = await planner().buildPlan({ sandboxName: "demo", agent: "openclaw", workflow: "onboard", isInteractive: true, - selectedChannels: ["wechat", "telegram"], configuredChannels: ["wechat", "telegram"], }); @@ -154,7 +153,6 @@ describe("MessagingWorkflowPlanner", () => { agent: "openclaw", workflow: "add-channel", isInteractive: true, - selectedChannels: ["slack"], configuredChannels: ["telegram", "slack"], disabledChannels: ["telegram"], }); @@ -165,7 +163,7 @@ describe("MessagingWorkflowPlanner", () => { configured: true, disabled: true, active: false, - selected: false, + selected: true, }); expect(plan.channels.find((channel) => channel.channelId === "slack")).toMatchObject({ configured: true, @@ -179,7 +177,7 @@ describe("MessagingWorkflowPlanner", () => { ]); }); - it("runs add-channel enrollment only for the selected channel", async () => { + it("runs add-channel enrollment only for active configured channels", async () => { const hooks = new MessagingHookRegistry([ { id: "common.tokenPaste", @@ -208,13 +206,13 @@ describe("MessagingWorkflowPlanner", () => { agent: "openclaw", workflow: "add-channel", isInteractive: true, - selectedChannels: ["slack"], configuredChannels: ["telegram", "slack"], + disabledChannels: ["telegram"], }); expect(plan.channels.find((channel) => channel.channelId === "telegram")).toMatchObject({ - active: true, - selected: false, + active: false, + selected: true, }); expect( plan.channels @@ -230,7 +228,6 @@ describe("MessagingWorkflowPlanner", () => { agent: "openclaw", workflow: "stop-channel", isInteractive: false, - selectedChannels: ["telegram"], configuredChannels: ["telegram", "slack"], disabledChannels: ["telegram"], }); @@ -247,7 +244,7 @@ describe("MessagingWorkflowPlanner", () => { configured: true, disabled: false, active: true, - selected: false, + selected: true, }); expect(plan.networkPolicy.entries.map((entry) => entry.channelId)).toEqual([ "telegram", @@ -264,7 +261,6 @@ describe("MessagingWorkflowPlanner", () => { agent: "openclaw", workflow: "start-channel", isInteractive: false, - selectedChannels: ["telegram"], configuredChannels: ["telegram", "slack"], credentialAvailability: { TELEGRAM_BOT_TOKEN: true, @@ -330,7 +326,7 @@ describe("MessagingWorkflowPlanner", () => { configured: true, disabled: true, active: false, - selected: false, + selected: true, }); expect(plan.networkPolicy.entries.map((entry) => entry.channelId)).toEqual([ "telegram", @@ -346,7 +342,6 @@ describe("MessagingWorkflowPlanner", () => { agent: "openclaw", workflow: "onboard", isInteractive: false, - selectedChannels: ["slack", "discord"], configuredChannels: ["slack", "discord"], supportedChannelIds: ["telegram"], }), @@ -364,7 +359,6 @@ describe("MessagingWorkflowPlanner", () => { agent: "openclaw", workflow: "add-channel", isInteractive: false, - selectedChannels: ["telegram"], configuredChannels: ["telegram"], }); const serialized = JSON.stringify(plan); diff --git a/src/lib/messaging/compiler/workflow-planner.ts b/src/lib/messaging/compiler/workflow-planner.ts index 5de203f2f9..8371b2d1ad 100644 --- a/src/lib/messaging/compiler/workflow-planner.ts +++ b/src/lib/messaging/compiler/workflow-planner.ts @@ -20,7 +20,6 @@ export interface MessagingWorkflowPlannerBuildContext { readonly agent: MessagingAgentId; readonly workflow: MessagingCompilerWorkflow; readonly isInteractive: boolean; - readonly selectedChannels?: readonly MessagingChannelId[]; readonly configuredChannels?: readonly MessagingChannelId[]; readonly disabledChannels?: readonly MessagingChannelId[]; readonly supportedChannelIds?: readonly MessagingChannelId[]; @@ -40,20 +39,15 @@ export class MessagingWorkflowPlanner { async buildPlan( context: MessagingWorkflowPlannerBuildContext, ): Promise { - const selectedChannels = uniqueChannels(context.selectedChannels); const configuredChannels = uniqueChannels(context.configuredChannels); const disabledChannels = onlyConfiguredChannels(context.disabledChannels, configuredChannels); - this.assertSupportedChannels( - [...selectedChannels, ...configuredChannels, ...disabledChannels], - context, - ); + this.assertSupportedChannels(configuredChannels, context); const compilerContext: ManifestCompilerContext = { sandboxName: context.sandboxName, agent: context.agent, isInteractive: context.isInteractive, workflow: context.workflow, - selectedChannels, configuredChannels, disabledChannels, supportedChannelIds: context.supportedChannelIds,