From bb83755c2c95763da65110466bedb4cb35bfc4b7 Mon Sep 17 00:00:00 2001 From: San Dang Date: Fri, 22 May 2026 10:03:19 +0700 Subject: [PATCH 01/40] 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/40] 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/40] 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/40] 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/40] 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/40] 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/40] 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/40] 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/40] 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/40] 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/40] 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/40] 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/40] 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/40] 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/40] 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/40] 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/40] 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 6cd7dd36d6d4655345979167b043b2b77147b6d0 Mon Sep 17 00:00:00 2001 From: San Dang Date: Tue, 26 May 2026 18:14:49 +0700 Subject: [PATCH 18/40] feat(messaging): migrate enrollment to manifest hooks Closes #4247 --- src/lib/actions/sandbox/policy-channel.ts | 10 +- .../messaging/applier/setup-applier.test.ts | 78 +-- .../messaging/channels/discord/manifest.ts | 23 + src/lib/messaging/channels/manifests.test.ts | 37 +- src/lib/messaging/channels/slack/manifest.ts | 16 + .../hooks/get-me-reachability.test.ts | 22 +- .../messaging/channels/telegram/manifest.ts | 23 +- .../wechat/hooks/host-qr-login-runtime.ts | 64 +++ .../channels/wechat/hooks/ilink-login.ts | 13 +- .../wechat/hooks/implementations.test.ts | 6 +- .../messaging/channels/wechat/hooks/index.ts | 7 +- src/lib/messaging/channels/wechat/manifest.ts | 14 + .../messaging/channels/whatsapp/manifest.ts | 2 + .../compiler/manifest-compiler.test.ts | 3 +- .../messaging/compiler/manifest-compiler.ts | 43 +- .../compiler/workflow-planner.test.ts | 190 +++---- .../messaging/compiler/workflow-planner.ts | 9 +- src/lib/messaging/hooks/builtins.ts | 11 +- .../hooks/common/config-prompt.test.ts | 125 +++++ .../messaging/hooks/common/config-prompt.ts | 247 +++++++++ src/lib/messaging/hooks/common/index.ts | 83 +++ .../hooks/common/token-paste.test.ts | 4 +- src/lib/messaging/hooks/common/token-paste.ts | 153 ++++-- src/lib/messaging/hooks/hook-runner.test.ts | 29 +- src/lib/messaging/index.ts | 1 + src/lib/messaging/manifest/types.ts | 6 + src/lib/messaging/utils.ts | 80 +++ src/lib/onboard.ts | 161 +----- .../onboard/messaging-channel-setup.test.ts | 203 ++++++- src/lib/onboard/messaging-channel-setup.ts | 495 +++++++++++++----- test/onboard-messaging.test.ts | 2 + 31 files changed, 1582 insertions(+), 578 deletions(-) create mode 100644 src/lib/messaging/channels/wechat/hooks/host-qr-login-runtime.ts create mode 100644 src/lib/messaging/hooks/common/config-prompt.test.ts create mode 100644 src/lib/messaging/hooks/common/config-prompt.ts create mode 100644 src/lib/messaging/utils.ts diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index 669a1cbc6a..3a852db58d 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -9,6 +9,7 @@ import { loadAgent, type AgentDefinition } from "../../agent/defs"; import { CLI_DISPLAY_NAME, CLI_NAME } from "../../cli/branding"; import { hashCredential } from "../../security/credential-hash"; import { getCredential, prompt as askPrompt } from "../../credentials/store"; +import { createBuiltInChannelManifestRegistry } from "../../messaging/channels"; import { recoverNamedGatewayRuntime } from "../../gateway-runtime-action"; const { isNonInteractive } = require("../../onboard") as { isNonInteractive: () => boolean }; const onboardProviders = require("../../onboard/providers"); @@ -45,6 +46,8 @@ type ChannelMutationOptions = { dryRun?: boolean; }; +const messagingManifestRegistry = createBuiltInChannelManifestRegistry(); + const useColor = !process.env.NO_COLOR && !!process.stdout.isTTY; const trueColor = useColor && (process.env.COLORTERM === "truecolor" || process.env.COLORTERM === "24bit"); @@ -300,8 +303,11 @@ export function listSandboxChannels(sandboxName: string) { // channels-add upsert collides with (i.e. updates) the same provider that // a later rebuild would have created from scratch. function bridgeProviderName(sandboxName: string, channelName: string, envKey: string): string { - if (channelName === "slack" && envKey === "SLACK_APP_TOKEN") { - return `${sandboxName}-slack-app`; + const credential = messagingManifestRegistry + .get(channelName) + ?.credentials.find((entry) => entry.providerEnvKey === envKey); + if (credential) { + return credential.providerName.replaceAll("{sandboxName}", sandboxName); } return `${sandboxName}-${channelName}-bridge`; } diff --git a/src/lib/messaging/applier/setup-applier.test.ts b/src/lib/messaging/applier/setup-applier.test.ts index b1914e5361..583f487429 100644 --- a/src/lib/messaging/applier/setup-applier.test.ts +++ b/src/lib/messaging/applier/setup-applier.test.ts @@ -19,14 +19,6 @@ import { type MessagingPolicyApplyContext, } 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, @@ -55,42 +47,7 @@ async function withEnv( } function planner(): MessagingWorkflowPlanner { - return new MessagingWorkflowPlanner( - createBuiltInChannelManifestRegistry(), - 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", - }, - }, - }), - ); + return new MessagingWorkflowPlanner(createBuiltInChannelManifestRegistry()); } async function planOnboard( @@ -156,6 +113,13 @@ describe("MessagingSetupApplier", () => { phase: "enroll", handler: "wechat.ilinkLogin", }), + expect.objectContaining({ + sandboxName: "demo", + channelId: "wechat", + hookId: "wechat-config-prompt", + phase: "enroll", + handler: "common.configPrompt", + }), ]); expect(MessagingSetupApplier.listHookRequests(plan, "post-agent-install")).toEqual([ expect.objectContaining({ @@ -350,33 +314,7 @@ describe("MessagingSetupApplier", () => { ["wechat"], ); 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", }, diff --git a/src/lib/messaging/channels/discord/manifest.ts b/src/lib/messaging/channels/discord/manifest.ts index 2daddfde27..212d48db8e 100644 --- a/src/lib/messaging/channels/discord/manifest.ts +++ b/src/lib/messaging/channels/discord/manifest.ts @@ -32,6 +32,7 @@ export const discordManifest = { 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.", + emptyValueMessage: "guild channels stay disabled", }, }, { @@ -40,6 +41,7 @@ export const discordManifest = { required: false, envKey: "DISCORD_REQUIRE_MENTION", statePath: "discordGuilds.requireMention", + promptWhenInput: "serverId", validValues: ["0", "1"], prompt: { label: "Discord mention mode", @@ -52,9 +54,11 @@ export const discordManifest = { required: false, envKey: "DISCORD_USER_ID", statePath: "discordGuilds.userIds", + promptWhenInput: "serverId", 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.", + emptyValueMessage: "any member in the configured server can message the bot", }, }, ], @@ -164,5 +168,24 @@ export const discordManifest = { ], onFailure: "skip-channel", }, + { + id: "discord-config-prompt", + phase: "enroll", + handler: "common.configPrompt", + outputs: [ + { + id: "serverId", + kind: "config", + }, + { + id: "requireMention", + kind: "config", + }, + { + id: "userId", + kind: "config", + }, + ], + }, ], } as const satisfies ChannelManifest; diff --git a/src/lib/messaging/channels/manifests.test.ts b/src/lib/messaging/channels/manifests.test.ts index 4a0706c88c..7d5d475652 100644 --- a/src/lib/messaging/channels/manifests.test.ts +++ b/src/lib/messaging/channels/manifests.test.ts @@ -10,7 +10,10 @@ import { buildMessagingEnvLines, } from "../../../../agents/hermes/config/messaging-config.ts"; import { getChannelTokenKeys, KNOWN_CHANNELS, knownChannelNames } from "../../sandbox/channels"; -import { COMMON_TOKEN_PASTE_HOOK_HANDLER_ID } from "../hooks/common"; +import { + COMMON_CONFIG_PROMPT_HOOK_HANDLER_ID, + COMMON_TOKEN_PASTE_HOOK_HANDLER_ID, +} from "../hooks/common"; import type { ChannelInputSpec, ChannelManifest, ChannelRenderSpec } from "../manifest"; import { BUILT_IN_CHANNEL_MANIFESTS, @@ -58,6 +61,21 @@ function expectTokenPasteEnrollHook(manifest: ChannelManifest, outputIds: readon }); } +function expectConfigPromptEnrollHook( + manifest: ChannelManifest, + outputIds: readonly string[], +): void { + expect(manifest.hooks).toContainEqual({ + id: `${manifest.id}-config-prompt`, + phase: "enroll", + handler: COMMON_CONFIG_PROMPT_HOOK_HANDLER_ID, + outputs: outputIds.map((id) => ({ + id, + kind: "config", + })), + }); +} + describe("built-in channel manifests", () => { it("registers the phase-1 built-in manifests without consuming them in workflows", () => { const registry = createBuiltInChannelManifestRegistry(); @@ -99,6 +117,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/config-prompt.ts", "src/lib/messaging/hooks/common/token-paste.ts", ]; const forbiddenImports = [ @@ -194,13 +213,7 @@ 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", - }); + expectConfigPromptEnrollHook(telegramManifest, ["requireMention", "allowedIds"]); }); it("declares Discord guild and allowlist render intent for both agents", () => { @@ -253,6 +266,7 @@ describe("built-in channel manifests", () => { expect(renderJson(discordManifest)).toContain("discord.guilds"); expect(renderJson(discordManifest)).toContain("require_mention"); expectTokenPasteEnrollHook(discordManifest, ["botToken"]); + expectConfigPromptEnrollHook(discordManifest, ["serverId", "requireMention", "userId"]); }); it("declares Slack Bolt-compatible placeholders and allowlist render intent", () => { @@ -300,6 +314,7 @@ describe("built-in channel manifests", () => { expect(renderJson(slackManifest)).toContain("channels.slack.accounts.default"); expect(renderJson(slackManifest)).toContain("allowedIds.slack.channels"); expectTokenPasteEnrollHook(slackManifest, ["botToken", "appToken"]); + expectConfigPromptEnrollHook(slackManifest, ["allowedUsers"]); }); it("declares WeChat host-QR hooks, state hydration, provider binding, and Hermes env intent", () => { @@ -366,10 +381,12 @@ describe("built-in channel manifests", () => { expect(renderJson(wechatManifest)).toContain("credential.wechatBotToken.placeholder"); expect(wechatManifest.hooks.map((hook) => hook.handler)).toEqual([ "wechat.ilinkLogin", + "common.configPrompt", "wechat.seedOpenClawAccount", "wechat.healthCheck", ]); - expect(wechatManifest.hooks[1]?.outputs).toEqual( + expectConfigPromptEnrollHook(wechatManifest, ["allowedIds"]); + expect(wechatManifest.hooks[2]?.outputs).toEqual( expect.arrayContaining([ expect.objectContaining({ id: "openclawWeixinAccountFile", @@ -381,7 +398,7 @@ describe("built-in channel manifests", () => { }), ]), ); - expect(wechatManifest.hooks[2]).toMatchObject({ + expect(wechatManifest.hooks[3]).toMatchObject({ id: "wechat-health-check", phase: "health-check", handler: "wechat.healthCheck", diff --git a/src/lib/messaging/channels/slack/manifest.ts b/src/lib/messaging/channels/slack/manifest.ts index 25033ccecb..c1070bccc3 100644 --- a/src/lib/messaging/channels/slack/manifest.ts +++ b/src/lib/messaging/channels/slack/manifest.ts @@ -18,6 +18,8 @@ export const slackManifest = { kind: "secret", required: true, envKey: "SLACK_BOT_TOKEN", + formatPattern: "^xoxb-[A-Za-z0-9_-]+$", + formatHint: "Slack bot tokens start with 'xoxb-' (e.g. xoxb-1234-5678-abcdef).", prompt: { label: "Slack Bot Token", help: "Slack API → Your Apps → OAuth & Permissions → Bot User OAuth Token (xoxb-...).", @@ -29,6 +31,8 @@ export const slackManifest = { kind: "secret", required: true, envKey: "SLACK_APP_TOKEN", + formatPattern: "^xapp-[A-Za-z0-9_-]+$", + formatHint: "Slack app tokens start with 'xapp-' (e.g. xapp-1-A0000-12345-abcdef).", prompt: { label: "Slack App Token (Socket Mode)", help: "Slack API → Your Apps → Basic Information → App-Level Tokens (xapp-...).", @@ -44,6 +48,7 @@ export const slackManifest = { 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.", + emptyValueMessage: "bot will require manual pairing", }, }, ], @@ -128,5 +133,16 @@ export const slackManifest = { ], onFailure: "skip-channel", }, + { + id: "slack-config-prompt", + phase: "enroll", + handler: "common.configPrompt", + outputs: [ + { + id: "allowedUsers", + kind: "config", + }, + ], + }, ], } as const satisfies ChannelManifest; 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..7faba8646f 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 @@ -3,13 +3,21 @@ import { describe, expect, it } from "vitest"; +import type { ChannelHookSpec } from "../../../manifest"; import { MessagingHookRegistry, runMessagingHook } from "../../../hooks"; -import { telegramManifest } from "../manifest"; import { createTelegramGetMeReachabilityHook, TELEGRAM_GET_ME_REACHABILITY_HOOK_ID, } from "./get-me-reachability"; +const TELEGRAM_REACHABILITY_HOOK = { + id: "telegram-reachability", + phase: "reachability-check", + handler: TELEGRAM_GET_ME_REACHABILITY_HOOK_ID, + inputs: ["botToken"], + onFailure: "abort", +} as const satisfies ChannelHookSpec; + describe("Telegram getMe reachability hook implementation", () => { it("calls Telegram getMe without exposing the token in outputs", async () => { const urls: string[] = []; @@ -34,12 +42,8 @@ describe("Telegram getMe reachability hook implementation", () => { }), }, ]); - const hook = telegramManifest.hooks.find((entry) => entry.phase === "reachability-check"); - - if (!hook) throw new Error("missing Telegram reachability hook"); - await expect( - runMessagingHook(hook, registry, { + runMessagingHook(TELEGRAM_REACHABILITY_HOOK, registry, { channelId: "telegram", inputs: { botToken: "123456:telegram-token", @@ -73,12 +77,8 @@ describe("Telegram getMe reachability hook implementation", () => { }), }, ]); - const hook = telegramManifest.hooks.find((entry) => entry.phase === "reachability-check"); - - if (!hook) throw new Error("missing Telegram reachability hook"); - await expect( - runMessagingHook(hook, registry, { + runMessagingHook(TELEGRAM_REACHABILITY_HOOK, registry, { channelId: "telegram", inputs: { botToken: "bad-token", diff --git a/src/lib/messaging/channels/telegram/manifest.ts b/src/lib/messaging/channels/telegram/manifest.ts index e5faf9dffc..c02e239057 100644 --- a/src/lib/messaging/channels/telegram/manifest.ts +++ b/src/lib/messaging/channels/telegram/manifest.ts @@ -8,6 +8,10 @@ export const telegramManifest = { id: "telegram", displayName: "Telegram", description: "Telegram bot messaging", + enrollmentNotes: [ + "For Telegram group chats, disable privacy mode in @BotFather (/setprivacy -> your bot -> Disable).", + "After changing privacy mode, remove and re-add the bot to each group before testing @mentions.", + ], supportedAgents: ["openclaw", "hermes"], auth: { mode: "token-paste", @@ -32,6 +36,7 @@ export const telegramManifest = { prompt: { label: "Telegram User ID (for DM access)", help: "Send /start to @userinfobot on Telegram to get your numeric user ID.", + emptyValueMessage: "bot will require manual pairing", }, }, { @@ -146,11 +151,19 @@ export const telegramManifest = { onFailure: "skip-channel", }, { - id: "telegram-reachability", - phase: "reachability-check", - handler: "telegram.getMeReachability", - inputs: ["botToken"], - onFailure: "abort", + id: "telegram-config-prompt", + phase: "enroll", + handler: "common.configPrompt", + outputs: [ + { + id: "requireMention", + kind: "config", + }, + { + id: "allowedIds", + kind: "config", + }, + ], }, ], } as const satisfies ChannelManifest; diff --git a/src/lib/messaging/channels/wechat/hooks/host-qr-login-runtime.ts b/src/lib/messaging/channels/wechat/hooks/host-qr-login-runtime.ts new file mode 100644 index 0000000000..8b7112b5cf --- /dev/null +++ b/src/lib/messaging/channels/wechat/hooks/host-qr-login-runtime.ts @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { saveCredential } from "../../../../credentials/store"; +import { + HOST_QR_LOGIN_HANDLERS, + type HostQrLoginResult, +} from "../../../../host-qr-handlers"; +import { wechatManifest } from "../manifest"; +import type { WechatIlinkLoginHookOptions, WechatLoginResult } from "./ilink-login"; + +export function createDefaultWechatHostQrLoginOptions(): WechatIlinkLoginHookOptions { + return { + saveCredential, + runLogin: createWechatHostQrLoginRunner(), + }; +} + +function createWechatHostQrLoginRunner(): () => Promise { + return async () => { + logEnrollmentHelp(); + const handler = HOST_QR_LOGIN_HANDLERS.wechat; + if (!handler) return { kind: "error", message: "no host-qr handler registered" }; + + let result: HostQrLoginResult; + try { + result = await handler(); + } catch (error) { + result = { kind: "error", message: error instanceof Error ? error.message : String(error) }; + } + + if (result.kind !== "ok") { + return result.kind === "error" + ? { kind: "error", message: result.message } + : { kind: result.kind }; + } + if (!result.token) { + return { kind: "error", message: "host-qr handler returned no token" }; + } + + const accountId = result.extraEnv?.WECHAT_ACCOUNT_ID; + if (!accountId) { + return { kind: "error", message: "host-qr handler returned no WeChat account id" }; + } + + return { + kind: "ok", + summary: result.summary, + credentials: { + token: result.token, + accountId, + baseUrl: result.extraEnv?.WECHAT_BASE_URL, + userId: result.extraEnv?.WECHAT_USER_ID ?? result.defaultUserId, + }, + }; + }; +} + +function logEnrollmentHelp(): void { + const help = wechatManifest.enrollmentHelp ?? wechatManifest.inputs[0]?.prompt?.help; + if (!help) return; + console.log(""); + console.log(` ${help}`); +} diff --git a/src/lib/messaging/channels/wechat/hooks/ilink-login.ts b/src/lib/messaging/channels/wechat/hooks/ilink-login.ts index 77322519f7..1f134fe2a2 100644 --- a/src/lib/messaging/channels/wechat/hooks/ilink-login.ts +++ b/src/lib/messaging/channels/wechat/hooks/ilink-login.ts @@ -16,7 +16,11 @@ export interface WechatLoginCredentials { } export type WechatLoginResult = - | { readonly kind: "ok"; readonly credentials: WechatLoginCredentials } + | { + readonly kind: "ok"; + readonly credentials: WechatLoginCredentials; + readonly summary?: string; + } | { readonly kind: "timeout" } | { readonly kind: "expired"; readonly reason?: string } | { readonly kind: "aborted" } @@ -28,6 +32,7 @@ export interface WechatIlinkLoginHookOptions { readonly env?: NodeJS.ProcessEnv; readonly runLogin?: () => Promise; readonly saveCredential?: (key: string, value: string) => void; + readonly log?: (message: string) => void; } export function createWechatIlinkLoginHook( @@ -42,7 +47,9 @@ export function createWechatIlinkLoginHook( } const result = await runLogin(); if (result.kind !== "ok") { - throw new Error(`WeChat host QR login failed: ${wechatFailureReason(result)}.`); + const reason = wechatFailureReason(result); + (options.log ?? console.log)(` Skipped ${context.channelId} (${reason})`); + throw new Error(`WeChat host QR login failed: ${reason}.`); } const env = options.env ?? process.env; @@ -59,6 +66,8 @@ export function createWechatIlinkLoginHook( env.WECHAT_ACCOUNT_ID = accountId; if (baseUrl) env.WECHAT_BASE_URL = baseUrl; if (userId) env.WECHAT_USER_ID = userId; + const suffix = result.summary ? ` (${result.summary})` : ""; + (options.log ?? console.log)(` ✓ ${context.channelId} token saved${suffix}`); const outputs: Record = { botToken: { diff --git a/src/lib/messaging/channels/wechat/hooks/implementations.test.ts b/src/lib/messaging/channels/wechat/hooks/implementations.test.ts index 5e21fb2991..8ed4a14613 100644 --- a/src/lib/messaging/channels/wechat/hooks/implementations.test.ts +++ b/src/lib/messaging/channels/wechat/hooks/implementations.test.ts @@ -41,6 +41,7 @@ describe("WeChat hook implementations", () => { id: WECHAT_ILINK_LOGIN_HOOK_ID, handler: createWechatIlinkLoginHook({ env, + log: () => {}, saveCredential: (key, value) => saved.push({ key, value }), runLogin: async () => ({ kind: "ok", @@ -100,6 +101,7 @@ describe("WeChat hook implementations", () => { id: WECHAT_ILINK_LOGIN_HOOK_ID, handler: createWechatIlinkLoginHook({ env, + log: () => {}, saveCredential: (key, value) => saved.push({ key, value }), runLogin: async () => ({ kind: "timeout" }), }), @@ -166,7 +168,9 @@ describe("WeChat hook implementations", () => { }), }, ]); - const hook = wechatManifest.hooks[1]; + const hook = wechatManifest.hooks.find( + (entry) => entry.id === "wechat-seed-openclaw-account", + ); if (!hook) throw new Error("missing WeChat seed hook"); diff --git a/src/lib/messaging/channels/wechat/hooks/index.ts b/src/lib/messaging/channels/wechat/hooks/index.ts index db09732c15..bd1b707916 100644 --- a/src/lib/messaging/channels/wechat/hooks/index.ts +++ b/src/lib/messaging/channels/wechat/hooks/index.ts @@ -3,6 +3,7 @@ import type { MessagingHookRegistration } from "../../../hooks/types"; import { createWechatHealthCheckHookRegistration } from "./health-check"; +import { createDefaultWechatHostQrLoginOptions } from "./host-qr-login-runtime"; import { createWechatIlinkLoginHookRegistration, type WechatIlinkLoginHookOptions, @@ -24,8 +25,12 @@ export interface WechatHookOptions { export function createWechatHookRegistrations( options: WechatHookOptions = {}, ): readonly MessagingHookRegistration[] { + const ilinkLoginOptions = { + ...createDefaultWechatHostQrLoginOptions(), + ...options.ilinkLogin, + }; return [ - createWechatIlinkLoginHookRegistration(options.ilinkLogin), + createWechatIlinkLoginHookRegistration(ilinkLoginOptions), 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 f6c245a123..7c87b38820 100644 --- a/src/lib/messaging/channels/wechat/manifest.ts +++ b/src/lib/messaging/channels/wechat/manifest.ts @@ -8,6 +8,8 @@ export const wechatManifest = { id: "wechat", displayName: "WeChat", description: "WeChat (personal) bot messaging", + enrollmentHelp: + "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.", supportedAgents: ["openclaw", "hermes"], auth: { mode: "host-qr", @@ -53,6 +55,7 @@ export const wechatManifest = { 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.", + emptyValueMessage: "bot will require manual pairing", }, }, ], @@ -136,6 +139,17 @@ export const wechatManifest = { ], onFailure: "skip-channel", }, + { + id: "wechat-config-prompt", + phase: "enroll", + handler: "common.configPrompt", + outputs: [ + { + id: "allowedIds", + kind: "config", + }, + ], + }, { id: "wechat-seed-openclaw-account", phase: "post-agent-install", diff --git a/src/lib/messaging/channels/whatsapp/manifest.ts b/src/lib/messaging/channels/whatsapp/manifest.ts index 906399f4a2..4171055e0b 100644 --- a/src/lib/messaging/channels/whatsapp/manifest.ts +++ b/src/lib/messaging/channels/whatsapp/manifest.ts @@ -8,6 +8,8 @@ export const whatsappManifest = { id: "whatsapp", displayName: "WhatsApp", description: "WhatsApp Web messaging (QR pairing)", + enrollmentHelp: + "WhatsApp Web pairs via QR code scanned with your phone — no host-side token. After the sandbox is running, run `openshell term` and then use `openclaw channels login --channel whatsapp` for OpenClaw or `hermes whatsapp` for Hermes to display the QR.", supportedAgents: ["openclaw", "hermes"], auth: { mode: "in-sandbox-qr", diff --git a/src/lib/messaging/compiler/manifest-compiler.test.ts b/src/lib/messaging/compiler/manifest-compiler.test.ts index c31ab51300..b3437b29e2 100644 --- a/src/lib/messaging/compiler/manifest-compiler.test.ts +++ b/src/lib/messaging/compiler/manifest-compiler.test.ts @@ -35,7 +35,7 @@ function compiler(): ManifestCompiler { env: {}, getCredential: (key) => TEST_CREDENTIALS[key] ?? null, saveCredential: () => {}, - prompt: async () => "unused", + prompt: async () => "", log: () => {}, }, telegram: { @@ -53,6 +53,7 @@ function compiler(): ManifestCompiler { wechat: { ilinkLogin: { env: {}, + log: () => {}, saveCredential: () => {}, runLogin: async () => ({ kind: "ok", diff --git a/src/lib/messaging/compiler/manifest-compiler.ts b/src/lib/messaging/compiler/manifest-compiler.ts index 2ff8cad62a..e83b9fdd36 100644 --- a/src/lib/messaging/compiler/manifest-compiler.ts +++ b/src/lib/messaging/compiler/manifest-compiler.ts @@ -3,6 +3,7 @@ import type { ChannelHookSpec, + ChannelHookOutputSpec, ChannelInputSpec, ChannelManifest, ChannelManifestRegistry, @@ -14,7 +15,11 @@ import type { SandboxMessagingInputReference, SandboxMessagingPlan, } from "../manifest"; -import { MessagingHookRegistry, runMessagingHook } from "../hooks"; +import { + BUILT_IN_MESSAGING_HOOK_REGISTRY, + MessagingHookRegistry, + runMessagingHook, +} from "../hooks"; import type { MessagingHookInputMap, MessagingHookOutputMap, @@ -31,7 +36,7 @@ import type { ManifestCompilerContext } from "./types"; export class ManifestCompiler { constructor( private readonly registry: ChannelManifestRegistry, - private readonly hooks = new MessagingHookRegistry(), + private readonly hooks = BUILT_IN_MESSAGING_HOOK_REGISTRY, ) {} async compile(context: ManifestCompilerContext): Promise { @@ -178,8 +183,8 @@ async function resolveChannelInputs( readonly skipped: boolean; }> { let inputs = manifest.inputs.map((input) => resolveChannelInput(manifest, input, context)); - let hookInputs = buildCompilerHookInputs(manifest, inputs); inputs = applyCredentialAvailability(manifest, inputs, context); + let hookInputs = buildCompilerHookInputs(manifest, inputs); const enrollmentHooks = options.runEnrollment ? manifest.hooks .filter((hook) => isHookForAgent(hook, context.agent)) @@ -188,6 +193,7 @@ async function resolveChannelInputs( let skipped = false; for (const hook of enrollmentHooks) { + if (!shouldRunEnrollmentHook(hook, inputs)) continue; const result = await runCompilerHook(manifest, hook, hooks, hookInputs); if (!result) { skipped = true; @@ -317,6 +323,37 @@ function hasRequiredInputsAvailable( }); } +function shouldRunEnrollmentHook( + hook: ChannelHookSpec, + inputs: readonly SandboxMessagingInputReference[], +): boolean { + const outputs = hook.outputs ?? []; + if (outputs.length === 0) return true; + + const requiredOutputs = outputs.filter((output) => output.required); + if (requiredOutputs.length > 0) { + return requiredOutputs.some((output) => !isHookOutputAvailable(output, inputs)); + } + + if (outputs.every((output) => output.kind === "config")) return true; + return outputs.some((output) => !isHookOutputAvailable(output, inputs)); +} + +function isHookOutputAvailable( + output: ChannelHookOutputSpec, + inputs: readonly SandboxMessagingInputReference[], +): boolean { + const input = inputs.find((entry) => entry.inputId === output.id); + if (!input) return false; + if (output.kind === "secret") { + return input.kind === "secret" && input.credentialAvailable === true; + } + if (output.kind === "config") { + return input.kind === "config" && input.value !== undefined; + } + return false; +} + function buildCompilerHookInputs( manifest: ChannelManifest, inputs: readonly SandboxMessagingInputReference[], diff --git a/src/lib/messaging/compiler/workflow-planner.test.ts b/src/lib/messaging/compiler/workflow-planner.test.ts index 1a23195ab1..58f66376d5 100644 --- a/src/lib/messaging/compiler/workflow-planner.test.ts +++ b/src/lib/messaging/compiler/workflow-planner.test.ts @@ -1,64 +1,13 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createBuiltInChannelManifestRegistry } from "../channels"; -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(), - 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", - }, - }, - }), - ); + return new MessagingWorkflowPlanner(createBuiltInChannelManifestRegistry()); } function findFunctionPaths(value: unknown, prefix = "$"): string[] { @@ -102,13 +51,37 @@ async function withEnv( } describe("MessagingWorkflowPlanner", () => { + beforeEach(() => { + vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + 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"], - }); + const plan = await withEnv( + { + TELEGRAM_BOT_TOKEN: "123456:test-telegram-token", + TELEGRAM_REQUIRE_MENTION: "1", + TELEGRAM_ALLOWED_IDS: "123456789", + WECHAT_ACCOUNT_ID: "test-wechat-account", + WECHAT_BASE_URL: "https://ilinkai.wechat.example", + WECHAT_USER_ID: "test-wechat-user", + WECHAT_ALLOWED_IDS: "test-wechat-user", + }, + () => + planner().planOnboard({ + sandboxName: "demo", + agent: "openclaw", + isInteractive: true, + selectedChannels: ["wechat", "telegram"], + credentialAvailability: { + TELEGRAM_BOT_TOKEN: true, + WECHAT_BOT_TOKEN: true, + }, + }), + ); expect(plan.workflow).toBe("onboard"); expect(plan.channels.map((channel) => channel.channelId)).toEqual([ @@ -146,14 +119,22 @@ describe("MessagingWorkflowPlanner", () => { }); 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"], - }); + const plan = await withEnv( + { + SLACK_BOT_TOKEN: "xoxb-test-slack-token", + SLACK_APP_TOKEN: "xapp-test-slack-token", + SLACK_ALLOWED_USERS: "U0123456789", + }, + () => + 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({ @@ -172,36 +153,22 @@ describe("MessagingWorkflowPlanner", () => { }); it("runs add-channel enrollment only for the selected channel", async () => { - const hooks = new MessagingHookRegistry([ + const plan = await withEnv( { - 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: `test-${context.channelId}-${output.id}`, - }; - } - } - return { outputs }; - }, + TELEGRAM_BOT_TOKEN: undefined, + SLACK_BOT_TOKEN: "xoxb-test-slack-token", + SLACK_APP_TOKEN: "xapp-test-slack-token", + SLACK_ALLOWED_USERS: "U0123456789", }, - ]); - const plan = await new MessagingWorkflowPlanner( - createBuiltInChannelManifestRegistry(), - hooks, - ).planAddChannel({ - sandboxName: "demo", - agent: "openclaw", - isInteractive: true, - channelId: "slack", - configuredChannels: ["telegram"], - }); + () => + planner().planAddChannel({ + sandboxName: "demo", + agent: "openclaw", + isInteractive: true, + channelId: "slack", + configuredChannels: ["telegram"], + }), + ); expect(plan.channels.find((channel) => channel.channelId === "telegram")).toMatchObject({ active: true, @@ -215,6 +182,39 @@ describe("MessagingWorkflowPlanner", () => { ).toBe(true); }); + it("does not re-run host-QR enrollment when required manifest inputs are already available", async () => { + await withEnv( + { + WECHAT_ACCOUNT_ID: "cached-wechat-account", + WECHAT_ALLOWED_IDS: "cached-wechat-user", + }, + async () => { + const plan = await planner().planOnboard({ + sandboxName: "demo", + agent: "openclaw", + isInteractive: true, + selectedChannels: ["wechat"], + credentialAvailability: { + WECHAT_BOT_TOKEN: true, + }, + }); + + expect(plan.channels[0]).toMatchObject({ + channelId: "wechat", + active: true, + selected: true, + configured: true, + }); + expect(plan.channels[0]?.inputs).toContainEqual( + expect.objectContaining({ + inputId: "accountId", + value: "cached-wechat-account", + }), + ); + }, + ); + }); + it("plans stop-channel by keeping configured state and disabling only that channel", async () => { const plan = await planner().planStopChannel({ sandboxName: "demo", diff --git a/src/lib/messaging/compiler/workflow-planner.ts b/src/lib/messaging/compiler/workflow-planner.ts index d10b6ba6f2..b3c6661fd2 100644 --- a/src/lib/messaging/compiler/workflow-planner.ts +++ b/src/lib/messaging/compiler/workflow-planner.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { MessagingHookRegistry } from "../hooks"; +import { BUILT_IN_MESSAGING_HOOK_REGISTRY } from "../hooks"; import type { ChannelManifestRegistry, MessagingAgentId, @@ -38,11 +38,8 @@ export interface MessagingWorkflowPlannerChannelContext export class MessagingWorkflowPlanner { private readonly compiler: ManifestCompiler; - constructor( - private readonly registry: ChannelManifestRegistry, - hooks = new MessagingHookRegistry(), - ) { - this.compiler = new ManifestCompiler(registry, hooks); + constructor(private readonly registry: ChannelManifestRegistry) { + this.compiler = new ManifestCompiler(registry, BUILT_IN_MESSAGING_HOOK_REGISTRY); } async planOnboard( diff --git a/src/lib/messaging/hooks/builtins.ts b/src/lib/messaging/hooks/builtins.ts index 0cd04cd3d7..c40457ed03 100644 --- a/src/lib/messaging/hooks/builtins.ts +++ b/src/lib/messaging/hooks/builtins.ts @@ -5,13 +5,16 @@ import { createTelegramHookRegistrations, type TelegramGetMeReachabilityHookOptions, } from "../channels/telegram/hooks"; -import { createWechatHookRegistrations, type WechatHookOptions } from "../channels/wechat/hooks"; -import { createCommonHookRegistrations, type TokenPasteHookOptions } from "./common"; +import { + createWechatHookRegistrations, + type WechatHookOptions, +} from "../channels/wechat/hooks"; +import { createCommonHookRegistrations, type CommonHookOptions } from "./common"; import { MessagingHookRegistry } from "./registry"; import type { MessagingHookRegistration } from "./types"; export interface BuiltInMessagingHookOptions { - readonly common?: TokenPasteHookOptions; + readonly common?: CommonHookOptions; readonly telegram?: TelegramGetMeReachabilityHookOptions; readonly wechat?: WechatHookOptions; } @@ -31,3 +34,5 @@ export function createBuiltInMessagingHookRegistry( ): MessagingHookRegistry { return new MessagingHookRegistry(createBuiltInMessagingHookRegistrations(options)); } + +export const BUILT_IN_MESSAGING_HOOK_REGISTRY = createBuiltInMessagingHookRegistry(); diff --git a/src/lib/messaging/hooks/common/config-prompt.test.ts b/src/lib/messaging/hooks/common/config-prompt.test.ts new file mode 100644 index 0000000000..bb1b6e2c2e --- /dev/null +++ b/src/lib/messaging/hooks/common/config-prompt.test.ts @@ -0,0 +1,125 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { discordManifest, telegramManifest } from "../../channels"; +import { runMessagingHook } from "../hook-runner"; +import { MessagingHookRegistry } from "../registry"; +import { + COMMON_CONFIG_PROMPT_HOOK_HANDLER_ID, + createConfigPromptHook, +} from "./config-prompt"; + +describe("common config-prompt hook implementation", () => { + it("prompts manifest config outputs in hook declaration order", async () => { + const env: NodeJS.ProcessEnv = {}; + const questions: string[] = []; + const registry = new MessagingHookRegistry([ + { + id: COMMON_CONFIG_PROMPT_HOOK_HANDLER_ID, + handler: createConfigPromptHook({ + env, + log: () => {}, + prompt: async (question) => { + questions.push(question); + return question.includes("Reply only") ? "n" : "123456789"; + }, + }), + }, + ]); + const hook = telegramManifest.hooks.find( + (entry) => entry.id === "telegram-config-prompt", + ); + + if (!hook) throw new Error("missing Telegram config-prompt hook"); + + await expect( + runMessagingHook(hook, registry, { + channelId: "telegram", + }), + ).resolves.toMatchObject({ + handlerId: COMMON_CONFIG_PROMPT_HOOK_HANDLER_ID, + outputs: { + requireMention: { + kind: "config", + value: "0", + }, + allowedIds: { + kind: "config", + value: "123456789", + }, + }, + }); + expect(questions).toEqual([ + " Reply only when @mentioned? [Y/n]: ", + " Telegram User ID (for DM access): ", + ]); + expect(env.TELEGRAM_REQUIRE_MENTION).toBe("0"); + expect(env.TELEGRAM_ALLOWED_IDS).toBe("123456789"); + }); + + it("gates dependent prompts on earlier manifest config input values", async () => { + const questions: string[] = []; + const registry = new MessagingHookRegistry([ + { + id: COMMON_CONFIG_PROMPT_HOOK_HANDLER_ID, + handler: createConfigPromptHook({ + env: {}, + log: () => {}, + prompt: async (question) => { + questions.push(question); + return ""; + }, + }), + }, + ]); + const hook = discordManifest.hooks.find( + (entry) => entry.id === "discord-config-prompt", + ); + + if (!hook) throw new Error("missing Discord config-prompt hook"); + + await expect( + runMessagingHook(hook, registry, { + channelId: "discord", + }), + ).resolves.toMatchObject({ + outputs: {}, + }); + expect(questions).toEqual([ + " Discord Server ID (for guild workspace access): ", + ]); + }); + + it("logs existing config values without reprompting", async () => { + const logs: string[] = []; + const registry = new MessagingHookRegistry([ + { + id: COMMON_CONFIG_PROMPT_HOOK_HANDLER_ID, + handler: createConfigPromptHook({ + env: { + TELEGRAM_REQUIRE_MENTION: "1", + TELEGRAM_ALLOWED_IDS: "123456789", + }, + log: (message) => logs.push(message), + prompt: async () => { + throw new Error("existing config should not reprompt"); + }, + }), + }, + ]); + const hook = telegramManifest.hooks.find( + (entry) => entry.id === "telegram-config-prompt", + ); + + if (!hook) throw new Error("missing Telegram config-prompt hook"); + + await runMessagingHook(hook, registry, { + channelId: "telegram", + }); + + expect(logs.join("\n")).toContain("reply mode already set: @mentions only"); + expect(logs.join("\n")).toContain("allowed IDs already set: 123456789"); + }); +}); diff --git a/src/lib/messaging/hooks/common/config-prompt.ts b/src/lib/messaging/hooks/common/config-prompt.ts new file mode 100644 index 0000000000..87e0287618 --- /dev/null +++ b/src/lib/messaging/hooks/common/config-prompt.ts @@ -0,0 +1,247 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createBuiltInChannelManifestRegistry } from "../../channels"; +import type { + ChannelConfigInputSpec, + ChannelHookOutputSpec, + ChannelManifest, + MessagingSerializableValue, +} from "../../manifest"; +import type { + MessagingHookHandler, + MessagingHookInputMap, + MessagingHookOutputMap, + MessagingHookRegistration, +} from "../types"; + +export const COMMON_CONFIG_PROMPT_HOOK_HANDLER_ID = "common.configPrompt"; + +export interface ConfigPromptField { + readonly id: string; + readonly envKey: string; + readonly label: string; + readonly help?: string; + readonly emptyValueMessage?: string; + readonly validValues?: readonly string[]; + readonly promptWhenInput?: string; + readonly statePath?: string; +} + +export interface ConfigPromptHookOptions { + readonly env?: NodeJS.ProcessEnv; + readonly prompt?: (question: string, options?: { readonly secret?: boolean }) => Promise; + readonly log?: (message: string) => void; + readonly resolveField?: ( + channelId: string, + output: ChannelHookOutputSpec, + ) => ConfigPromptField | null; +} + +export function createConfigPromptHook( + options: ConfigPromptHookOptions = {}, +): MessagingHookHandler { + return async (context) => { + const outputs: Record = {}; + const availableInputs: Record = { + ...(context.inputs ?? {}), + }; + + for (const output of context.outputDeclarations ?? []) { + if (output.kind !== "config") continue; + const field = resolveConfigPromptField(context.channelId, output, options); + if (!field) { + throw new Error( + `No config-prompt field registered for ${context.channelId}.${output.id}`, + ); + } + if (field.promptWhenInput && !hasInputValue(availableInputs, field.promptWhenInput)) { + continue; + } + + const existing = readExistingConfigValue(field, availableInputs, options); + if (existing) { + recordConfigValue(field, existing, outputs, availableInputs, options); + logExistingConfigInput(context.channelId, field, existing, options); + continue; + } + + if (field.help) log(options, ` ${field.help}`); + const value = await promptConfigInputValue(field, options); + if (value) { + recordConfigValue(field, value, outputs, availableInputs, options); + logSavedConfigInput(context.channelId, field, value, options); + } else { + logSkippedConfigInput(context.channelId, field, options); + } + } + + return { outputs }; + }; +} + +export function createConfigPromptHookRegistration( + options: ConfigPromptHookOptions = {}, +): MessagingHookRegistration { + return { + id: COMMON_CONFIG_PROMPT_HOOK_HANDLER_ID, + handler: createConfigPromptHook(options), + }; +} + +function resolveConfigPromptField( + channelId: string, + output: ChannelHookOutputSpec, + options: ConfigPromptHookOptions, +): ConfigPromptField | null { + const custom = options.resolveField?.(channelId, output); + if (custom) return custom; + + const manifest = createBuiltInChannelManifestRegistry().get(channelId); + if (!manifest) return null; + return resolveManifestConfigPromptField(manifest, output); +} + +export function resolveManifestConfigPromptField( + manifest: ChannelManifest, + output: ChannelHookOutputSpec, +): ConfigPromptField | null { + const input = manifest.inputs.find( + (entry): entry is ChannelConfigInputSpec => + entry.kind === "config" && entry.id === output.id, + ); + if (!input?.envKey || !input.prompt) return null; + return { + id: input.id, + envKey: input.envKey, + label: input.prompt.label, + help: input.prompt.help, + emptyValueMessage: input.prompt.emptyValueMessage, + validValues: input.validValues, + promptWhenInput: input.promptWhenInput, + statePath: input.statePath, + }; +} + +function readExistingConfigValue( + field: ConfigPromptField, + availableInputs: MessagingHookInputMap, + options: ConfigPromptHookOptions, +): string | null { + const env = options.env ?? process.env; + return ( + normalizeConfigValue(field, env[field.envKey]) ?? + normalizeConfigValue(field, availableInputs[field.id]) ?? + normalizeConfigValue(field, field.statePath ? availableInputs[field.statePath] : undefined) + ); +} + +function recordConfigValue( + field: ConfigPromptField, + value: string, + outputs: Record, + availableInputs: Record, + options: ConfigPromptHookOptions, +): void { + const env = options.env ?? process.env; + env[field.envKey] = value; + outputs[field.id] = { + kind: "config", + value, + }; + availableInputs[field.id] = value; + if (field.statePath) availableInputs[field.statePath] = value; +} + +async function promptConfigInputValue( + field: ConfigPromptField, + options: ConfigPromptHookOptions, +): Promise { + const prompt = options.prompt ?? missingConfigPrompt; + if (isMentionModeInput(field)) { + const answer = (await prompt(" Reply only when @mentioned? [Y/n]: ")).trim().toLowerCase(); + return answer === "n" || answer === "no" ? "0" : "1"; + } + return normalizeConfigValue(field, await prompt(` ${field.label}: `)); +} + +async function missingConfigPrompt(): Promise { + throw new Error( + "Config-prompt hook requires an injected prompt implementation in phase 1.", + ); +} + +function normalizeConfigValue(field: ConfigPromptField, value: unknown): string | null { + if (typeof value !== "string") return null; + const normalized = value.replace(/\r/g, "").trim(); + if (!normalized) return null; + if (field.validValues && !field.validValues.includes(normalized)) return null; + return normalized; +} + +function hasInputValue( + inputs: Record, + inputId: string, +): boolean { + const value = inputs[inputId]; + return typeof value === "string" ? value.trim().length > 0 : value !== undefined; +} + +function logExistingConfigInput( + channelId: string, + field: ConfigPromptField, + value: string, + options: ConfigPromptHookOptions, +): void { + if (isMentionModeInput(field)) { + log(options, ` ✓ ${channelId} — reply mode already set: ${formatMentionMode(value)}`); + return; + } + log(options, ` ✓ ${channelId} — ${configInputNoun(field)} already set: ${value}`); +} + +function logSavedConfigInput( + channelId: string, + field: ConfigPromptField, + value: string, + options: ConfigPromptHookOptions, +): void { + if (isMentionModeInput(field)) { + log(options, ` ✓ ${channelId} reply mode saved: ${formatMentionMode(value)}`); + return; + } + log(options, ` ✓ ${channelId} ${configInputNoun(field)} saved`); +} + +function logSkippedConfigInput( + channelId: string, + field: ConfigPromptField, + options: ConfigPromptHookOptions, +): void { + const reason = field.emptyValueMessage ?? "left unset"; + log(options, ` Skipped ${channelId} ${configInputNoun(field)} (${reason})`); +} + +function configInputNoun(field: ConfigPromptField): string { + if (/server/i.test(field.id)) return "server ID"; + if (/allowed|user/i.test(field.id)) return "allowed IDs"; + return field.label; +} + +function isMentionModeInput(field: ConfigPromptField): boolean { + return ( + field.validValues?.length === 2 && + field.validValues.includes("0") && + field.validValues.includes("1") + ); +} + +function formatMentionMode(value: string): string { + return value === "0" ? "all messages" : "@mentions only"; +} + +function log(options: ConfigPromptHookOptions, message: string): void { + (options.log ?? console.log)(message); +} + +export const configPromptHook = createConfigPromptHook(); diff --git a/src/lib/messaging/hooks/common/index.ts b/src/lib/messaging/hooks/common/index.ts index b6eee49387..90c1b92f0b 100644 --- a/src/lib/messaging/hooks/common/index.ts +++ b/src/lib/messaging/hooks/common/index.ts @@ -1,4 +1,87 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { + createConfigPromptHookRegistration, + type ConfigPromptHookOptions, +} from "./config-prompt"; +import { + createTokenPasteHookRegistration, + type TokenPasteHookOptions, +} from "./token-paste"; +import { getCredential, prompt, saveCredential } from "../../../credentials/store"; +import type { MessagingHookRegistration } from "../types"; + +export interface CommonHookOptions extends TokenPasteHookOptions { + readonly tokenPaste?: TokenPasteHookOptions; + readonly configPrompt?: ConfigPromptHookOptions; +} + +export function createCommonHookRegistrations( + options: CommonHookOptions = {}, +): readonly MessagingHookRegistration[] { + const resolvedOptions = mergeCommonHookOptions(defaultCommonHookOptions(), options); + const tokenPasteOptions = { + ...resolvedOptions, + ...resolvedOptions.tokenPaste, + }; + const configPromptOptions = { + env: resolvedOptions.env, + prompt: resolvedOptions.prompt, + log: resolvedOptions.log, + ...resolvedOptions.configPrompt, + }; + + return [ + createTokenPasteHookRegistration(tokenPasteOptions), + createConfigPromptHookRegistration(configPromptOptions), + ] as const; +} + +export const COMMON_HOOK_REGISTRATIONS: readonly MessagingHookRegistration[] = + createCommonHookRegistrations(); + +function defaultCommonHookOptions(): CommonHookOptions { + return { + getCredential, + saveCredential, + prompt, + tokenPaste: { + log: logMessage, + }, + configPrompt: { + log: logMessage, + }, + }; +} + +function mergeCommonHookOptions( + defaults: CommonHookOptions, + options: CommonHookOptions, +): CommonHookOptions { + const base = { + ...defaults, + ...options, + }; + const inheritedLog = options.log ? { log: options.log } : {}; + return { + ...base, + tokenPaste: { + ...defaults.tokenPaste, + ...inheritedLog, + ...options.tokenPaste, + }, + configPrompt: { + ...defaults.configPrompt, + ...inheritedLog, + ...options.configPrompt, + }, + }; +} + +function logMessage(message: string): void { + console.log(message); +} + +export * from "./config-prompt"; 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 index 9c56814451..56cda3a00f 100644 --- a/src/lib/messaging/hooks/common/token-paste.test.ts +++ b/src/lib/messaging/hooks/common/token-paste.test.ts @@ -7,15 +7,17 @@ import { slackManifest, telegramManifest } from "../../channels"; import { runMessagingHook } from "../hook-runner"; import { MessagingHookRegistry } from "../registry"; import { + COMMON_CONFIG_PROMPT_HOOK_HANDLER_ID, COMMON_TOKEN_PASTE_HOOK_HANDLER_ID, COMMON_HOOK_REGISTRATIONS, createTokenPasteHook, -} from "./token-paste"; +} from "./index"; describe("common token-paste hook implementation", () => { it("uses the shared handler id declared by token-paste channel manifests", () => { expect(COMMON_HOOK_REGISTRATIONS.map((registration) => registration.id)).toEqual([ COMMON_TOKEN_PASTE_HOOK_HANDLER_ID, + COMMON_CONFIG_PROMPT_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); diff --git a/src/lib/messaging/hooks/common/token-paste.ts b/src/lib/messaging/hooks/common/token-paste.ts index fdba275b03..d55bd700b0 100644 --- a/src/lib/messaging/hooks/common/token-paste.ts +++ b/src/lib/messaging/hooks/common/token-paste.ts @@ -6,8 +6,12 @@ import type { MessagingHookOutputMap, MessagingHookRegistration, } from "../types"; -import { getChannelDef } from "../../../sandbox/channels"; -import type { ChannelHookOutputSpec } from "../../manifest"; +import { createBuiltInChannelManifestRegistry } from "../../channels"; +import type { + ChannelHookOutputSpec, + ChannelManifest, + ChannelSecretInputSpec, +} from "../../manifest"; export const COMMON_TOKEN_PASTE_HOOK_HANDLER_ID = "common.tokenPaste"; @@ -34,6 +38,7 @@ export interface TokenPasteHookOptions { export function createTokenPasteHook(options: TokenPasteHookOptions = {}): MessagingHookHandler { return async (context) => { const outputs: Record = {}; + const manifest = createBuiltInChannelManifestRegistry().get(context.channelId); for (const output of context.outputDeclarations ?? []) { if (output.kind !== "secret") continue; @@ -43,32 +48,36 @@ export function createTokenPasteHook(options: TokenPasteHookOptions = {}): Messa `No token-paste field registered for ${context.channelId}.${output.id}`, ); } - const token = await resolveTokenValue(field, options); + const resolved = await resolveTokenValue(context.channelId, output, field, options); outputs[output.id] = { kind: "secret", - value: token, + value: resolved.token, }; + logTokenStatus(context.channelId, output, resolved.source, options); + if (isPrimarySecretOutput(manifest, output)) { + logEnrollmentNotes(manifest, options); + } } return { outputs }; }; } -export function createCommonHookRegistrations( +export function createTokenPasteHookRegistration( options: TokenPasteHookOptions = {}, -): readonly MessagingHookRegistration[] { - return [ - { - id: COMMON_TOKEN_PASTE_HOOK_HANDLER_ID, - handler: createTokenPasteHook(options), - }, - ] as const; +): MessagingHookRegistration { + return { + id: COMMON_TOKEN_PASTE_HOOK_HANDLER_ID, + handler: createTokenPasteHook(options), + }; } async function resolveTokenValue( + channelId: string, + output: ChannelHookOutputSpec, field: TokenPasteField, options: TokenPasteHookOptions, -): Promise { +): Promise<{ readonly token: string; readonly source: "existing" | "prompted" }> { const env = options.env ?? process.env; const readCredential = options.getCredential ?? (() => null); const writeCredential = options.saveCredential ?? (() => {}); @@ -76,14 +85,24 @@ async function resolveTokenValue( const log = options.log ?? ((message: string) => console.log(message)); let token = normalizeCredentialValue(env[field.envKey]) || readCredential(field.envKey); + let source: "existing" | "prompted" = "existing"; if (!token) { - if (field.help) log(` ${field.help}`); + if (field.help) { + log(""); + log(` ${field.help}`); + } token = normalizeCredentialValue(await prompt(` ${field.label}: `, { secret: true })); + source = "prompted"; } if (!token) { + log(formatSkippedNoTokenMessage(channelId, output)); throw new Error(`No token entered for ${field.envKey}.`); } if (field.format && !field.format.test(token)) { + log( + ` ✗ Invalid format. ${field.formatHint || "Check the token and try again."}`, + ); + log(formatSkippedInvalidTokenMessage(channelId, output)); throw new Error( `Invalid token format for ${field.envKey}. ${ field.formatHint || "Check the token and try again." @@ -93,7 +112,7 @@ async function resolveTokenValue( writeCredential(field.envKey, token); env[field.envKey] = token; - return token; + return { token, source }; } async function missingPhaseOnePrompt(): Promise { @@ -115,30 +134,90 @@ function resolveTokenPasteField( 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, - }; + const manifest = createBuiltInChannelManifestRegistry().get(channelId); + return manifest ? resolveManifestTokenPasteField(manifest, output) : null; +} + +function resolveManifestTokenPasteField( + manifest: ChannelManifest, + output: ChannelHookOutputSpec, +): TokenPasteField | null { + const input = manifest.inputs.find( + (entry): entry is ChannelSecretInputSpec => + entry.kind === "secret" && entry.id === output.id, + ); + if (!input?.envKey) return null; + return { + envKey: input.envKey, + label: input.prompt?.label ?? input.envKey, + help: input.prompt?.help, + format: input.formatPattern ? new RegExp(input.formatPattern) : undefined, + formatHint: input.formatHint, + }; +} + +export const tokenPasteHook = createTokenPasteHook(); + +function logTokenStatus( + channelId: string, + output: ChannelHookOutputSpec, + source: "existing" | "prompted", + options: TokenPasteHookOptions, +): void { + const log = options.log ?? ((message: string) => console.log(message)); + if (source === "existing") { + log( + output.id === "botToken" + ? ` ✓ ${channelId} — already configured` + : ` ✓ ${channelId} ${tokenNoun(output)} — already configured`, + ); + return; } - 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, - }; + log(` ✓ ${channelId} ${tokenNoun(output)} saved`); +} + +function logEnrollmentNotes( + manifest: ChannelManifest | undefined, + options: TokenPasteHookOptions, +): void { + const log = options.log ?? ((message: string) => console.log(message)); + for (const line of manifest?.enrollmentNotes ?? []) { + log(` ${line}`); } - return null; } -export const tokenPasteHook = createTokenPasteHook(); +function isPrimarySecretOutput( + manifest: ChannelManifest | undefined, + output: ChannelHookOutputSpec, +): boolean { + return ( + manifest?.inputs.find( + (input): input is ChannelSecretInputSpec => + input.kind === "secret" && input.required && Boolean(input.envKey), + )?.id === output.id + ); +} + +function tokenNoun(output: ChannelHookOutputSpec): string { + return output.id === "appToken" ? "app token" : "token"; +} + +function formatSkippedNoTokenMessage( + channelId: string, + output: ChannelHookOutputSpec, +): string { + if (output.id === "appToken") { + return ` Skipped ${channelId} app token (Socket Mode requires both tokens)`; + } + return ` Skipped ${channelId} (no token entered)`; +} -export const COMMON_HOOK_REGISTRATIONS: readonly MessagingHookRegistration[] = - createCommonHookRegistrations(); +function formatSkippedInvalidTokenMessage( + channelId: string, + output: ChannelHookOutputSpec, +): string { + if (output.id === "appToken") { + return ` Skipped ${channelId} app token (invalid token format)`; + } + return ` Skipped ${channelId} (invalid token format)`; +} diff --git a/src/lib/messaging/hooks/hook-runner.test.ts b/src/lib/messaging/hooks/hook-runner.test.ts index 570a66eb0c..b06531591b 100644 --- a/src/lib/messaging/hooks/hook-runner.test.ts +++ b/src/lib/messaging/hooks/hook-runner.test.ts @@ -30,36 +30,11 @@ const HOST_QR_HOOK = { describe("MessagingHookRegistry", () => { 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", - }, - }, - }); + const registry = createBuiltInMessagingHookRegistry(); expect(registry.listIds()).toEqual([ "common.tokenPaste", + "common.configPrompt", "telegram.getMeReachability", "wechat.ilinkLogin", "wechat.seedOpenClawAccount", diff --git a/src/lib/messaging/index.ts b/src/lib/messaging/index.ts index 0f2c562802..c0e99120ba 100644 --- a/src/lib/messaging/index.ts +++ b/src/lib/messaging/index.ts @@ -6,3 +6,4 @@ export * from "./compiler"; export * from "./hooks"; export * from "./applier"; export * from "./manifest"; +export * from "./utils"; diff --git a/src/lib/messaging/manifest/types.ts b/src/lib/messaging/manifest/types.ts index 684300f16e..f07ef63661 100644 --- a/src/lib/messaging/manifest/types.ts +++ b/src/lib/messaging/manifest/types.ts @@ -33,6 +33,8 @@ export interface ChannelManifest { readonly id: MessagingChannelId; readonly displayName: string; readonly description?: string; + readonly enrollmentHelp?: string; + readonly enrollmentNotes?: readonly string[]; readonly supportedAgents: readonly MessagingAgentId[]; readonly auth: ChannelAuthSpec; readonly inputs: readonly ChannelInputSpec[]; @@ -67,6 +69,7 @@ export interface ChannelInputPromptSpec { readonly label: string; readonly help?: string; readonly placeholder?: string; + readonly emptyValueMessage?: string; } /** Shared fields for secret and non-secret manifest inputs. */ @@ -76,6 +79,8 @@ interface ChannelInputBaseSpec { readonly envKey?: string; readonly prompt?: ChannelInputPromptSpec; readonly validValues?: readonly string[]; + readonly formatPattern?: string; + readonly formatHint?: string; } /** Secret input metadata; values must be referenced, not stored in manifests or plans. */ @@ -88,6 +93,7 @@ export interface ChannelSecretInputSpec extends ChannelInputBaseSpec { export interface ChannelConfigInputSpec extends ChannelInputBaseSpec { readonly kind: "config"; readonly statePath?: MessagingStatePath; + readonly promptWhenInput?: string; } /** Manifest input declaration, split so secrets cannot declare defaults or state paths. */ diff --git a/src/lib/messaging/utils.ts b/src/lib/messaging/utils.ts new file mode 100644 index 0000000000..c55986bdae --- /dev/null +++ b/src/lib/messaging/utils.ts @@ -0,0 +1,80 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { + ChannelManifest, + ChannelManifestAvailabilityContext, + ChannelSecretInputSpec, + MessagingAgentId, + MessagingChannelId, +} from "./manifest"; + +export interface MessagingAgentDescriptor { + readonly name?: string; + readonly messagingPlatforms?: readonly MessagingChannelId[] | null; +} + +export type MessagingCredentialResolver = (envKey: string) => string | null; + +export function toMessagingAgentId( + agent: MessagingAgentDescriptor | null | undefined, +): MessagingAgentId { + return agent?.name === "hermes" ? "hermes" : "openclaw"; +} + +export function getMessagingManifestAvailabilityContext( + agent: MessagingAgentDescriptor | null | undefined, +): ChannelManifestAvailabilityContext { + return { + agent: toMessagingAgentId(agent), + supportedChannelIds: + agent?.messagingPlatforms && agent.messagingPlatforms.length > 0 + ? agent.messagingPlatforms + : null, + }; +} + +export function resolveMessagingManifestSeed( + manifests: readonly ChannelManifest[], + existingChannels: readonly string[] | null | undefined, + hasChannelCredentials: (manifest: ChannelManifest) => boolean, + { includeAllExisting = false }: { readonly includeAllExisting?: boolean } = {}, +): string[] { + const seeded = new Set( + manifests.filter(hasChannelCredentials).map((manifest) => manifest.id), + ); + if (!Array.isArray(existingChannels)) return Array.from(seeded); + + const manifestById = new Map(manifests.map((manifest) => [manifest.id, manifest])); + for (const channelId of existingChannels) { + const manifest = manifestById.get(channelId); + if (!manifest) continue; + if (includeAllExisting || manifest.auth.mode === "in-sandbox-qr") { + seeded.add(channelId); + } + } + return Array.from(seeded); +} + +export function hasMessagingManifestCredentials( + manifest: ChannelManifest, + resolveCredential: MessagingCredentialResolver, +): boolean { + const requiredSecrets = manifest.inputs.filter( + (input): input is ChannelSecretInputSpec => + input.kind === "secret" && input.required && Boolean(input.envKey), + ); + if (requiredSecrets.length === 0) return false; + return requiredSecrets.every((input) => Boolean(input.envKey && resolveCredential(input.envKey))); +} + +export function hasMessagingManifestPrimaryCredential( + manifest: ChannelManifest, + resolveCredential: MessagingCredentialResolver, +): boolean { + const primarySecret = manifest.inputs.find( + (input): input is ChannelSecretInputSpec => + input.kind === "secret" && input.required && Boolean(input.envKey), + ); + return Boolean(primarySecret?.envKey && resolveCredential(primarySecret.envKey)); +} diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 518a276843..085ba82d93 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -88,7 +88,7 @@ const { toSessionWechatConfig, } = require("./onboard/wechat-config") as typeof import("./onboard/wechat-config"); const { - setupSelectedMessagingChannels, + setupMessagingChannels: setupMessagingChannelsImpl, } = require("./onboard/messaging-channel-setup") as typeof import("./onboard/messaging-channel-setup"); const { clearAgentScopedResumeState, @@ -496,8 +496,6 @@ import { import { mergePolicyMessagingChannels } from "./onboard/messaging-policy-presets"; import { filterEnabledChannelsByAgent, - getAvailableMessagingChannelsForAgent, - resolveMessagingChannelSeed, resolveQrSelectedChannels, } from "./onboard/messaging-state"; import { getMessagingToken } from "./onboard/messaging-token"; @@ -5939,159 +5937,12 @@ async function setupMessagingChannels( agent: AgentDefinition | null = null, existingChannels: string[] | null = null, ): Promise { - step(5, 8, "Messaging channels"); - - const availableChannels = getAvailableMessagingChannelsForAgent(MESSAGING_CHANNELS, agent); - const seedFromState = (includeAllExisting = false): string[] => - resolveMessagingChannelSeed( - availableChannels, - existingChannels, - (channel) => Boolean(getMessagingToken(channel.envKey)), - { includeAllExisting }, - ); - - // Non-interactive: skip prompt, tokens come from env/credentials - if (isNonInteractive() || process.env.NEMOCLAW_NON_INTERACTIVE === "1") { - const found = Array.from(new Set(seedFromState(false))); - if (found.length > 0) { - note(` [non-interactive] Messaging tokens detected: ${found.join(", ")}`); - if (found.includes("telegram")) { - const telegramToken = getMessagingToken("TELEGRAM_BOT_TOKEN"); - if (telegramToken) { - await checkTelegramReachability(telegramToken); - } - } - } else { - note(" [non-interactive] No messaging tokens configured. Skipping."); - } - return found; - } - - // Single-keypress toggle selector — pre-select channels that already have tokens - // or were recorded for this sandbox (so a rebuild does not silently drop QR-only - // channels that have no host token). - const enabled = new Set(seedFromState(true)); - - const output = process.stderr; - // Lines above the prompt: 1 blank + 1 header + N channels + 1 blank = N + 3 - const linesAbovePrompt = availableChannels.length + 3; - let firstDraw = true; - const showList = () => { - if (!firstDraw) { - // Cursor is at end of prompt line. Move to column 0, go up, clear to end of screen. - output.write(`\r\x1b[${linesAbovePrompt}A\x1b[J`); - } - firstDraw = false; - output.write("\n"); - output.write(" Available messaging channels:\n"); - availableChannels.forEach((ch, i) => { - const marker = enabled.has(ch.name) ? "●" : "○"; - const status = getMessagingToken(ch.envKey) ? " (configured)" : ""; - output.write(` [${i + 1}] ${marker} ${ch.name} — ${ch.description}${status}\n`); - }); - output.write("\n"); - output.write(` Press 1-${availableChannels.length} to toggle, Enter when done: `); - }; - - showList(); - - await new Promise((resolve, reject) => { - const input = process.stdin; - let rawModeEnabled = false; - let finished = false; - - function cleanup() { - input.removeListener("data", onData); - if (rawModeEnabled && typeof input.setRawMode === "function") { - input.setRawMode(false); - } - // Symmetric with the ref() at the entry; lets the wizard exit - // naturally if this is the last prompt. - if (typeof input.pause === "function") { - input.pause(); - } - if (typeof input.unref === "function") { - input.unref(); - } - } - - function finish(): void { - if (finished) return; - finished = true; - cleanup(); - output.write("\n"); - resolve(); - } - - function onData(chunk: Buffer | string): void { - const text = chunk.toString("utf8"); - for (let i = 0; i < text.length; i += 1) { - const ch = text[i]; - if (ch === "\u0003") { - cleanup(); - reject(Object.assign(new Error("Prompt interrupted"), { code: "SIGINT" })); - process.kill(process.pid, "SIGINT"); - return; - } - if (ch === "\r" || ch === "\n") { - finish(); - return; - } - const num = parseInt(ch, 10); - if (num >= 1 && num <= availableChannels.length) { - const channel = availableChannels[num - 1]; - if (enabled.has(channel.name)) { - enabled.delete(channel.name); - } else { - enabled.add(channel.name); - } - showList(); - } - } - } - - // Re-attach stdin to the event loop. A prior prompt cleanup may have - // unref'd it (sticky), and resume() alone would leave the raw-mode read - // detached from the loop. - if (typeof input.ref === "function") { - input.ref(); - } - input.setEncoding("utf8"); - if (typeof input.resume === "function") { - input.resume(); - } - if (typeof input.setRawMode === "function") { - input.setRawMode(true); - rawModeEnabled = true; - } - input.on("data", onData); + return setupMessagingChannelsImpl(agent, existingChannels, { + step, + note, + isNonInteractive, + checkTelegramReachability, }); - - const selected = Array.from(enabled); - if (selected.length === 0) { - console.log(" Skipping messaging channels."); - return []; - } - - await setupSelectedMessagingChannels(selected, enabled, availableChannels); - console.log(""); - - // Channels where the user declined to enter a token were dropped from - // `enabled` inside the per-channel loop, so only channels with credentials - // configured remain in the Set. - - // Preflight: verify Telegram API is reachable from the host before sandbox creation. - // The non-interactive branch above already ran this probe and returned early, - // so this second call only fires on the interactive path — guard explicitly - // to make the no-double-probe invariant visible at the call site. - if (!isNonInteractive() && enabled.has("telegram")) { - const telegramToken = getMessagingToken("TELEGRAM_BOT_TOKEN"); - if (telegramToken) { - await checkTelegramReachability(telegramToken); - } - } - - return Array.from(enabled); } diff --git a/src/lib/onboard/messaging-channel-setup.test.ts b/src/lib/onboard/messaging-channel-setup.test.ts index 30012b4b55..606b914be6 100644 --- a/src/lib/onboard/messaging-channel-setup.test.ts +++ b/src/lib/onboard/messaging-channel-setup.test.ts @@ -3,8 +3,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { KNOWN_CHANNELS } from "../sandbox/channels"; -import { setupSelectedMessagingChannels } from "./messaging-channel-setup"; +import { getCredential, prompt, saveCredential } from "../credentials/store"; +import { HOST_QR_LOGIN_HANDLERS } from "../host-qr-handlers"; +import { createBuiltInChannelManifestRegistry } from "../messaging"; +import { + setupMessagingChannels, + setupSelectedMessagingChannels, +} from "./messaging-channel-setup"; vi.mock("../credentials/store", () => ({ getCredential: vi.fn(() => null), @@ -15,15 +20,29 @@ vi.mock("../credentials/store", () => ({ saveCredential: vi.fn(), })); -vi.mock("./host-qr-dispatch", () => ({ - dispatchHostQrLogin: vi.fn(), +vi.mock("../host-qr-handlers", () => ({ + HOST_QR_LOGIN_HANDLERS: { + wechat: vi.fn(), + }, })); const ORIGINAL_ENV = { ...process.env }; +const manifestRegistry = createBuiltInChannelManifestRegistry(); + +function manifests(...channelIds: string[]) { + return channelIds.map((channelId) => { + const manifest = manifestRegistry.get(channelId); + if (!manifest) throw new Error(`missing manifest ${channelId}`); + return manifest; + }); +} describe("setupSelectedMessagingChannels", () => { beforeEach(() => { process.env = { ...ORIGINAL_ENV }; + vi.clearAllMocks(); + vi.mocked(getCredential).mockReturnValue(null); + vi.mocked(prompt).mockResolvedValue(""); }); afterEach(() => { @@ -43,7 +62,7 @@ describe("setupSelectedMessagingChannels", () => { await setupSelectedMessagingChannels( ["telegram"], new Set(["telegram"]), - [{ name: "telegram", ...KNOWN_CHANNELS.telegram }], + manifests("telegram"), ); const output = logs.join("\n"); @@ -51,5 +70,179 @@ describe("setupSelectedMessagingChannels", () => { expect(output).toContain("/setprivacy -> your bot -> Disable"); expect(output).toContain("remove and re-add the bot to each group"); expect(output).toContain("reply mode already set: @mentions only"); + expect(output.indexOf("✓ telegram — already configured")).toBeLessThan( + output.indexOf("disable privacy mode in @BotFather"), + ); + expect(output.indexOf("disable privacy mode in @BotFather")).toBeLessThan( + output.indexOf("reply mode already set: @mentions only"), + ); + }); + + it("uses manifest token validation for Slack dual-token enrollment", async () => { + const logs: string[] = []; + vi.mocked(prompt).mockResolvedValueOnce("not-a-slack-token"); + vi.spyOn(console, "log").mockImplementation((message = "") => { + logs.push(String(message)); + }); + const enabled = new Set(["slack"]); + + await setupSelectedMessagingChannels( + ["slack"], + enabled, + manifests("slack"), + ); + + expect(enabled.has("slack")).toBe(false); + expect(saveCredential).not.toHaveBeenCalled(); + expect(logs.join("\n")).toContain("Slack bot tokens start with 'xoxb-'"); + expect(logs.join("\n")).toContain("Skipped slack (invalid token format)"); + expect(logs.join("\n")).not.toContain("enrollment failed"); + }); + + it("prompts each channel's config before enrolling the next selected channel", async () => { + const questions: string[] = []; + vi.mocked(prompt).mockImplementation(async (question: string) => { + questions.push(question); + if (question.includes("Telegram Bot Token")) return "123456:telegram-token"; + if (question.includes("Reply only")) return "n"; + if (question.includes("Telegram User ID")) return "123456789"; + if (question.includes("Discord Bot Token")) return "discord-token"; + return ""; + }); + vi.spyOn(console, "log").mockImplementation(() => {}); + + await setupSelectedMessagingChannels( + ["telegram", "discord"], + new Set(["telegram", "discord"]), + manifests("telegram", "discord"), + ); + + expect(questions).toEqual([ + " Telegram Bot Token: ", + " Reply only when @mentioned? [Y/n]: ", + " Telegram User ID (for DM access): ", + " Discord Bot Token: ", + " Discord Server ID (for guild workspace access): ", + ]); + expect(process.env.TELEGRAM_REQUIRE_MENTION).toBe("0"); + expect(process.env.TELEGRAM_ALLOWED_IDS).toBe("123456789"); + }); + + it("prompts Discord guild-only config after the manifest server ID input is set", async () => { + process.env.DISCORD_BOT_TOKEN = "discord-token"; + vi.mocked(prompt) + .mockResolvedValueOnce("1491590992753590594") + .mockResolvedValueOnce("n") + .mockResolvedValueOnce("1005536447329222676"); + const logs: string[] = []; + vi.spyOn(console, "log").mockImplementation((message = "") => { + logs.push(String(message)); + }); + + await setupSelectedMessagingChannels( + ["discord"], + new Set(["discord"]), + manifests("discord"), + ); + + expect(process.env.DISCORD_SERVER_ID).toBe("1491590992753590594"); + expect(process.env.DISCORD_REQUIRE_MENTION).toBe("0"); + expect(process.env.DISCORD_USER_ID).toBe("1005536447329222676"); + expect(logs.join("\n")).toContain("discord server ID saved"); + expect(logs.join("\n")).toContain("discord reply mode saved: all messages"); + expect(logs.join("\n")).toContain("discord allowed IDs saved"); + }); + + it("runs WeChat host-QR enrollment through the manifest hook", async () => { + vi.mocked(HOST_QR_LOGIN_HANDLERS.wechat).mockResolvedValue({ + kind: "ok", + token: "wechat-token", + extraEnv: { + WECHAT_ACCOUNT_ID: "wechat-account", + WECHAT_BASE_URL: "https://ilinkai.wechat.example", + WECHAT_USER_ID: "wechat-user", + }, + defaultUserId: "wechat-user", + summary: "account wechat-account", + }); + const logs: string[] = []; + vi.spyOn(console, "log").mockImplementation((message = "") => { + logs.push(String(message)); + }); + + const plan = await setupSelectedMessagingChannels( + ["wechat"], + new Set(["wechat"]), + manifests("wechat"), + ); + + expect(saveCredential).toHaveBeenCalledWith("WECHAT_BOT_TOKEN", "wechat-token"); + expect(process.env.WECHAT_ACCOUNT_ID).toBe("wechat-account"); + expect(process.env.WECHAT_BASE_URL).toBe("https://ilinkai.wechat.example"); + expect(process.env.WECHAT_USER_ID).toBe("wechat-user"); + expect(process.env.WECHAT_ALLOWED_IDS).toBe("wechat-user"); + expect(plan?.channels[0]).toMatchObject({ channelId: "wechat", active: true }); + expect(logs.join("\n")).toContain("wechat token saved (account wechat-account)"); + }); + + it("enrolls tokenless WhatsApp without credential prompts or providers", async () => { + const logs: string[] = []; + vi.spyOn(console, "log").mockImplementation((message = "") => { + logs.push(String(message)); + }); + + const plan = await setupSelectedMessagingChannels( + ["whatsapp"], + new Set(["whatsapp"]), + manifests("whatsapp"), + { agent: { name: "hermes" } }, + ); + + expect(prompt).not.toHaveBeenCalled(); + expect(getCredential).not.toHaveBeenCalled(); + expect(plan?.credentialBindings).toEqual([]); + expect(plan?.channels[0]).toMatchObject({ + channelId: "whatsapp", + authMode: "in-sandbox-qr", + active: true, + }); + expect(logs.join("\n")).toContain("WhatsApp Web pairs via QR code"); + }); +}); + +describe("setupMessagingChannels", () => { + beforeEach(() => { + process.env = { ...ORIGINAL_ENV }; + vi.clearAllMocks(); + vi.mocked(getCredential).mockReturnValue(null); + vi.mocked(prompt).mockResolvedValue(""); + }); + + afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + vi.restoreAllMocks(); + }); + + it("orchestrates non-interactive manifest selection with injected onboard deps", async () => { + process.env.TELEGRAM_BOT_TOKEN = "123456:telegram-token"; + process.env.SLACK_BOT_TOKEN = "xoxb-test-slack-token"; + const steps: string[] = []; + const notes: string[] = []; + const checkTelegramReachability = vi.fn(async () => {}); + + const result = await setupMessagingChannels(null, null, { + step: (current, total, label) => steps.push(`${current}/${total} ${label}`), + note: (message) => notes.push(message), + isNonInteractive: () => true, + checkTelegramReachability, + }); + + expect(result).toEqual(["telegram", "slack"]); + expect(steps).toEqual(["5/8 Messaging channels"]); + expect(notes).toEqual([ + " [non-interactive] Messaging tokens detected: telegram, slack", + ]); + expect(checkTelegramReachability).toHaveBeenCalledWith("123456:telegram-token"); + expect(prompt).not.toHaveBeenCalled(); }); }); diff --git a/src/lib/onboard/messaging-channel-setup.ts b/src/lib/onboard/messaging-channel-setup.ts index 0ebe3fac52..36be1075db 100644 --- a/src/lib/onboard/messaging-channel-setup.ts +++ b/src/lib/onboard/messaging-channel-setup.ts @@ -4,175 +4,384 @@ import { getCredential, normalizeCredentialValue, - prompt, - saveCredential, } from "../credentials/store"; -import { normalizeMessagingChannelConfigValue } from "../messaging-channel-config"; -import { channelHasStaticToken, type ChannelDef } from "../sandbox/channels"; -import { dispatchHostQrLogin } from "./host-qr-dispatch"; +import { + createBuiltInChannelManifestRegistry, + getMessagingManifestAvailabilityContext, + hasMessagingManifestPrimaryCredential, + MessagingWorkflowPlanner, + resolveMessagingManifestSeed, + toMessagingAgentId, + type ChannelManifest, + type ChannelSecretInputSpec, + type SandboxMessagingPlan, +} from "../messaging"; +import type { AgentDefinition } from "../agent/defs"; + +export interface SetupSelectedMessagingChannelsOptions { + readonly agent?: { readonly name?: string } | null; + readonly sandboxName?: string | null; + readonly interactive?: boolean; +} -type ChannelEntry = { name: string } & ChannelDef; +export interface SetupMessagingChannelsDeps { + readonly step?: (current: number, total: number, label: string) => void; + readonly note?: (message: string) => void; + readonly isNonInteractive?: () => boolean; + readonly checkTelegramReachability?: (token: string) => Promise; +} + +type SecretAvailabilitySnapshot = ReadonlyMap>; const getMessagingToken = (envKey: string): string | null => normalizeCredentialValue(process.env[envKey]) || getCredential(envKey) || null; -const getMessagingConfigValue = (envKey: string): string | null => - normalizeMessagingChannelConfigValue(envKey, process.env[envKey]); +export async function setupMessagingChannels( + agent: AgentDefinition | null = null, + existingChannels: string[] | null = null, + deps: SetupMessagingChannelsDeps = {}, +): Promise { + deps.step?.(5, 8, "Messaging channels"); + + const note = deps.note ?? console.log; + const isNonInteractive = + deps.isNonInteractive ?? (() => process.env.NEMOCLAW_NON_INTERACTIVE === "1"); + const checkTelegramReachability = deps.checkTelegramReachability ?? (async () => {}); + const manifestRegistry = createBuiltInChannelManifestRegistry(); + const availabilityContext = getMessagingManifestAvailabilityContext(agent); + const availableChannels = manifestRegistry.listAvailable(availabilityContext); + const hasManifestCredentials = (manifest: ChannelManifest) => + hasMessagingManifestPrimaryCredential(manifest, getMessagingToken); + const seedFromState = (includeAllExisting = false): string[] => + resolveMessagingManifestSeed( + availableChannels, + existingChannels, + hasManifestCredentials, + { includeAllExisting }, + ); + + if (isNonInteractive() || process.env.NEMOCLAW_NON_INTERACTIVE === "1") { + const enabled = new Set(seedFromState(false)); + const found = Array.from(enabled); + if (found.length > 0) { + note(` [non-interactive] Messaging tokens detected: ${found.join(", ")}`); + await setupSelectedMessagingChannels(found, enabled, availableChannels, { + agent, + interactive: false, + }); + if (enabled.has("telegram")) { + const telegramToken = getMessagingToken("TELEGRAM_BOT_TOKEN"); + if (telegramToken) { + await checkTelegramReachability(telegramToken); + } + } + } else { + note(" [non-interactive] No messaging tokens configured. Skipping."); + } + return Array.from(enabled); + } + + const enabled = new Set(seedFromState(true)); + const output = process.stderr; + const linesAbovePrompt = availableChannels.length + 3; + let firstDraw = true; + const showList = () => { + if (!firstDraw) { + output.write(`\r\x1b[${linesAbovePrompt}A\x1b[J`); + } + firstDraw = false; + output.write("\n"); + output.write(" Available messaging channels:\n"); + availableChannels.forEach((manifest, i) => { + const marker = enabled.has(manifest.id) ? "●" : "○"; + const status = hasManifestCredentials(manifest) ? " (configured)" : ""; + output.write( + ` [${i + 1}] ${marker} ${manifest.id} — ${ + manifest.description ?? manifest.displayName + }${status}\n`, + ); + }); + output.write("\n"); + output.write(` Press 1-${availableChannels.length} to toggle, Enter when done: `); + }; + + showList(); + await readMessagingChannelSelection(availableChannels, enabled, showList); + + const selected = Array.from(enabled); + if (selected.length === 0) { + console.log(" Skipping messaging channels."); + return []; + } + + await setupSelectedMessagingChannels(selected, enabled, availableChannels, { agent }); + console.log(""); + + if (!isNonInteractive() && enabled.has("telegram")) { + const telegramToken = getMessagingToken("TELEGRAM_BOT_TOKEN"); + if (telegramToken) { + await checkTelegramReachability(telegramToken); + } + } + + return Array.from(enabled); +} /** - * Prompt for token + per-channel config (app token, server ID, mention - * mode, allowlist IDs) for each selected messaging channel. Mutates - * `process.env` for non-secret config and saves credentials via - * `saveCredential`. Channels where the user declined or supplied an - * invalid token are removed from `enabled`. + * Prompt for token + per-channel config for each selected messaging channel. * - * Extracted from `setupMessagingChannels` in onboard.ts so the - * per-channel interactive loop lives outside the top-level entrypoint - * (src/lib/onboard.ts file-growth budget). + * Enrollment now flows through the manifest-first architecture: selected + * built-in manifests are planned with `MessagingWorkflowPlanner`, token paste + * and host-QR acquisition run via registered hooks, and follow-up config prompts + * are driven from manifest input metadata. */ export async function setupSelectedMessagingChannels( selected: readonly string[], enabled: Set, - messagingChannels: readonly ChannelEntry[], -): Promise { - for (const name of selected) { - const ch = messagingChannels.find((c) => c.name === name); - if (!ch) { - console.log(` Unknown channel: ${name}`); + messagingChannels: readonly ChannelManifest[], + options: SetupSelectedMessagingChannelsOptions = {}, +): Promise { + const registry = createBuiltInChannelManifestRegistry(); + const supportedChannelIds = messagingChannels.map((channel) => channel.id); + const selectedChannels = uniqueSelectedChannels(selected, supportedChannelIds, registry); + if (selectedChannels.length === 0) return null; + + const agent = toMessagingAgentId(options.agent); + const sandboxName = options.sandboxName || "pending-sandbox"; + const planner = new MessagingWorkflowPlanner(registry); + + if (options.interactive === false) { + const plan = await planner.planOnboard({ + sandboxName, + agent, + isInteractive: false, + selectedChannels, + supportedChannelIds, + credentialAvailability: buildCredentialAvailability(registry, selectedChannels), + }); + for (const channel of plan.channels) { + if (!channel.active) enabled.delete(channel.channelId); + } + return plan; + } + + for (const channelId of selectedChannels) { + const manifest = registry.get(channelId); + if (!manifest) continue; + const initialSecretAvailability = snapshotSecretAvailability(registry, [channelId]); + if (hasAllSecretInputsAvailable(manifest, initialSecretAvailability)) { + printExistingSecretStatus(manifest); + printEnrollmentNotes(manifest); + } + const enrolledPlan = await planner.planOnboard({ + sandboxName, + agent, + isInteractive: true, + selectedChannels: [channelId], + supportedChannelIds, + credentialAvailability: buildCredentialAvailability(registry, [channelId]), + }); + const enrolledChannel = enrolledPlan.channels.find((channel) => channel.channelId === channelId); + if (!enrolledChannel?.active) { + enabled.delete(channelId); continue; } - if (channelHasStaticToken(ch) && getMessagingToken(ch.envKey)) { - console.log(` ✓ ${ch.name} — already configured`); - } else if (ch.loginMethod === "host-qr") { - console.log(""); - console.log(` ${ch.help}`); - const outcome = await dispatchHostQrLogin(ch); - if (!outcome.ok) { - console.log(` Skipped ${ch.name} (${outcome.reason})`); - enabled.delete(ch.name); - continue; + if (manifest.auth.mode === "in-sandbox-qr") printInSandboxQrStatus(manifest); + } + + const finalSelectedChannels = selectedChannels.filter((channelId) => enabled.has(channelId)); + const finalPlan = await planner.planOnboard({ + sandboxName, + agent, + isInteractive: false, + selectedChannels: finalSelectedChannels, + supportedChannelIds, + credentialAvailability: buildCredentialAvailability(registry, finalSelectedChannels), + }); + + for (const channel of finalPlan.channels) { + if (!channel.active) enabled.delete(channel.channelId); + } + + return finalPlan; +} + +function readMessagingChannelSelection( + availableChannels: readonly ChannelManifest[], + enabled: Set, + showList: () => void, +): Promise { + return new Promise((resolve, reject) => { + const input = process.stdin; + const output = process.stderr; + let rawModeEnabled = false; + let finished = false; + + function cleanup() { + input.removeListener("data", onData); + if (rawModeEnabled && typeof input.setRawMode === "function") { + input.setRawMode(false); } - const suffix = outcome.summary ? ` (${outcome.summary})` : ""; - console.log(` ✓ ${ch.name} token saved${suffix}`); - } else if (ch.loginMethod === "in-sandbox-qr") { - console.log(""); - console.log(` ${ch.help}`); - console.log( - ` ✓ ${ch.name} enabled — complete QR pairing from inside the sandbox after rebuild.`, - ); - continue; - } else { - if (!channelHasStaticToken(ch)) continue; - console.log(""); - console.log(` ${ch.help}`); - const token = normalizeCredentialValue(await prompt(` ${ch.label}: `, { secret: true })); - if (token && ch.tokenFormat && !ch.tokenFormat.test(token)) { - console.log( - ` ✗ Invalid format. ${ch.tokenFormatHint || "Check the token and try again."}`, - ); - console.log(` Skipped ${ch.name} (invalid token format)`); - enabled.delete(ch.name); - continue; + if (typeof input.pause === "function") { + input.pause(); } - if (token) { - saveCredential(ch.envKey, token); - process.env[ch.envKey] = token; - console.log(` ✓ ${ch.name} token saved`); - } else { - console.log(` Skipped ${ch.name} (no token entered)`); - enabled.delete(ch.name); - continue; + if (typeof input.unref === "function") { + input.unref(); } } - for (const line of ch.setupNotes ?? []) { - console.log(` ${line}`); - } - if (ch.appTokenEnvKey) { - const existingAppToken = getMessagingToken(ch.appTokenEnvKey); - if (existingAppToken) { - console.log(` ✓ ${ch.name} app token — already configured`); - } else { - console.log(""); - console.log(` ${ch.appTokenHelp}`); - const appToken = normalizeCredentialValue( - await prompt(` ${ch.appTokenLabel}: `, { secret: true }), - ); - if (appToken && ch.appTokenFormat && !ch.appTokenFormat.test(appToken)) { - console.log( - ` ✗ Invalid format. ${ch.appTokenFormatHint || "Check the token and try again."}`, - ); - console.log(` Skipped ${ch.name} app token (invalid token format)`); - enabled.delete(ch.name); - continue; + + function finish(): void { + if (finished) return; + finished = true; + cleanup(); + output.write("\n"); + resolve(); + } + + function onData(chunk: Buffer | string): void { + const text = chunk.toString("utf8"); + for (let i = 0; i < text.length; i += 1) { + const ch = text[i]; + if (ch === "\u0003") { + cleanup(); + reject(Object.assign(new Error("Prompt interrupted"), { code: "SIGINT" })); + process.kill(process.pid, "SIGINT"); + return; } - if (appToken) { - saveCredential(ch.appTokenEnvKey, appToken); - process.env[ch.appTokenEnvKey] = appToken; - console.log(` ✓ ${ch.name} app token saved`); - } else { - console.log(` Skipped ${ch.name} app token (Socket Mode requires both tokens)`); - enabled.delete(ch.name); - continue; + if (ch === "\r" || ch === "\n") { + finish(); + return; } - } - } - if (ch.serverIdEnvKey) { - const existingServerIds = getMessagingConfigValue(ch.serverIdEnvKey) || ""; - if (existingServerIds) { - process.env[ch.serverIdEnvKey] = existingServerIds; - console.log(` ✓ ${ch.name} — server ID already set: ${existingServerIds}`); - } else { - console.log(` ${ch.serverIdHelp}`); - const serverId = (await prompt(` ${ch.serverIdLabel}: `)).trim(); - if (serverId) { - process.env[ch.serverIdEnvKey] = serverId; - console.log(` ✓ ${ch.name} server ID saved`); - } else { - console.log(` Skipped ${ch.name} server ID (guild channels stay disabled)`); + const num = parseInt(ch, 10); + if (num >= 1 && num <= availableChannels.length) { + const channel = availableChannels[num - 1]; + if (enabled.has(channel.id)) { + enabled.delete(channel.id); + } else { + enabled.add(channel.id); + } + showList(); } } } - // Mention-control prompt: fires for any channel that exposes a - // requireMention env key. Discord gates the prompt behind a configured - // server ID (mention control only makes sense in a guild). Telegram - // has no serverIdEnvKey because mention control applies to every group - // the bot is added to, so the prompt always fires there. See #1737. - const requireMentionKey = ch.requireMentionEnvKey; - if (requireMentionKey && (!ch.serverIdEnvKey || Boolean(process.env[ch.serverIdEnvKey]))) { - const existingRequireMention = getMessagingConfigValue(requireMentionKey); - if (existingRequireMention === "0" || existingRequireMention === "1") { - process.env[requireMentionKey] = existingRequireMention; - const mode = existingRequireMention === "0" ? "all messages" : "@mentions only"; - console.log(` ✓ ${ch.name} — reply mode already set: ${mode}`); - } else { - console.log(` ${ch.requireMentionHelp}`); - const answer = (await prompt(" Reply only when @mentioned? [Y/n]: ")).trim().toLowerCase(); - const value = answer === "n" || answer === "no" ? "0" : "1"; - process.env[requireMentionKey] = value; - const mode = value === "0" ? "all messages" : "@mentions only"; - console.log(` ✓ ${ch.name} reply mode saved: ${mode}`); - } + + if (typeof input.ref === "function") { + input.ref(); } - // Prompt for user/sender ID when the channel supports allowlisting - if (ch.userIdEnvKey && (!ch.serverIdEnvKey || process.env[ch.serverIdEnvKey])) { - const existingIds = getMessagingConfigValue(ch.userIdEnvKey) || ""; - if (existingIds) { - process.env[ch.userIdEnvKey] = existingIds; - console.log(` ✓ ${ch.name} — allowed IDs already set: ${existingIds}`); - } else { - console.log(` ${ch.userIdHelp}`); - const userId = (await prompt(` ${ch.userIdLabel}: `)).trim(); - if (userId) { - process.env[ch.userIdEnvKey] = userId; - console.log(` ✓ ${ch.name} allowed IDs saved`); - } else { - const skippedReason = - ch.allowIdsMode === "guild" - ? "any member in the configured server can message the bot" - : "bot will require manual pairing"; - console.log(` Skipped ${ch.name} user ID (${skippedReason})`); - } + input.setEncoding("utf8"); + if (typeof input.resume === "function") { + input.resume(); + } + if (typeof input.setRawMode === "function") { + input.setRawMode(true); + rawModeEnabled = true; + } + input.on("data", onData); + }); +} + +function uniqueSelectedChannels( + selected: readonly string[], + supportedChannelIds: readonly string[], + registry: ReturnType, +): string[] { + const supported = new Set(supportedChannelIds); + const result: string[] = []; + for (const rawName of selected) { + const name = rawName.trim().toLowerCase(); + if (!supported.has(name) || !registry.get(name)) { + console.log(` Unknown channel: ${rawName}`); + continue; + } + if (!result.includes(name)) result.push(name); + } + return result; +} + +function logEnrollmentHelp(manifest: ChannelManifest): void { + const help = manifest.enrollmentHelp ?? manifest.inputs[0]?.prompt?.help; + if (!help) return; + console.log(""); + console.log(` ${help}`); +} + +function snapshotSecretAvailability( + registry: ReturnType, + channelIds: readonly string[], +): SecretAvailabilitySnapshot { + const availability = new Map>(); + for (const channelId of channelIds) { + const manifest = registry.get(channelId); + if (!manifest) continue; + const availableInputs = new Set(); + for (const input of manifest.inputs) { + if (input.kind !== "secret" || !input.envKey) continue; + if (getMessagingToken(input.envKey)) availableInputs.add(input.id); + } + availability.set(manifest.id, availableInputs); + } + return availability; +} + +function buildCredentialAvailability( + registry: ReturnType, + channelIds: readonly string[], +): Record { + const availability: Record = {}; + for (const channelId of channelIds) { + const manifest = registry.get(channelId); + if (!manifest) continue; + for (const input of manifest.inputs) { + if (input.kind !== "secret" || !input.envKey || !getMessagingToken(input.envKey)) { + continue; } + availability[input.id] = true; + availability[`${manifest.id}.${input.id}`] = true; + availability[input.envKey] = true; } } + return availability; +} + +function hasAllSecretInputsAvailable( + manifest: ChannelManifest, + availability: SecretAvailabilitySnapshot, +): boolean { + const secretInputs = manifest.inputs.filter( + (entry): entry is ChannelSecretInputSpec => entry.kind === "secret", + ); + return ( + secretInputs.length > 0 && + secretInputs.every((input) => availability.get(manifest.id)?.has(input.id) === true) + ); +} + +function printExistingSecretStatus(manifest: ChannelManifest): void { + const secretInputs = manifest.inputs.filter( + (entry): entry is ChannelSecretInputSpec => entry.kind === "secret", + ); + for (const input of secretInputs) { + if (input.id === "botToken") { + console.log(` ✓ ${manifest.id} — already configured`); + } else { + console.log(` ✓ ${manifest.id} ${tokenNoun(input.id)} — already configured`); + } + } +} + +function printEnrollmentNotes(manifest: ChannelManifest): void { + for (const line of manifest.enrollmentNotes ?? []) { + console.log(` ${line}`); + } +} + +function printInSandboxQrStatus(manifest: ChannelManifest): void { + logEnrollmentHelp(manifest); + console.log( + ` ✓ ${manifest.id} enabled — complete QR pairing from inside the sandbox after rebuild.`, + ); +} + +function tokenNoun(inputId: string): string { + return inputId === "appToken" ? "app token" : "token"; } diff --git a/test/onboard-messaging.test.ts b/test/onboard-messaging.test.ts index 7056e9a75f..db7bf30fb1 100644 --- a/test/onboard-messaging.test.ts +++ b/test/onboard-messaging.test.ts @@ -1675,6 +1675,7 @@ const { setupMessagingChannels } = require(${onboardPath}); delete process.env.TELEGRAM_BOT_TOKEN; delete process.env.DISCORD_BOT_TOKEN; delete process.env.SLACK_BOT_TOKEN; + delete process.env.SLACK_APP_TOKEN; const result = await setupMessagingChannels(); console.log(JSON.stringify(result)); })().catch((error) => { @@ -1695,6 +1696,7 @@ const { setupMessagingChannels } = require(${onboardPath}); TELEGRAM_BOT_TOKEN: "", DISCORD_BOT_TOKEN: "", SLACK_BOT_TOKEN: "", + SLACK_APP_TOKEN: "", }, }); From 7027847b54ddfc514cc1db32233a19e20257048f Mon Sep 17 00:00:00 2001 From: San Dang Date: Tue, 26 May 2026 18:39:06 +0700 Subject: [PATCH 19/40] fix(messaging): address enrollment review feedback --- src/lib/messaging/channels/slack/manifest.ts | 4 ++-- .../messaging/channels/wechat/hooks/index.ts | 10 +++++++++- src/lib/onboard/messaging-channel-setup.test.ts | 17 +++++++++++++++++ src/lib/onboard/messaging-channel-setup.ts | 13 ++++++++----- test/onboard-messaging.test.ts | 2 ++ 5 files changed, 38 insertions(+), 8 deletions(-) diff --git a/src/lib/messaging/channels/slack/manifest.ts b/src/lib/messaging/channels/slack/manifest.ts index c1070bccc3..08631d2402 100644 --- a/src/lib/messaging/channels/slack/manifest.ts +++ b/src/lib/messaging/channels/slack/manifest.ts @@ -19,7 +19,7 @@ export const slackManifest = { required: true, envKey: "SLACK_BOT_TOKEN", formatPattern: "^xoxb-[A-Za-z0-9_-]+$", - formatHint: "Slack bot tokens start with 'xoxb-' (e.g. xoxb-1234-5678-abcdef).", + formatHint: "Slack bot tokens start with 'xoxb-' (e.g. xoxb---).", prompt: { label: "Slack Bot Token", help: "Slack API → Your Apps → OAuth & Permissions → Bot User OAuth Token (xoxb-...).", @@ -32,7 +32,7 @@ export const slackManifest = { required: true, envKey: "SLACK_APP_TOKEN", formatPattern: "^xapp-[A-Za-z0-9_-]+$", - formatHint: "Slack app tokens start with 'xapp-' (e.g. xapp-1-A0000-12345-abcdef).", + formatHint: "Slack app tokens start with 'xapp-' (e.g. xapp----).", prompt: { label: "Slack App Token (Socket Mode)", help: "Slack API → Your Apps → Basic Information → App-Level Tokens (xapp-...).", diff --git a/src/lib/messaging/channels/wechat/hooks/index.ts b/src/lib/messaging/channels/wechat/hooks/index.ts index bd1b707916..6a78df8406 100644 --- a/src/lib/messaging/channels/wechat/hooks/index.ts +++ b/src/lib/messaging/channels/wechat/hooks/index.ts @@ -27,7 +27,7 @@ export function createWechatHookRegistrations( ): readonly MessagingHookRegistration[] { const ilinkLoginOptions = { ...createDefaultWechatHostQrLoginOptions(), - ...options.ilinkLogin, + ...withoutUndefinedValues(options.ilinkLogin), }; return [ createWechatIlinkLoginHookRegistration(ilinkLoginOptions), @@ -35,3 +35,11 @@ export function createWechatHookRegistrations( createWechatHealthCheckHookRegistration(), ] as const; } + +function withoutUndefinedValues( + options: WechatIlinkLoginHookOptions | undefined, +): WechatIlinkLoginHookOptions { + return Object.fromEntries( + Object.entries(options ?? {}).filter(([, value]) => value !== undefined), + ) as WechatIlinkLoginHookOptions; +} diff --git a/src/lib/onboard/messaging-channel-setup.test.ts b/src/lib/onboard/messaging-channel-setup.test.ts index 606b914be6..251c28ba6c 100644 --- a/src/lib/onboard/messaging-channel-setup.test.ts +++ b/src/lib/onboard/messaging-channel-setup.test.ts @@ -226,6 +226,7 @@ describe("setupMessagingChannels", () => { it("orchestrates non-interactive manifest selection with injected onboard deps", async () => { process.env.TELEGRAM_BOT_TOKEN = "123456:telegram-token"; process.env.SLACK_BOT_TOKEN = "xoxb-test-slack-token"; + process.env.SLACK_APP_TOKEN = "xapp-test-slack-token"; const steps: string[] = []; const notes: string[] = []; const checkTelegramReachability = vi.fn(async () => {}); @@ -245,4 +246,20 @@ describe("setupMessagingChannels", () => { expect(checkTelegramReachability).toHaveBeenCalledWith("123456:telegram-token"); expect(prompt).not.toHaveBeenCalled(); }); + + it("skips partially configured multi-secret channels in non-interactive mode", async () => { + process.env.SLACK_BOT_TOKEN = "xoxb-test-slack-token"; + const notes: string[] = []; + + const result = await setupMessagingChannels(null, null, { + note: (message) => notes.push(message), + isNonInteractive: () => true, + }); + + expect(result).toEqual([]); + expect(notes).toEqual([ + " [non-interactive] No messaging tokens configured. Skipping.", + ]); + expect(prompt).not.toHaveBeenCalled(); + }); }); diff --git a/src/lib/onboard/messaging-channel-setup.ts b/src/lib/onboard/messaging-channel-setup.ts index 36be1075db..c619eac04c 100644 --- a/src/lib/onboard/messaging-channel-setup.ts +++ b/src/lib/onboard/messaging-channel-setup.ts @@ -1,22 +1,23 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import type { AgentDefinition } from "../agent/defs"; import { getCredential, normalizeCredentialValue, } from "../credentials/store"; import { + type ChannelManifest, + type ChannelSecretInputSpec, createBuiltInChannelManifestRegistry, getMessagingManifestAvailabilityContext, + hasMessagingManifestCredentials, hasMessagingManifestPrimaryCredential, MessagingWorkflowPlanner, resolveMessagingManifestSeed, - toMessagingAgentId, - type ChannelManifest, - type ChannelSecretInputSpec, type SandboxMessagingPlan, + toMessagingAgentId, } from "../messaging"; -import type { AgentDefinition } from "../agent/defs"; export interface SetupSelectedMessagingChannelsOptions { readonly agent?: { readonly name?: string } | null; @@ -51,6 +52,8 @@ export async function setupMessagingChannels( const availabilityContext = getMessagingManifestAvailabilityContext(agent); const availableChannels = manifestRegistry.listAvailable(availabilityContext); const hasManifestCredentials = (manifest: ChannelManifest) => + hasMessagingManifestCredentials(manifest, getMessagingToken); + const hasManifestPrimaryCredential = (manifest: ChannelManifest) => hasMessagingManifestPrimaryCredential(manifest, getMessagingToken); const seedFromState = (includeAllExisting = false): string[] => resolveMessagingManifestSeed( @@ -94,7 +97,7 @@ export async function setupMessagingChannels( output.write(" Available messaging channels:\n"); availableChannels.forEach((manifest, i) => { const marker = enabled.has(manifest.id) ? "●" : "○"; - const status = hasManifestCredentials(manifest) ? " (configured)" : ""; + const status = hasManifestPrimaryCredential(manifest) ? " (configured)" : ""; output.write( ` [${i + 1}] ${marker} ${manifest.id} — ${ manifest.description ?? manifest.displayName diff --git a/test/onboard-messaging.test.ts b/test/onboard-messaging.test.ts index db7bf30fb1..ca440c2f22 100644 --- a/test/onboard-messaging.test.ts +++ b/test/onboard-messaging.test.ts @@ -1244,6 +1244,7 @@ const { createSandbox } = require(${onboardPath}); process.env.OPENSHELL_GATEWAY = "nemoclaw"; process.env.DISCORD_BOT_TOKEN = "test-discord-token"; process.env.SLACK_BOT_TOKEN = "xoxb-test-slack-token"; + process.env.SLACK_APP_TOKEN = "xapp-test-slack-token"; const sandboxName = await createSandbox(null, "gpt-5.4", "nvidia-prod", null, "my-assistant"); console.log(JSON.stringify({ sandboxName, commands })); })().catch((error) => { @@ -1613,6 +1614,7 @@ const { setupMessagingChannels } = require(${onboardPath}); // Only set telegram and slack tokens — discord should be absent process.env.TELEGRAM_BOT_TOKEN = "123456:ABC-test-telegram-token"; process.env.SLACK_BOT_TOKEN = "xoxb-test-slack-token"; + process.env.SLACK_APP_TOKEN = "xapp-test-slack-token"; const result = await setupMessagingChannels(); console.log(JSON.stringify(result)); })().catch((error) => { From ee19d10cc7d6282c0a6fde274e157fecb82387d6 Mon Sep 17 00:00:00 2001 From: San Dang Date: Tue, 26 May 2026 20:58:12 +0700 Subject: [PATCH 20/40] fix(messaging): gate channels on required inputs --- .../messaging/applier/setup-applier.test.ts | 25 +++++++- .../compiler/manifest-compiler.test.ts | 60 ++++++++++++++++--- .../messaging/compiler/manifest-compiler.ts | 19 ++++-- .../compiler/workflow-planner.test.ts | 35 ++++++++--- src/lib/messaging/utils.ts | 35 +++++------ .../onboard/messaging-channel-setup.test.ts | 38 +++++++++++- src/lib/onboard/messaging-channel-setup.ts | 60 +++++-------------- 7 files changed, 180 insertions(+), 92 deletions(-) diff --git a/src/lib/messaging/applier/setup-applier.test.ts b/src/lib/messaging/applier/setup-applier.test.ts index 583f487429..7bd21947fa 100644 --- a/src/lib/messaging/applier/setup-applier.test.ts +++ b/src/lib/messaging/applier/setup-applier.test.ts @@ -103,7 +103,13 @@ 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 planOnboard( + { + WECHAT_BOT_TOKEN: "wechat-token", + WECHAT_ACCOUNT_ID: "wechat-account", + }, + ["wechat"], + ); expect(MessagingSetupApplier.listHookRequests(plan, "enroll")).toEqual([ expect.objectContaining({ @@ -307,6 +313,7 @@ describe("MessagingSetupApplier", () => { it("runs post-install hook implementations and writes their build-file outputs", async () => { const plan = await planOnboard( { + WECHAT_BOT_TOKEN: "wechat-token", WECHAT_ACCOUNT_ID: "wechat-account", WECHAT_BASE_URL: "https://ilinkai.wechat.example", WECHAT_USER_ID: "wechat-user", @@ -397,7 +404,13 @@ describe("MessagingSetupApplier", () => { }); it("rejects prototype-polluting build-file merge keys", async () => { - const plan = await planOnboard({ WECHAT_ACCOUNT_ID: "wechat-account" }, ["wechat"]); + const plan = await planOnboard( + { + WECHAT_BOT_TOKEN: "wechat-token", + WECHAT_ACCOUNT_ID: "wechat-account", + }, + ["wechat"], + ); const files: Record = { "/sandbox/.openclaw/openclaw.json": "{}", }; @@ -508,7 +521,13 @@ 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 planOnboard( + { + WECHAT_BOT_TOKEN: "wechat-token", + WECHAT_ACCOUNT_ID: "wechat-account", + }, + ["wechat"], + ); const runOpenshell: MessagingOpenShellRunner = (args, options) => { if (args.includes("cat") && options?.input === undefined) { return { status: 0, stdout: "{}" }; diff --git a/src/lib/messaging/compiler/manifest-compiler.test.ts b/src/lib/messaging/compiler/manifest-compiler.test.ts index b3437b29e2..66a7f2b08f 100644 --- a/src/lib/messaging/compiler/manifest-compiler.test.ts +++ b/src/lib/messaging/compiler/manifest-compiler.test.ts @@ -235,13 +235,26 @@ describe("ManifestCompiler", () => { }); it("compiles Hermes render and manifest-owned WeChat policy intent", async () => { - const plan = await compiler().compile({ - sandboxName: "demo", - agent: "hermes", - workflow: "rebuild", - isInteractive: false, - selectedChannels: ALL_CHANNELS, - }); + const plan = await withEnv( + { + WECHAT_ACCOUNT_ID: "test-wechat-account", + }, + () => + compiler().compile({ + sandboxName: "demo", + agent: "hermes", + workflow: "rebuild", + isInteractive: false, + selectedChannels: ALL_CHANNELS, + credentialAvailability: { + TELEGRAM_BOT_TOKEN: true, + DISCORD_BOT_TOKEN: true, + WECHAT_BOT_TOKEN: true, + SLACK_BOT_TOKEN: true, + SLACK_APP_TOKEN: true, + }, + }), + ); expect(plan.networkPolicy.entries.find((entry) => entry.channelId === "wechat")).toEqual({ channelId: "wechat", @@ -266,7 +279,38 @@ describe("ManifestCompiler", () => { plan.channels .find((channel) => channel.channelId === "wechat") ?.inputs.find((input) => input.inputId === "accountId"), - ).not.toHaveProperty("value"); + ).toMatchObject({ + kind: "config", + value: "test-wechat-account", + }); + }); + + it("does not activate a requested channel while any required manifest input is missing", async () => { + const plan = await withEnv( + { + WECHAT_ACCOUNT_ID: undefined, + }, + () => + compiler().compile({ + sandboxName: "demo", + agent: "hermes", + workflow: "onboard", + isInteractive: false, + selectedChannels: ["wechat"], + credentialAvailability: { + WECHAT_BOT_TOKEN: true, + }, + }), + ); + + expect(plan.channels[0]).toMatchObject({ + channelId: "wechat", + active: false, + disabled: true, + }); + expect(plan.networkPolicy.entries).toEqual([]); + expect(plan.agentRender).toEqual([]); + expect(plan.healthChecks).toEqual([]); }); it("runs enrollment hooks before returning the final channel input plan", async () => { diff --git a/src/lib/messaging/compiler/manifest-compiler.ts b/src/lib/messaging/compiler/manifest-compiler.ts index e83b9fdd36..33f2a51abb 100644 --- a/src/lib/messaging/compiler/manifest-compiler.ts +++ b/src/lib/messaging/compiler/manifest-compiler.ts @@ -121,7 +121,8 @@ export class ManifestCompiler { context.isInteractive, runEnrollmentChecks: selected && requestedActive && isEnrollmentWorkflow(context.workflow), }); - const active = requestedActive && !resolvedInputs.skipped; + const requiredInputsAvailable = hasRequiredInputsAvailable(manifest, resolvedInputs.inputs); + const active = requestedActive && !resolvedInputs.skipped && requiredInputsAvailable; return { channelId: manifest.id, @@ -130,7 +131,8 @@ export class ManifestCompiler { active, selected, configured: configured && !resolvedInputs.skipped, - disabled: disabled || resolvedInputs.skipped, + disabled: + disabled || resolvedInputs.skipped || (requestedActive && !requiredInputsAvailable), inputs: resolvedInputs.inputs, hooks: active ? manifest.hooks @@ -273,7 +275,8 @@ function inputReferenceBase( function readInputEnvValue(input: ChannelInputSpec): MessagingSerializableValue | undefined { if (!input.envKey) return undefined; const value = process.env[input.envKey]; - return value && value.length > 0 ? value : undefined; + const normalized = value?.replace(/\r/g, "").trim(); + return normalized && normalized.length > 0 ? normalized : undefined; } function readInputStatePath(input: ChannelInputSpec): MessagingStatePath | undefined { @@ -317,12 +320,16 @@ function hasRequiredInputsAvailable( 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; + return isInputReferenceAvailable(resolved); }); } +function isInputReferenceAvailable(input: SandboxMessagingInputReference): boolean { + if (input.kind === "secret") return input.credentialAvailable === true; + if (input.value === undefined) return false; + return typeof input.value === "string" ? input.value.trim().length > 0 : true; +} + function shouldRunEnrollmentHook( hook: ChannelHookSpec, inputs: readonly SandboxMessagingInputReference[], diff --git a/src/lib/messaging/compiler/workflow-planner.test.ts b/src/lib/messaging/compiler/workflow-planner.test.ts index 58f66376d5..551969d83c 100644 --- a/src/lib/messaging/compiler/workflow-planner.test.ts +++ b/src/lib/messaging/compiler/workflow-planner.test.ts @@ -167,6 +167,9 @@ describe("MessagingWorkflowPlanner", () => { isInteractive: true, channelId: "slack", configuredChannels: ["telegram"], + credentialAvailability: { + TELEGRAM_BOT_TOKEN: true, + }, }), ); @@ -222,6 +225,10 @@ describe("MessagingWorkflowPlanner", () => { isInteractive: false, channelId: "telegram", configuredChannels: ["telegram", "slack"], + credentialAvailability: { + SLACK_BOT_TOKEN: true, + SLACK_APP_TOKEN: true, + }, }); expect(plan.workflow).toBe("stop-channel"); @@ -279,6 +286,10 @@ describe("MessagingWorkflowPlanner", () => { channelId: "telegram", configuredChannels: ["telegram", "wechat", "slack"], disabledChannels: ["telegram", "wechat"], + credentialAvailability: { + SLACK_BOT_TOKEN: true, + SLACK_APP_TOKEN: true, + }, }); expect(plan.workflow).toBe("remove-channel"); @@ -293,13 +304,23 @@ describe("MessagingWorkflowPlanner", () => { }); 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"], - }); + const plan = await withEnv( + { + WECHAT_ACCOUNT_ID: "test-wechat-account", + }, + () => + planner().planRebuild({ + sandboxName: "demo", + agent: "openclaw", + isInteractive: false, + configuredChannels: ["telegram", "discord", "wechat"], + disabledChannels: ["discord"], + credentialAvailability: { + TELEGRAM_BOT_TOKEN: true, + WECHAT_BOT_TOKEN: true, + }, + }), + ); expect(plan.workflow).toBe("rebuild"); expect(plan.channels.map((channel) => channel.channelId)).toEqual([ diff --git a/src/lib/messaging/utils.ts b/src/lib/messaging/utils.ts index c55986bdae..0f7c606969 100644 --- a/src/lib/messaging/utils.ts +++ b/src/lib/messaging/utils.ts @@ -2,9 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 import type { + ChannelInputSpec, ChannelManifest, ChannelManifestAvailabilityContext, - ChannelSecretInputSpec, MessagingAgentId, MessagingChannelId, } from "./manifest"; @@ -14,7 +14,7 @@ export interface MessagingAgentDescriptor { readonly messagingPlatforms?: readonly MessagingChannelId[] | null; } -export type MessagingCredentialResolver = (envKey: string) => string | null; +export type MessagingInputResolver = (input: ChannelInputSpec) => string | null; export function toMessagingAgentId( agent: MessagingAgentDescriptor | null | undefined, @@ -37,11 +37,11 @@ export function getMessagingManifestAvailabilityContext( export function resolveMessagingManifestSeed( manifests: readonly ChannelManifest[], existingChannels: readonly string[] | null | undefined, - hasChannelCredentials: (manifest: ChannelManifest) => boolean, + hasChannelRequiredInputs: (manifest: ChannelManifest) => boolean, { includeAllExisting = false }: { readonly includeAllExisting?: boolean } = {}, ): string[] { const seeded = new Set( - manifests.filter(hasChannelCredentials).map((manifest) => manifest.id), + manifests.filter(hasChannelRequiredInputs).map((manifest) => manifest.id), ); if (!Array.isArray(existingChannels)) return Array.from(seeded); @@ -56,25 +56,18 @@ export function resolveMessagingManifestSeed( return Array.from(seeded); } -export function hasMessagingManifestCredentials( +export function hasMessagingManifestRequiredInputs( manifest: ChannelManifest, - resolveCredential: MessagingCredentialResolver, + resolveInput: MessagingInputResolver, ): boolean { - const requiredSecrets = manifest.inputs.filter( - (input): input is ChannelSecretInputSpec => - input.kind === "secret" && input.required && Boolean(input.envKey), - ); - if (requiredSecrets.length === 0) return false; - return requiredSecrets.every((input) => Boolean(input.envKey && resolveCredential(input.envKey))); + const requiredInputs = manifest.inputs.filter((input) => input.required); + if (requiredInputs.length === 0) return false; + return requiredInputs.every((input) => { + if (!input.envKey) return false; + return hasResolvedInputValue(resolveInput(input)); + }); } -export function hasMessagingManifestPrimaryCredential( - manifest: ChannelManifest, - resolveCredential: MessagingCredentialResolver, -): boolean { - const primarySecret = manifest.inputs.find( - (input): input is ChannelSecretInputSpec => - input.kind === "secret" && input.required && Boolean(input.envKey), - ); - return Boolean(primarySecret?.envKey && resolveCredential(primarySecret.envKey)); +function hasResolvedInputValue(value: string | null): boolean { + return typeof value === "string" && value.trim().length > 0; } diff --git a/src/lib/onboard/messaging-channel-setup.test.ts b/src/lib/onboard/messaging-channel-setup.test.ts index 251c28ba6c..579d1ff1a0 100644 --- a/src/lib/onboard/messaging-channel-setup.test.ts +++ b/src/lib/onboard/messaging-channel-setup.test.ts @@ -241,7 +241,7 @@ describe("setupMessagingChannels", () => { expect(result).toEqual(["telegram", "slack"]); expect(steps).toEqual(["5/8 Messaging channels"]); expect(notes).toEqual([ - " [non-interactive] Messaging tokens detected: telegram, slack", + " [non-interactive] Messaging channel inputs detected: telegram, slack", ]); expect(checkTelegramReachability).toHaveBeenCalledWith("123456:telegram-token"); expect(prompt).not.toHaveBeenCalled(); @@ -258,7 +258,41 @@ describe("setupMessagingChannels", () => { expect(result).toEqual([]); expect(notes).toEqual([ - " [non-interactive] No messaging tokens configured. Skipping.", + " [non-interactive] No complete messaging channel inputs configured. Skipping.", + ]); + expect(prompt).not.toHaveBeenCalled(); + }); + + it("skips channels missing required non-secret inputs in non-interactive mode", async () => { + process.env.WECHAT_BOT_TOKEN = "wechat-token"; + process.env.WECHAT_ACCOUNT_ID = " "; + const notes: string[] = []; + + const result = await setupMessagingChannels(null, null, { + note: (message) => notes.push(message), + isNonInteractive: () => true, + }); + + expect(result).toEqual([]); + expect(notes).toEqual([ + " [non-interactive] No complete messaging channel inputs configured. Skipping.", + ]); + expect(prompt).not.toHaveBeenCalled(); + }); + + it("seeds channels with all required secret and non-secret inputs in non-interactive mode", async () => { + process.env.WECHAT_BOT_TOKEN = "wechat-token"; + process.env.WECHAT_ACCOUNT_ID = "wechat-account"; + const notes: string[] = []; + + const result = await setupMessagingChannels(null, null, { + note: (message) => notes.push(message), + isNonInteractive: () => true, + }); + + expect(result).toEqual(["wechat"]); + expect(notes).toEqual([ + " [non-interactive] Messaging channel inputs detected: wechat", ]); expect(prompt).not.toHaveBeenCalled(); }); diff --git a/src/lib/onboard/messaging-channel-setup.ts b/src/lib/onboard/messaging-channel-setup.ts index c619eac04c..4f8f3c2cd2 100644 --- a/src/lib/onboard/messaging-channel-setup.ts +++ b/src/lib/onboard/messaging-channel-setup.ts @@ -8,11 +8,11 @@ import { } from "../credentials/store"; import { type ChannelManifest, + type ChannelInputSpec, type ChannelSecretInputSpec, createBuiltInChannelManifestRegistry, getMessagingManifestAvailabilityContext, - hasMessagingManifestCredentials, - hasMessagingManifestPrimaryCredential, + hasMessagingManifestRequiredInputs, MessagingWorkflowPlanner, resolveMessagingManifestSeed, type SandboxMessagingPlan, @@ -32,11 +32,15 @@ export interface SetupMessagingChannelsDeps { readonly checkTelegramReachability?: (token: string) => Promise; } -type SecretAvailabilitySnapshot = ReadonlyMap>; - const getMessagingToken = (envKey: string): string | null => normalizeCredentialValue(process.env[envKey]) || getCredential(envKey) || null; +const getMessagingInputValue = (input: ChannelInputSpec): string | null => { + if (!input.envKey) return null; + if (input.kind === "secret") return getMessagingToken(input.envKey); + return normalizeCredentialValue(process.env[input.envKey]) || null; +}; + export async function setupMessagingChannels( agent: AgentDefinition | null = null, existingChannels: string[] | null = null, @@ -51,15 +55,13 @@ export async function setupMessagingChannels( const manifestRegistry = createBuiltInChannelManifestRegistry(); const availabilityContext = getMessagingManifestAvailabilityContext(agent); const availableChannels = manifestRegistry.listAvailable(availabilityContext); - const hasManifestCredentials = (manifest: ChannelManifest) => - hasMessagingManifestCredentials(manifest, getMessagingToken); - const hasManifestPrimaryCredential = (manifest: ChannelManifest) => - hasMessagingManifestPrimaryCredential(manifest, getMessagingToken); + const hasManifestRequiredInputs = (manifest: ChannelManifest) => + hasMessagingManifestRequiredInputs(manifest, getMessagingInputValue); const seedFromState = (includeAllExisting = false): string[] => resolveMessagingManifestSeed( availableChannels, existingChannels, - hasManifestCredentials, + hasManifestRequiredInputs, { includeAllExisting }, ); @@ -67,7 +69,7 @@ export async function setupMessagingChannels( const enabled = new Set(seedFromState(false)); const found = Array.from(enabled); if (found.length > 0) { - note(` [non-interactive] Messaging tokens detected: ${found.join(", ")}`); + note(` [non-interactive] Messaging channel inputs detected: ${found.join(", ")}`); await setupSelectedMessagingChannels(found, enabled, availableChannels, { agent, interactive: false, @@ -79,7 +81,7 @@ export async function setupMessagingChannels( } } } else { - note(" [non-interactive] No messaging tokens configured. Skipping."); + note(" [non-interactive] No complete messaging channel inputs configured. Skipping."); } return Array.from(enabled); } @@ -97,7 +99,7 @@ export async function setupMessagingChannels( output.write(" Available messaging channels:\n"); availableChannels.forEach((manifest, i) => { const marker = enabled.has(manifest.id) ? "●" : "○"; - const status = hasManifestPrimaryCredential(manifest) ? " (configured)" : ""; + const status = hasManifestRequiredInputs(manifest) ? " (configured)" : ""; output.write( ` [${i + 1}] ${marker} ${manifest.id} — ${ manifest.description ?? manifest.displayName @@ -171,8 +173,7 @@ export async function setupSelectedMessagingChannels( for (const channelId of selectedChannels) { const manifest = registry.get(channelId); if (!manifest) continue; - const initialSecretAvailability = snapshotSecretAvailability(registry, [channelId]); - if (hasAllSecretInputsAvailable(manifest, initialSecretAvailability)) { + if (hasMessagingManifestRequiredInputs(manifest, getMessagingInputValue)) { printExistingSecretStatus(manifest); printEnrollmentNotes(manifest); } @@ -308,24 +309,6 @@ function logEnrollmentHelp(manifest: ChannelManifest): void { console.log(` ${help}`); } -function snapshotSecretAvailability( - registry: ReturnType, - channelIds: readonly string[], -): SecretAvailabilitySnapshot { - const availability = new Map>(); - for (const channelId of channelIds) { - const manifest = registry.get(channelId); - if (!manifest) continue; - const availableInputs = new Set(); - for (const input of manifest.inputs) { - if (input.kind !== "secret" || !input.envKey) continue; - if (getMessagingToken(input.envKey)) availableInputs.add(input.id); - } - availability.set(manifest.id, availableInputs); - } - return availability; -} - function buildCredentialAvailability( registry: ReturnType, channelIds: readonly string[], @@ -346,19 +329,6 @@ function buildCredentialAvailability( return availability; } -function hasAllSecretInputsAvailable( - manifest: ChannelManifest, - availability: SecretAvailabilitySnapshot, -): boolean { - const secretInputs = manifest.inputs.filter( - (entry): entry is ChannelSecretInputSpec => entry.kind === "secret", - ); - return ( - secretInputs.length > 0 && - secretInputs.every((input) => availability.get(manifest.id)?.has(input.id) === true) - ); -} - function printExistingSecretStatus(manifest: ChannelManifest): void { const secretInputs = manifest.inputs.filter( (entry): entry is ChannelSecretInputSpec => entry.kind === "secret", From 226f6cab7032641ccd962a14a1f4d2d47195b13a Mon Sep 17 00:00:00 2001 From: San Dang Date: Tue, 26 May 2026 21:10:37 +0700 Subject: [PATCH 21/40] fix(messaging): preserve telegram reachability UX --- src/lib/messaging/channels/manifests.test.ts | 11 +++ .../hooks/get-me-reachability.test.ts | 73 +++++++++++++++++-- .../telegram/hooks/get-me-reachability.ts | 38 ++++++++-- .../messaging/channels/telegram/manifest.ts | 7 ++ .../compiler/manifest-compiler.test.ts | 39 ++++++---- src/lib/onboard.ts | 63 ---------------- .../onboard/messaging-channel-setup.test.ts | 23 +++++- src/lib/onboard/messaging-channel-setup.ts | 17 +---- test/e2e/test-channels-stop-start.sh | 5 +- test/e2e/test-messaging-providers.sh | 7 +- test/onboard-messaging.test.ts | 17 ++--- 11 files changed, 177 insertions(+), 123 deletions(-) diff --git a/src/lib/messaging/channels/manifests.test.ts b/src/lib/messaging/channels/manifests.test.ts index 7d5d475652..1ae09eb499 100644 --- a/src/lib/messaging/channels/manifests.test.ts +++ b/src/lib/messaging/channels/manifests.test.ts @@ -76,6 +76,16 @@ function expectConfigPromptEnrollHook( }); } +function expectReachabilityHook(manifest: ChannelManifest, inputIds: readonly string[]): void { + expect(manifest.hooks).toContainEqual({ + id: `${manifest.id}-get-me-reachability`, + phase: "reachability-check", + handler: `${manifest.id}.getMeReachability`, + inputs: inputIds, + onFailure: "abort", + }); +} + describe("built-in channel manifests", () => { it("registers the phase-1 built-in manifests without consuming them in workflows", () => { const registry = createBuiltInChannelManifestRegistry(); @@ -214,6 +224,7 @@ describe("built-in channel manifests", () => { expect(renderJson(telegramManifest)).toContain("telegramConfig.requireMention"); expectTokenPasteEnrollHook(telegramManifest, ["botToken"]); expectConfigPromptEnrollHook(telegramManifest, ["requireMention", "allowedIds"]); + expectReachabilityHook(telegramManifest, ["botToken"]); }); it("declares Discord guild and allowlist render intent for both agents", () => { 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 7faba8646f..04ad2e1532 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 @@ -3,8 +3,8 @@ import { describe, expect, it } from "vitest"; -import type { ChannelHookSpec } from "../../../manifest"; import { MessagingHookRegistry, runMessagingHook } from "../../../hooks"; +import type { ChannelHookSpec } from "../../../manifest"; import { createTelegramGetMeReachabilityHook, TELEGRAM_GET_ME_REACHABILITY_HOOK_ID, @@ -58,15 +58,17 @@ describe("Telegram getMe reachability hook implementation", () => { expect(urls).toEqual(["https://telegram.test/bot123456:telegram-token/getMe"]); }); - it("fails closed when Telegram rejects the token", async () => { + it("warns and continues when Telegram rejects the token", async () => { + const logs: string[] = []; const registry = new MessagingHookRegistry([ { id: TELEGRAM_GET_ME_REACHABILITY_HOOK_ID, handler: createTelegramGetMeReachabilityHook({ + log: (message) => logs.push(message), fetch: async () => ({ ok: false, - status: 401, - statusText: "Unauthorized", + status: 404, + statusText: "Not Found", async json() { return { ok: false }; }, @@ -84,6 +86,67 @@ describe("Telegram getMe reachability hook implementation", () => { botToken: "bad-token", }, }), - ).rejects.toThrow("Telegram reachability check failed with HTTP 401 Unauthorized."); + ).resolves.toMatchObject({ + hookId: "telegram-reachability", + outputs: {}, + }); + expect(logs).toEqual([ + " ⚠ Bot token was rejected by Telegram — verify the token is correct.", + ]); + }); + + it("fails closed when the Bot API request fails in non-interactive mode", async () => { + const registry = new MessagingHookRegistry([ + { + id: TELEGRAM_GET_ME_REACHABILITY_HOOK_ID, + handler: createTelegramGetMeReachabilityHook({ + env: { + NEMOCLAW_NON_INTERACTIVE: "1", + }, + fetch: async () => { + throw new Error("network unavailable"); + }, + }), + }, + ]); + await expect( + runMessagingHook(TELEGRAM_REACHABILITY_HOOK, registry, { + channelId: "telegram", + inputs: { + botToken: "123456:telegram-token", + }, + }), + ).rejects.toThrow("Telegram reachability check failed: Bot API request failed."); + }); + + it("honors the explicit skip env without calling Telegram", async () => { + const urls: string[] = []; + const registry = new MessagingHookRegistry([ + { + id: TELEGRAM_GET_ME_REACHABILITY_HOOK_ID, + handler: createTelegramGetMeReachabilityHook({ + env: { + NEMOCLAW_SKIP_TELEGRAM_REACHABILITY: "1", + }, + fetch: async (url) => { + urls.push(url); + throw new Error("fetch should not run"); + }, + }), + }, + ]); + + await expect( + runMessagingHook(TELEGRAM_REACHABILITY_HOOK, registry, { + channelId: "telegram", + inputs: { + botToken: "123456:telegram-token", + }, + }), + ).resolves.toMatchObject({ + hookId: "telegram-reachability", + outputs: {}, + }); + expect(urls).toEqual([]); }); }); diff --git a/src/lib/messaging/channels/telegram/hooks/get-me-reachability.ts b/src/lib/messaging/channels/telegram/hooks/get-me-reachability.ts index eb4729582e..53db59813a 100644 --- a/src/lib/messaging/channels/telegram/hooks/get-me-reachability.ts +++ b/src/lib/messaging/channels/telegram/hooks/get-me-reachability.ts @@ -17,34 +17,43 @@ interface TelegramFetchResponse { type TelegramFetch = (url: string) => Promise; export interface TelegramGetMeReachabilityHookOptions { + readonly env?: NodeJS.ProcessEnv; readonly fetch?: TelegramFetch; readonly apiBaseUrl?: string; + readonly log?: (message: string) => void; } export function createTelegramGetMeReachabilityHook( options: TelegramGetMeReachabilityHookOptions = {}, ): MessagingHookHandler { return async (context) => { + const env = options.env ?? process.env; + if (env.NEMOCLAW_SKIP_TELEGRAM_REACHABILITY === "1") { + return {}; + } + const rawToken = context.inputs?.botToken; const token = normalizeCredentialValue(typeof rawToken === "string" ? rawToken : ""); if (!token) { throw new Error("Telegram reachability check requires botToken."); } + const log = options.log ?? console.log; const response = await fetchTelegramGetMe(token, options).catch(() => { - throw new Error("Telegram reachability check failed: Bot API request failed."); + const message = "Telegram reachability check failed: Bot API request failed."; + if (env.NEMOCLAW_NON_INTERACTIVE === "1") throw new Error(message); + log(` ⚠ ${message}`); + return null; }); + if (!response) return {}; if (!response.ok) { - throw new Error( - `Telegram reachability check failed with HTTP ${response.status}${ - response.statusText ? ` ${response.statusText}` : "" - }.`, - ); + logTelegramHttpWarning(response, log); + return {}; } const payload = await readTelegramJson(response); if (!isObject(payload) || payload.ok !== true) { - throw new Error("Telegram reachability check failed: Bot API rejected the token."); + log(" ⚠ Bot token was rejected by Telegram — verify the token is correct."); } return {}; @@ -89,3 +98,18 @@ async function readTelegramJson(response: TelegramFetchResponse): Promise { return typeof value === "object" && value !== null && !Array.isArray(value); } + +function logTelegramHttpWarning( + response: TelegramFetchResponse, + log: (message: string) => void, +): void { + if (response.status === 401 || response.status === 404) { + log(" ⚠ Bot token was rejected by Telegram — verify the token is correct."); + return; + } + log( + ` ⚠ Telegram API returned HTTP ${response.status}${ + response.statusText ? ` ${response.statusText}` : "" + } — the bot may not work correctly.`, + ); +} diff --git a/src/lib/messaging/channels/telegram/manifest.ts b/src/lib/messaging/channels/telegram/manifest.ts index c02e239057..27e85e97d6 100644 --- a/src/lib/messaging/channels/telegram/manifest.ts +++ b/src/lib/messaging/channels/telegram/manifest.ts @@ -165,5 +165,12 @@ export const telegramManifest = { }, ], }, + { + id: "telegram-get-me-reachability", + phase: "reachability-check", + handler: "telegram.getMeReachability", + inputs: ["botToken"], + 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 66a7f2b08f..ebcb14ab3b 100644 --- a/src/lib/messaging/compiler/manifest-compiler.test.ts +++ b/src/lib/messaging/compiler/manifest-compiler.test.ts @@ -6,8 +6,8 @@ import { describe, expect, it } from "vitest"; import { createBuiltInChannelManifestRegistry } from "../channels"; import { createBuiltInMessagingHookRegistry, MessagingHookRegistry } from "../hooks"; import { - ChannelManifestRegistry, type ChannelManifest, + ChannelManifestRegistry, type SandboxMessagingPlan, } from "../manifest"; import { ManifestCompiler } from "./manifest-compiler"; @@ -386,7 +386,8 @@ describe("ManifestCompiler", () => { expect(plan.healthChecks).toEqual([]); }); - it("skips token-paste and QR enrollment hooks for non-interactive create plans", async () => { + it("skips enrollment hooks but runs manifest reachability checks for non-interactive create plans", async () => { + const hookCalls: string[] = []; const hooks = new MessagingHookRegistry([ { id: "common.tokenPaste", @@ -396,27 +397,35 @@ describe("ManifestCompiler", () => { }, { id: "telegram.getMeReachability", - handler: () => ({}), + handler: (context) => { + hookCalls.push(`reachability:${String(context.inputs?.botToken)}`); + return {}; + }, }, ]); - const plan = await new ManifestCompiler( - createBuiltInChannelManifestRegistry(), - hooks, - ).compile({ - sandboxName: "demo", - agent: "openclaw", - workflow: "onboard", - isInteractive: false, - selectedChannels: ["telegram"], - credentialAvailability: { - TELEGRAM_BOT_TOKEN: true, + const plan = await withEnv( + { + TELEGRAM_BOT_TOKEN: "123456:raw-telegram-token", }, - }); + () => + 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, }); + expect(hookCalls).toEqual(["reachability:123456:raw-telegram-token"]); + expect(JSON.stringify(plan)).not.toContain("123456:raw-telegram-token"); }); it("reads input values from env keys before returning non-interactive plans", async () => { diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 085ba82d93..088a43892e 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -671,7 +671,6 @@ const { hydrateCredentialEnv }: typeof import("./onboard/credential-env") = const { summarizeCurlFailure, summarizeProbeFailure, - runCurlProbe, } = httpProbe; const selectOnboardAgent = createSelectOnboardAgent({ @@ -5874,65 +5873,6 @@ function getRecordedMessagingChannelsForResume( }); } -// Curl exit codes that indicate a network-level failure (not a token problem). -// 35 (TLS handshake failure) covers corporate proxies that MITM HTTPS. -const TELEGRAM_NETWORK_CURL_CODES = new Set([6, 7, 28, 35, 52, 56]); - -async function checkTelegramReachability(token: string) { - if (process.env.NEMOCLAW_SKIP_TELEGRAM_REACHABILITY === "1") { - note(" [non-interactive] Skipping Telegram reachability probe by request."); - return; - } - - const result = runCurlProbe([ - "-sS", - "--connect-timeout", - "5", - "--max-time", - "10", - `https://api.telegram.org/bot${token}/getMe`, - ]); - - // HTTP 200 with "ok":true — Telegram is reachable and token is valid. - if (result.ok) return; - - // HTTP 401 or 404 — token was rejected by Telegram (not a network issue). - if (result.httpStatus === 401 || result.httpStatus === 404) { - console.log(" ⚠ Bot token was rejected by Telegram — verify the token is correct."); - return; - } - - // Network-level failure — Telegram is unreachable from this host. - if (result.curlStatus && TELEGRAM_NETWORK_CURL_CODES.has(result.curlStatus)) { - console.log(""); - console.log(" ⚠ api.telegram.org is not reachable from this host."); - console.log(" Telegram integration requires outbound HTTPS access to api.telegram.org."); - console.log(" This is commonly blocked by corporate network proxies."); - - if (isNonInteractive()) { - console.error( - " Aborting onboarding in non-interactive mode due to Telegram network reachability failure.", - ); - process.exit(1); - } else { - if (!(await promptYesNoOrDefault(" Continue anyway?", null, false))) { - console.log(" Aborting onboarding."); - process.exit(1); - } - } - return; - } - - // Unexpected probe failure — warn but don't block. - if (!result.ok && result.httpStatus > 0) { - console.log( - ` ⚠ Telegram API returned HTTP ${result.httpStatus} — the bot may not work correctly.`, - ); - } else if (!result.ok) { - console.log(` ⚠ Telegram reachability probe failed: ${result.message}`); - } -} - async function setupMessagingChannels( agent: AgentDefinition | null = null, existingChannels: string[] | null = null, @@ -5941,7 +5881,6 @@ async function setupMessagingChannels( step, note, isNonInteractive, - checkTelegramReachability, }); } @@ -7429,8 +7368,6 @@ module.exports = { fetchGatewayAuthTokenFromSandbox, getProbeAuthMode, getValidationProbeCurlArgs, - checkTelegramReachability, - TELEGRAM_NETWORK_CURL_CODES, verifyCompatibleEndpointSandboxSmoke, resumeProviderShimDeps: { isRoutedInferenceProvider, replaceNamedCredential }, }; diff --git a/src/lib/onboard/messaging-channel-setup.test.ts b/src/lib/onboard/messaging-channel-setup.test.ts index 579d1ff1a0..b2d565f4a2 100644 --- a/src/lib/onboard/messaging-channel-setup.test.ts +++ b/src/lib/onboard/messaging-channel-setup.test.ts @@ -37,16 +37,34 @@ function manifests(...channelIds: string[]) { }); } +function stubTelegramReachability(): void { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + status: 200, + async json() { + return { ok: true }; + }, + async text() { + return ""; + }, + })), + ); +} + describe("setupSelectedMessagingChannels", () => { beforeEach(() => { process.env = { ...ORIGINAL_ENV }; vi.clearAllMocks(); vi.mocked(getCredential).mockReturnValue(null); vi.mocked(prompt).mockResolvedValue(""); + stubTelegramReachability(); }); afterEach(() => { process.env = { ...ORIGINAL_ENV }; + vi.unstubAllGlobals(); vi.restoreAllMocks(); }); @@ -216,10 +234,12 @@ describe("setupMessagingChannels", () => { vi.clearAllMocks(); vi.mocked(getCredential).mockReturnValue(null); vi.mocked(prompt).mockResolvedValue(""); + stubTelegramReachability(); }); afterEach(() => { process.env = { ...ORIGINAL_ENV }; + vi.unstubAllGlobals(); vi.restoreAllMocks(); }); @@ -229,13 +249,11 @@ describe("setupMessagingChannels", () => { process.env.SLACK_APP_TOKEN = "xapp-test-slack-token"; const steps: string[] = []; const notes: string[] = []; - const checkTelegramReachability = vi.fn(async () => {}); const result = await setupMessagingChannels(null, null, { step: (current, total, label) => steps.push(`${current}/${total} ${label}`), note: (message) => notes.push(message), isNonInteractive: () => true, - checkTelegramReachability, }); expect(result).toEqual(["telegram", "slack"]); @@ -243,7 +261,6 @@ describe("setupMessagingChannels", () => { expect(notes).toEqual([ " [non-interactive] Messaging channel inputs detected: telegram, slack", ]); - expect(checkTelegramReachability).toHaveBeenCalledWith("123456:telegram-token"); expect(prompt).not.toHaveBeenCalled(); }); diff --git a/src/lib/onboard/messaging-channel-setup.ts b/src/lib/onboard/messaging-channel-setup.ts index 4f8f3c2cd2..a67fd36bd1 100644 --- a/src/lib/onboard/messaging-channel-setup.ts +++ b/src/lib/onboard/messaging-channel-setup.ts @@ -7,8 +7,8 @@ import { normalizeCredentialValue, } from "../credentials/store"; import { - type ChannelManifest, type ChannelInputSpec, + type ChannelManifest, type ChannelSecretInputSpec, createBuiltInChannelManifestRegistry, getMessagingManifestAvailabilityContext, @@ -29,7 +29,6 @@ export interface SetupMessagingChannelsDeps { readonly step?: (current: number, total: number, label: string) => void; readonly note?: (message: string) => void; readonly isNonInteractive?: () => boolean; - readonly checkTelegramReachability?: (token: string) => Promise; } const getMessagingToken = (envKey: string): string | null => @@ -51,7 +50,6 @@ export async function setupMessagingChannels( const note = deps.note ?? console.log; const isNonInteractive = deps.isNonInteractive ?? (() => process.env.NEMOCLAW_NON_INTERACTIVE === "1"); - const checkTelegramReachability = deps.checkTelegramReachability ?? (async () => {}); const manifestRegistry = createBuiltInChannelManifestRegistry(); const availabilityContext = getMessagingManifestAvailabilityContext(agent); const availableChannels = manifestRegistry.listAvailable(availabilityContext); @@ -74,12 +72,6 @@ export async function setupMessagingChannels( agent, interactive: false, }); - if (enabled.has("telegram")) { - const telegramToken = getMessagingToken("TELEGRAM_BOT_TOKEN"); - if (telegramToken) { - await checkTelegramReachability(telegramToken); - } - } } else { note(" [non-interactive] No complete messaging channel inputs configured. Skipping."); } @@ -122,13 +114,6 @@ export async function setupMessagingChannels( await setupSelectedMessagingChannels(selected, enabled, availableChannels, { agent }); console.log(""); - if (!isNonInteractive() && enabled.has("telegram")) { - const telegramToken = getMessagingToken("TELEGRAM_BOT_TOKEN"); - if (telegramToken) { - await checkTelegramReachability(telegramToken); - } - } - return Array.from(enabled); } diff --git a/test/e2e/test-channels-stop-start.sh b/test/e2e/test-channels-stop-start.sh index 82379f7c8f..e741b0140f 100755 --- a/test/e2e/test-channels-stop-start.sh +++ b/test/e2e/test-channels-stop-start.sh @@ -475,7 +475,10 @@ install_for_active_agent() { export NEMOCLAW_FRESH=1 if [ -z "${NEMOCLAW_SKIP_TELEGRAM_REACHABILITY:-}" ]; then - if ! curl -fsS --max-time 10 https://api.telegram.org/ >/dev/null 2>&1; then + if [[ "${TELEGRAM_BOT_TOKEN:-}" == test-fake-telegram-token-* ]]; then + export NEMOCLAW_SKIP_TELEGRAM_REACHABILITY=1 + info "Using fake Telegram token; setting NEMOCLAW_SKIP_TELEGRAM_REACHABILITY=1" + elif ! curl -fsS --max-time 10 https://api.telegram.org/ >/dev/null 2>&1; then export NEMOCLAW_SKIP_TELEGRAM_REACHABILITY=1 info "api.telegram.org unreachable from host; setting NEMOCLAW_SKIP_TELEGRAM_REACHABILITY=1" fi diff --git a/test/e2e/test-messaging-providers.sh b/test/e2e/test-messaging-providers.sh index 02094a65bf..e760f1946e 100755 --- a/test/e2e/test-messaging-providers.sh +++ b/test/e2e/test-messaging-providers.sh @@ -290,9 +290,12 @@ fi pass "Pre-cleanup complete" if [ -z "${NEMOCLAW_SKIP_TELEGRAM_REACHABILITY:-}" ]; then - if ! curl -fsS --max-time 10 https://api.telegram.org/ >/dev/null 2>&1; then + if [[ "$TELEGRAM_TOKEN" == test-fake-telegram-token-* ]]; then export NEMOCLAW_SKIP_TELEGRAM_REACHABILITY=1 - info "Host cannot reach api.telegram.org; skipping onboarding Telegram reachability probe for fake-token E2E" + info "Using fake Telegram token; skipping manifest Telegram reachability check" + elif ! curl -fsS --max-time 10 https://api.telegram.org/ >/dev/null 2>&1; then + export NEMOCLAW_SKIP_TELEGRAM_REACHABILITY=1 + info "Host cannot reach api.telegram.org; skipping manifest Telegram reachability check" fi fi diff --git a/test/onboard-messaging.test.ts b/test/onboard-messaging.test.ts index ca440c2f22..5db343b93f 100644 --- a/test/onboard-messaging.test.ts +++ b/test/onboard-messaging.test.ts @@ -1582,7 +1582,6 @@ const { createSandbox } = require(${onboardPath}); const scriptPath = path.join(tmpDir, "messaging-noninteractive.js"); const onboardPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "onboard.js")); const runnerPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "runner.js")); - const httpProbePath = JSON.stringify(path.join(repoRoot, "dist", "lib", "adapters", "http", "probe.js")); fs.mkdirSync(fakeBin, { recursive: true }); fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { @@ -1595,17 +1594,13 @@ const _n = (c) => (Array.isArray(c) ? c.join(" ") : String(c)).replace(/'/g, "") runner.run = () => ({ status: 0 }); runner.runCapture = () => ""; -// Stub the Telegram reachability probe so this test doesn't make a real network -// call — on networks where api.telegram.org is blocked, the non-interactive -// preflight would otherwise abort the test. -const httpProbe = require(${httpProbePath}); -httpProbe.runCurlProbe = () => ({ +// Stub the manifest-driven Telegram reachability hook so this test does not +// make a real network call. +global.fetch = async () => ({ ok: true, - httpStatus: 200, - curlStatus: 0, - body: '{"ok":true,"result":{"id":1,"is_bot":true}}', - stderr: "", - message: "", + status: 200, + json: async () => ({ ok: true, result: { id: 1, is_bot: true } }), + text: async () => "", }); const { setupMessagingChannels } = require(${onboardPath}); From accfbd8c3ac2466878ce3b2cda504440cb91bbc2 Mon Sep 17 00:00:00 2001 From: San Dang Date: Tue, 26 May 2026 21:19:19 +0700 Subject: [PATCH 22/40] fix(messaging): run telegram reachability from manifest --- src/lib/messaging/channels/manifests.test.ts | 11 +++ .../hooks/get-me-reachability.test.ts | 73 +++++++++++++++++-- .../telegram/hooks/get-me-reachability.ts | 38 ++++++++-- .../messaging/channels/telegram/manifest.ts | 7 ++ .../compiler/manifest-compiler.test.ts | 39 ++++++---- .../messaging/compiler/manifest-compiler.ts | 29 ++++---- src/lib/onboard.ts | 63 ---------------- .../onboard/messaging-channel-setup.test.ts | 56 +++++++++++++- src/lib/onboard/messaging-channel-setup.ts | 51 ++++--------- test/e2e/test-channels-stop-start.sh | 5 +- test/e2e/test-messaging-providers.sh | 7 +- test/onboard-messaging.test.ts | 17 ++--- 12 files changed, 238 insertions(+), 158 deletions(-) diff --git a/src/lib/messaging/channels/manifests.test.ts b/src/lib/messaging/channels/manifests.test.ts index 7d5d475652..1ae09eb499 100644 --- a/src/lib/messaging/channels/manifests.test.ts +++ b/src/lib/messaging/channels/manifests.test.ts @@ -76,6 +76,16 @@ function expectConfigPromptEnrollHook( }); } +function expectReachabilityHook(manifest: ChannelManifest, inputIds: readonly string[]): void { + expect(manifest.hooks).toContainEqual({ + id: `${manifest.id}-get-me-reachability`, + phase: "reachability-check", + handler: `${manifest.id}.getMeReachability`, + inputs: inputIds, + onFailure: "abort", + }); +} + describe("built-in channel manifests", () => { it("registers the phase-1 built-in manifests without consuming them in workflows", () => { const registry = createBuiltInChannelManifestRegistry(); @@ -214,6 +224,7 @@ describe("built-in channel manifests", () => { expect(renderJson(telegramManifest)).toContain("telegramConfig.requireMention"); expectTokenPasteEnrollHook(telegramManifest, ["botToken"]); expectConfigPromptEnrollHook(telegramManifest, ["requireMention", "allowedIds"]); + expectReachabilityHook(telegramManifest, ["botToken"]); }); it("declares Discord guild and allowlist render intent for both agents", () => { 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 7faba8646f..04ad2e1532 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 @@ -3,8 +3,8 @@ import { describe, expect, it } from "vitest"; -import type { ChannelHookSpec } from "../../../manifest"; import { MessagingHookRegistry, runMessagingHook } from "../../../hooks"; +import type { ChannelHookSpec } from "../../../manifest"; import { createTelegramGetMeReachabilityHook, TELEGRAM_GET_ME_REACHABILITY_HOOK_ID, @@ -58,15 +58,17 @@ describe("Telegram getMe reachability hook implementation", () => { expect(urls).toEqual(["https://telegram.test/bot123456:telegram-token/getMe"]); }); - it("fails closed when Telegram rejects the token", async () => { + it("warns and continues when Telegram rejects the token", async () => { + const logs: string[] = []; const registry = new MessagingHookRegistry([ { id: TELEGRAM_GET_ME_REACHABILITY_HOOK_ID, handler: createTelegramGetMeReachabilityHook({ + log: (message) => logs.push(message), fetch: async () => ({ ok: false, - status: 401, - statusText: "Unauthorized", + status: 404, + statusText: "Not Found", async json() { return { ok: false }; }, @@ -84,6 +86,67 @@ describe("Telegram getMe reachability hook implementation", () => { botToken: "bad-token", }, }), - ).rejects.toThrow("Telegram reachability check failed with HTTP 401 Unauthorized."); + ).resolves.toMatchObject({ + hookId: "telegram-reachability", + outputs: {}, + }); + expect(logs).toEqual([ + " ⚠ Bot token was rejected by Telegram — verify the token is correct.", + ]); + }); + + it("fails closed when the Bot API request fails in non-interactive mode", async () => { + const registry = new MessagingHookRegistry([ + { + id: TELEGRAM_GET_ME_REACHABILITY_HOOK_ID, + handler: createTelegramGetMeReachabilityHook({ + env: { + NEMOCLAW_NON_INTERACTIVE: "1", + }, + fetch: async () => { + throw new Error("network unavailable"); + }, + }), + }, + ]); + await expect( + runMessagingHook(TELEGRAM_REACHABILITY_HOOK, registry, { + channelId: "telegram", + inputs: { + botToken: "123456:telegram-token", + }, + }), + ).rejects.toThrow("Telegram reachability check failed: Bot API request failed."); + }); + + it("honors the explicit skip env without calling Telegram", async () => { + const urls: string[] = []; + const registry = new MessagingHookRegistry([ + { + id: TELEGRAM_GET_ME_REACHABILITY_HOOK_ID, + handler: createTelegramGetMeReachabilityHook({ + env: { + NEMOCLAW_SKIP_TELEGRAM_REACHABILITY: "1", + }, + fetch: async (url) => { + urls.push(url); + throw new Error("fetch should not run"); + }, + }), + }, + ]); + + await expect( + runMessagingHook(TELEGRAM_REACHABILITY_HOOK, registry, { + channelId: "telegram", + inputs: { + botToken: "123456:telegram-token", + }, + }), + ).resolves.toMatchObject({ + hookId: "telegram-reachability", + outputs: {}, + }); + expect(urls).toEqual([]); }); }); diff --git a/src/lib/messaging/channels/telegram/hooks/get-me-reachability.ts b/src/lib/messaging/channels/telegram/hooks/get-me-reachability.ts index eb4729582e..53db59813a 100644 --- a/src/lib/messaging/channels/telegram/hooks/get-me-reachability.ts +++ b/src/lib/messaging/channels/telegram/hooks/get-me-reachability.ts @@ -17,34 +17,43 @@ interface TelegramFetchResponse { type TelegramFetch = (url: string) => Promise; export interface TelegramGetMeReachabilityHookOptions { + readonly env?: NodeJS.ProcessEnv; readonly fetch?: TelegramFetch; readonly apiBaseUrl?: string; + readonly log?: (message: string) => void; } export function createTelegramGetMeReachabilityHook( options: TelegramGetMeReachabilityHookOptions = {}, ): MessagingHookHandler { return async (context) => { + const env = options.env ?? process.env; + if (env.NEMOCLAW_SKIP_TELEGRAM_REACHABILITY === "1") { + return {}; + } + const rawToken = context.inputs?.botToken; const token = normalizeCredentialValue(typeof rawToken === "string" ? rawToken : ""); if (!token) { throw new Error("Telegram reachability check requires botToken."); } + const log = options.log ?? console.log; const response = await fetchTelegramGetMe(token, options).catch(() => { - throw new Error("Telegram reachability check failed: Bot API request failed."); + const message = "Telegram reachability check failed: Bot API request failed."; + if (env.NEMOCLAW_NON_INTERACTIVE === "1") throw new Error(message); + log(` ⚠ ${message}`); + return null; }); + if (!response) return {}; if (!response.ok) { - throw new Error( - `Telegram reachability check failed with HTTP ${response.status}${ - response.statusText ? ` ${response.statusText}` : "" - }.`, - ); + logTelegramHttpWarning(response, log); + return {}; } const payload = await readTelegramJson(response); if (!isObject(payload) || payload.ok !== true) { - throw new Error("Telegram reachability check failed: Bot API rejected the token."); + log(" ⚠ Bot token was rejected by Telegram — verify the token is correct."); } return {}; @@ -89,3 +98,18 @@ async function readTelegramJson(response: TelegramFetchResponse): Promise { return typeof value === "object" && value !== null && !Array.isArray(value); } + +function logTelegramHttpWarning( + response: TelegramFetchResponse, + log: (message: string) => void, +): void { + if (response.status === 401 || response.status === 404) { + log(" ⚠ Bot token was rejected by Telegram — verify the token is correct."); + return; + } + log( + ` ⚠ Telegram API returned HTTP ${response.status}${ + response.statusText ? ` ${response.statusText}` : "" + } — the bot may not work correctly.`, + ); +} diff --git a/src/lib/messaging/channels/telegram/manifest.ts b/src/lib/messaging/channels/telegram/manifest.ts index c02e239057..27e85e97d6 100644 --- a/src/lib/messaging/channels/telegram/manifest.ts +++ b/src/lib/messaging/channels/telegram/manifest.ts @@ -165,5 +165,12 @@ export const telegramManifest = { }, ], }, + { + id: "telegram-get-me-reachability", + phase: "reachability-check", + handler: "telegram.getMeReachability", + inputs: ["botToken"], + 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 66a7f2b08f..ebcb14ab3b 100644 --- a/src/lib/messaging/compiler/manifest-compiler.test.ts +++ b/src/lib/messaging/compiler/manifest-compiler.test.ts @@ -6,8 +6,8 @@ import { describe, expect, it } from "vitest"; import { createBuiltInChannelManifestRegistry } from "../channels"; import { createBuiltInMessagingHookRegistry, MessagingHookRegistry } from "../hooks"; import { - ChannelManifestRegistry, type ChannelManifest, + ChannelManifestRegistry, type SandboxMessagingPlan, } from "../manifest"; import { ManifestCompiler } from "./manifest-compiler"; @@ -386,7 +386,8 @@ describe("ManifestCompiler", () => { expect(plan.healthChecks).toEqual([]); }); - it("skips token-paste and QR enrollment hooks for non-interactive create plans", async () => { + it("skips enrollment hooks but runs manifest reachability checks for non-interactive create plans", async () => { + const hookCalls: string[] = []; const hooks = new MessagingHookRegistry([ { id: "common.tokenPaste", @@ -396,27 +397,35 @@ describe("ManifestCompiler", () => { }, { id: "telegram.getMeReachability", - handler: () => ({}), + handler: (context) => { + hookCalls.push(`reachability:${String(context.inputs?.botToken)}`); + return {}; + }, }, ]); - const plan = await new ManifestCompiler( - createBuiltInChannelManifestRegistry(), - hooks, - ).compile({ - sandboxName: "demo", - agent: "openclaw", - workflow: "onboard", - isInteractive: false, - selectedChannels: ["telegram"], - credentialAvailability: { - TELEGRAM_BOT_TOKEN: true, + const plan = await withEnv( + { + TELEGRAM_BOT_TOKEN: "123456:raw-telegram-token", }, - }); + () => + 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, }); + expect(hookCalls).toEqual(["reachability:123456:raw-telegram-token"]); + expect(JSON.stringify(plan)).not.toContain("123456:raw-telegram-token"); }); it("reads input values from env keys before returning non-interactive plans", async () => { diff --git a/src/lib/messaging/compiler/manifest-compiler.ts b/src/lib/messaging/compiler/manifest-compiler.ts index 33f2a51abb..ec8ea07d5a 100644 --- a/src/lib/messaging/compiler/manifest-compiler.ts +++ b/src/lib/messaging/compiler/manifest-compiler.ts @@ -2,29 +2,29 @@ // SPDX-License-Identifier: Apache-2.0 import type { - ChannelHookSpec, + MessagingHookInputMap, + MessagingHookOutputMap, + MessagingHookRunResult, +} from "../hooks"; +import { + BUILT_IN_MESSAGING_HOOK_REGISTRY, + MessagingHookRegistry, + runMessagingHook, +} from "../hooks"; +import type { ChannelHookOutputSpec, + ChannelHookSpec, ChannelInputSpec, ChannelManifest, ChannelManifestRegistry, MessagingChannelId, - MessagingStatePath, MessagingSerializableValue, + MessagingStatePath, SandboxMessagingChannelPlan, SandboxMessagingHookReferencePlan, SandboxMessagingInputReference, SandboxMessagingPlan, } from "../manifest"; -import { - BUILT_IN_MESSAGING_HOOK_REGISTRY, - 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"; @@ -119,7 +119,10 @@ export class ManifestCompiler { requestedActive && isEnrollmentWorkflow(context.workflow) && context.isInteractive, - runEnrollmentChecks: selected && requestedActive && isEnrollmentWorkflow(context.workflow), + runEnrollmentChecks: + selected && + requestedActive && + isEnrollmentWorkflow(context.workflow), }); const requiredInputsAvailable = hasRequiredInputsAvailable(manifest, resolvedInputs.inputs); const active = requestedActive && !resolvedInputs.skipped && requiredInputsAvailable; diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 085ba82d93..088a43892e 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -671,7 +671,6 @@ const { hydrateCredentialEnv }: typeof import("./onboard/credential-env") = const { summarizeCurlFailure, summarizeProbeFailure, - runCurlProbe, } = httpProbe; const selectOnboardAgent = createSelectOnboardAgent({ @@ -5874,65 +5873,6 @@ function getRecordedMessagingChannelsForResume( }); } -// Curl exit codes that indicate a network-level failure (not a token problem). -// 35 (TLS handshake failure) covers corporate proxies that MITM HTTPS. -const TELEGRAM_NETWORK_CURL_CODES = new Set([6, 7, 28, 35, 52, 56]); - -async function checkTelegramReachability(token: string) { - if (process.env.NEMOCLAW_SKIP_TELEGRAM_REACHABILITY === "1") { - note(" [non-interactive] Skipping Telegram reachability probe by request."); - return; - } - - const result = runCurlProbe([ - "-sS", - "--connect-timeout", - "5", - "--max-time", - "10", - `https://api.telegram.org/bot${token}/getMe`, - ]); - - // HTTP 200 with "ok":true — Telegram is reachable and token is valid. - if (result.ok) return; - - // HTTP 401 or 404 — token was rejected by Telegram (not a network issue). - if (result.httpStatus === 401 || result.httpStatus === 404) { - console.log(" ⚠ Bot token was rejected by Telegram — verify the token is correct."); - return; - } - - // Network-level failure — Telegram is unreachable from this host. - if (result.curlStatus && TELEGRAM_NETWORK_CURL_CODES.has(result.curlStatus)) { - console.log(""); - console.log(" ⚠ api.telegram.org is not reachable from this host."); - console.log(" Telegram integration requires outbound HTTPS access to api.telegram.org."); - console.log(" This is commonly blocked by corporate network proxies."); - - if (isNonInteractive()) { - console.error( - " Aborting onboarding in non-interactive mode due to Telegram network reachability failure.", - ); - process.exit(1); - } else { - if (!(await promptYesNoOrDefault(" Continue anyway?", null, false))) { - console.log(" Aborting onboarding."); - process.exit(1); - } - } - return; - } - - // Unexpected probe failure — warn but don't block. - if (!result.ok && result.httpStatus > 0) { - console.log( - ` ⚠ Telegram API returned HTTP ${result.httpStatus} — the bot may not work correctly.`, - ); - } else if (!result.ok) { - console.log(` ⚠ Telegram reachability probe failed: ${result.message}`); - } -} - async function setupMessagingChannels( agent: AgentDefinition | null = null, existingChannels: string[] | null = null, @@ -5941,7 +5881,6 @@ async function setupMessagingChannels( step, note, isNonInteractive, - checkTelegramReachability, }); } @@ -7429,8 +7368,6 @@ module.exports = { fetchGatewayAuthTokenFromSandbox, getProbeAuthMode, getValidationProbeCurlArgs, - checkTelegramReachability, - TELEGRAM_NETWORK_CURL_CODES, verifyCompatibleEndpointSandboxSmoke, resumeProviderShimDeps: { isRoutedInferenceProvider, replaceNamedCredential }, }; diff --git a/src/lib/onboard/messaging-channel-setup.test.ts b/src/lib/onboard/messaging-channel-setup.test.ts index 579d1ff1a0..4240e4d01a 100644 --- a/src/lib/onboard/messaging-channel-setup.test.ts +++ b/src/lib/onboard/messaging-channel-setup.test.ts @@ -37,16 +37,34 @@ function manifests(...channelIds: string[]) { }); } +function stubTelegramReachability(): void { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + status: 200, + async json() { + return { ok: true }; + }, + async text() { + return ""; + }, + })), + ); +} + describe("setupSelectedMessagingChannels", () => { beforeEach(() => { process.env = { ...ORIGINAL_ENV }; vi.clearAllMocks(); vi.mocked(getCredential).mockReturnValue(null); vi.mocked(prompt).mockResolvedValue(""); + stubTelegramReachability(); }); afterEach(() => { process.env = { ...ORIGINAL_ENV }; + vi.unstubAllGlobals(); vi.restoreAllMocks(); }); @@ -78,6 +96,39 @@ describe("setupSelectedMessagingChannels", () => { ); }); + it("runs Telegram reachability once during interactive setup", async () => { + process.env.TELEGRAM_BOT_TOKEN = "123456:ABC-test-token"; + process.env.TELEGRAM_REQUIRE_MENTION = "1"; + process.env.TELEGRAM_ALLOWED_IDS = "123456789"; + const fetchMock = vi.fn(async () => ({ + ok: false, + status: 404, + statusText: "Not Found", + async json() { + return { ok: false }; + }, + async text() { + return ""; + }, + })); + vi.stubGlobal("fetch", fetchMock); + const logs: string[] = []; + vi.spyOn(console, "log").mockImplementation((message = "") => { + logs.push(String(message)); + }); + + await setupSelectedMessagingChannels( + ["telegram"], + new Set(["telegram"]), + manifests("telegram"), + ); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect( + logs.filter((line) => line.includes("Bot token was rejected by Telegram")), + ).toHaveLength(1); + }); + it("uses manifest token validation for Slack dual-token enrollment", async () => { const logs: string[] = []; vi.mocked(prompt).mockResolvedValueOnce("not-a-slack-token"); @@ -216,10 +267,12 @@ describe("setupMessagingChannels", () => { vi.clearAllMocks(); vi.mocked(getCredential).mockReturnValue(null); vi.mocked(prompt).mockResolvedValue(""); + stubTelegramReachability(); }); afterEach(() => { process.env = { ...ORIGINAL_ENV }; + vi.unstubAllGlobals(); vi.restoreAllMocks(); }); @@ -229,13 +282,11 @@ describe("setupMessagingChannels", () => { process.env.SLACK_APP_TOKEN = "xapp-test-slack-token"; const steps: string[] = []; const notes: string[] = []; - const checkTelegramReachability = vi.fn(async () => {}); const result = await setupMessagingChannels(null, null, { step: (current, total, label) => steps.push(`${current}/${total} ${label}`), note: (message) => notes.push(message), isNonInteractive: () => true, - checkTelegramReachability, }); expect(result).toEqual(["telegram", "slack"]); @@ -243,7 +294,6 @@ describe("setupMessagingChannels", () => { expect(notes).toEqual([ " [non-interactive] Messaging channel inputs detected: telegram, slack", ]); - expect(checkTelegramReachability).toHaveBeenCalledWith("123456:telegram-token"); expect(prompt).not.toHaveBeenCalled(); }); diff --git a/src/lib/onboard/messaging-channel-setup.ts b/src/lib/onboard/messaging-channel-setup.ts index 4f8f3c2cd2..c7d1776b25 100644 --- a/src/lib/onboard/messaging-channel-setup.ts +++ b/src/lib/onboard/messaging-channel-setup.ts @@ -7,8 +7,8 @@ import { normalizeCredentialValue, } from "../credentials/store"; import { - type ChannelManifest, type ChannelInputSpec, + type ChannelManifest, type ChannelSecretInputSpec, createBuiltInChannelManifestRegistry, getMessagingManifestAvailabilityContext, @@ -29,7 +29,6 @@ export interface SetupMessagingChannelsDeps { readonly step?: (current: number, total: number, label: string) => void; readonly note?: (message: string) => void; readonly isNonInteractive?: () => boolean; - readonly checkTelegramReachability?: (token: string) => Promise; } const getMessagingToken = (envKey: string): string | null => @@ -51,7 +50,6 @@ export async function setupMessagingChannels( const note = deps.note ?? console.log; const isNonInteractive = deps.isNonInteractive ?? (() => process.env.NEMOCLAW_NON_INTERACTIVE === "1"); - const checkTelegramReachability = deps.checkTelegramReachability ?? (async () => {}); const manifestRegistry = createBuiltInChannelManifestRegistry(); const availabilityContext = getMessagingManifestAvailabilityContext(agent); const availableChannels = manifestRegistry.listAvailable(availabilityContext); @@ -74,12 +72,6 @@ export async function setupMessagingChannels( agent, interactive: false, }); - if (enabled.has("telegram")) { - const telegramToken = getMessagingToken("TELEGRAM_BOT_TOKEN"); - if (telegramToken) { - await checkTelegramReachability(telegramToken); - } - } } else { note(" [non-interactive] No complete messaging channel inputs configured. Skipping."); } @@ -122,13 +114,6 @@ export async function setupMessagingChannels( await setupSelectedMessagingChannels(selected, enabled, availableChannels, { agent }); console.log(""); - if (!isNonInteractive() && enabled.has("telegram")) { - const telegramToken = getMessagingToken("TELEGRAM_BOT_TOKEN"); - if (telegramToken) { - await checkTelegramReachability(telegramToken); - } - } - return Array.from(enabled); } @@ -177,37 +162,27 @@ export async function setupSelectedMessagingChannels( printExistingSecretStatus(manifest); printEnrollmentNotes(manifest); } - const enrolledPlan = await planner.planOnboard({ - sandboxName, - agent, - isInteractive: true, - selectedChannels: [channelId], - supportedChannelIds, - credentialAvailability: buildCredentialAvailability(registry, [channelId]), - }); - const enrolledChannel = enrolledPlan.channels.find((channel) => channel.channelId === channelId); - if (!enrolledChannel?.active) { - enabled.delete(channelId); - continue; - } - if (manifest.auth.mode === "in-sandbox-qr") printInSandboxQrStatus(manifest); } - const finalSelectedChannels = selectedChannels.filter((channelId) => enabled.has(channelId)); - const finalPlan = await planner.planOnboard({ + const plan = await planner.planOnboard({ sandboxName, agent, - isInteractive: false, - selectedChannels: finalSelectedChannels, + isInteractive: true, + selectedChannels, supportedChannelIds, - credentialAvailability: buildCredentialAvailability(registry, finalSelectedChannels), + credentialAvailability: buildCredentialAvailability(registry, selectedChannels), }); - for (const channel of finalPlan.channels) { - if (!channel.active) enabled.delete(channel.channelId); + for (const channel of plan.channels) { + if (!channel.active) { + enabled.delete(channel.channelId); + continue; + } + const manifest = registry.get(channel.channelId); + if (manifest?.auth.mode === "in-sandbox-qr") printInSandboxQrStatus(manifest); } - return finalPlan; + return plan; } function readMessagingChannelSelection( diff --git a/test/e2e/test-channels-stop-start.sh b/test/e2e/test-channels-stop-start.sh index 82379f7c8f..e741b0140f 100755 --- a/test/e2e/test-channels-stop-start.sh +++ b/test/e2e/test-channels-stop-start.sh @@ -475,7 +475,10 @@ install_for_active_agent() { export NEMOCLAW_FRESH=1 if [ -z "${NEMOCLAW_SKIP_TELEGRAM_REACHABILITY:-}" ]; then - if ! curl -fsS --max-time 10 https://api.telegram.org/ >/dev/null 2>&1; then + if [[ "${TELEGRAM_BOT_TOKEN:-}" == test-fake-telegram-token-* ]]; then + export NEMOCLAW_SKIP_TELEGRAM_REACHABILITY=1 + info "Using fake Telegram token; setting NEMOCLAW_SKIP_TELEGRAM_REACHABILITY=1" + elif ! curl -fsS --max-time 10 https://api.telegram.org/ >/dev/null 2>&1; then export NEMOCLAW_SKIP_TELEGRAM_REACHABILITY=1 info "api.telegram.org unreachable from host; setting NEMOCLAW_SKIP_TELEGRAM_REACHABILITY=1" fi diff --git a/test/e2e/test-messaging-providers.sh b/test/e2e/test-messaging-providers.sh index 02094a65bf..e760f1946e 100755 --- a/test/e2e/test-messaging-providers.sh +++ b/test/e2e/test-messaging-providers.sh @@ -290,9 +290,12 @@ fi pass "Pre-cleanup complete" if [ -z "${NEMOCLAW_SKIP_TELEGRAM_REACHABILITY:-}" ]; then - if ! curl -fsS --max-time 10 https://api.telegram.org/ >/dev/null 2>&1; then + if [[ "$TELEGRAM_TOKEN" == test-fake-telegram-token-* ]]; then export NEMOCLAW_SKIP_TELEGRAM_REACHABILITY=1 - info "Host cannot reach api.telegram.org; skipping onboarding Telegram reachability probe for fake-token E2E" + info "Using fake Telegram token; skipping manifest Telegram reachability check" + elif ! curl -fsS --max-time 10 https://api.telegram.org/ >/dev/null 2>&1; then + export NEMOCLAW_SKIP_TELEGRAM_REACHABILITY=1 + info "Host cannot reach api.telegram.org; skipping manifest Telegram reachability check" fi fi diff --git a/test/onboard-messaging.test.ts b/test/onboard-messaging.test.ts index ca440c2f22..5db343b93f 100644 --- a/test/onboard-messaging.test.ts +++ b/test/onboard-messaging.test.ts @@ -1582,7 +1582,6 @@ const { createSandbox } = require(${onboardPath}); const scriptPath = path.join(tmpDir, "messaging-noninteractive.js"); const onboardPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "onboard.js")); const runnerPath = JSON.stringify(path.join(repoRoot, "dist", "lib", "runner.js")); - const httpProbePath = JSON.stringify(path.join(repoRoot, "dist", "lib", "adapters", "http", "probe.js")); fs.mkdirSync(fakeBin, { recursive: true }); fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { @@ -1595,17 +1594,13 @@ const _n = (c) => (Array.isArray(c) ? c.join(" ") : String(c)).replace(/'/g, "") runner.run = () => ({ status: 0 }); runner.runCapture = () => ""; -// Stub the Telegram reachability probe so this test doesn't make a real network -// call — on networks where api.telegram.org is blocked, the non-interactive -// preflight would otherwise abort the test. -const httpProbe = require(${httpProbePath}); -httpProbe.runCurlProbe = () => ({ +// Stub the manifest-driven Telegram reachability hook so this test does not +// make a real network call. +global.fetch = async () => ({ ok: true, - httpStatus: 200, - curlStatus: 0, - body: '{"ok":true,"result":{"id":1,"is_bot":true}}', - stderr: "", - message: "", + status: 200, + json: async () => ({ ok: true, result: { id: 1, is_bot: true } }), + text: async () => "", }); const { setupMessagingChannels } = require(${onboardPath}); From 63f3641998b298d7e1a5b2f123ccdf142f611cc4 Mon Sep 17 00:00:00 2001 From: San Dang Date: Tue, 26 May 2026 21:28:09 +0700 Subject: [PATCH 23/40] fix(onboard): pass sandbox name to messaging plans --- src/lib/onboard.ts | 2 ++ .../onboard/machine/handlers/sandbox.test.ts | 10 ++++++-- src/lib/onboard/machine/handlers/sandbox.ts | 10 +++++--- .../onboard/messaging-channel-setup.test.ts | 19 +++++++++++++++ src/lib/onboard/messaging-channel-setup.ts | 24 +++++++++++++++++-- 5 files changed, 58 insertions(+), 7 deletions(-) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 088a43892e..9ac4e840a6 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -5876,11 +5876,13 @@ function getRecordedMessagingChannelsForResume( async function setupMessagingChannels( agent: AgentDefinition | null = null, existingChannels: string[] | null = null, + sandboxName: string | null = null, ): Promise { return setupMessagingChannelsImpl(agent, existingChannels, { step, note, isNonInteractive, + sandboxName, }); } diff --git a/src/lib/onboard/machine/handlers/sandbox.test.ts b/src/lib/onboard/machine/handlers/sandbox.test.ts index 52cf8a6db2..fc1ae83c46 100644 --- a/src/lib/onboard/machine/handlers/sandbox.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox.test.ts @@ -132,7 +132,7 @@ describe("handleSandboxState", () => { const result = await handleSandboxState(baseOptions(deps)); expect(calls.startStep).toHaveBeenCalledWith("sandbox", { provider: "provider", model: "model" }); - expect(calls.setupMessaging).toHaveBeenCalledWith(null, null); + expect(calls.setupMessaging).toHaveBeenCalledWith(null, ["telegram"], "my-assistant"); expect(calls.promptName).toHaveBeenCalledWith(null); expect(calls.createSandbox).toHaveBeenCalledWith( { type: "nvidia" }, @@ -297,11 +297,17 @@ describe("handleSandboxState", () => { }); it("uses recorded messaging channels on non-interactive resume", async () => { - const { deps, calls } = createDeps({ getRecordedMessagingChannelsForResume: vi.fn(() => ["discord"]) }); + const getRecordedMessagingChannelsForResume = vi.fn(() => ["discord"]); + const { deps, calls } = createDeps({ getRecordedMessagingChannelsForResume }); const result = await handleSandboxState({ ...baseOptions(deps), resume: true }); expect(calls.setupMessaging).not.toHaveBeenCalled(); + expect(getRecordedMessagingChannelsForResume).toHaveBeenCalledWith( + true, + expect.any(Object), + "my-assistant", + ); expect(calls.note).toHaveBeenCalledWith(" [non-interactive] Reusing messaging channel configuration: discord"); expect(result.selectedMessagingChannels).toEqual(["discord"]); }); diff --git a/src/lib/onboard/machine/handlers/sandbox.ts b/src/lib/onboard/machine/handlers/sandbox.ts index 032fc74276..12170f860f 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -54,7 +54,11 @@ export interface SandboxStateOptions; + setupMessagingChannels( + agent: Agent, + existingChannels: string[] | null, + sandboxName: string, + ): Promise; readMessagingChannelConfigFromEnv(): MessagingChannelConfig | null; promptValidatedSandboxName(agent: Agent): Promise; selectResourceProfileForSandbox(): Promise; @@ -255,6 +259,7 @@ export async function handleSandboxState { @@ -274,7 +279,6 @@ export async function handleSandboxState { }); expect(logs.join("\n")).toContain("WhatsApp Web pairs via QR code"); }); + + it("threads the resolved sandbox name into manifest provider bindings", async () => { + process.env.TELEGRAM_BOT_TOKEN = "123456:telegram-token"; + + const plan = await setupSelectedMessagingChannels( + ["telegram"], + new Set(["telegram"]), + manifests("telegram"), + { interactive: false, sandboxName: "actual-sandbox" }, + ); + + expect(plan?.credentialBindings).toContainEqual( + expect.objectContaining({ + channelId: "telegram", + providerName: "actual-sandbox-telegram-bridge", + }), + ); + expect(JSON.stringify(plan)).not.toContain("pending-sandbox"); + }); }); describe("setupMessagingChannels", () => { diff --git a/src/lib/onboard/messaging-channel-setup.ts b/src/lib/onboard/messaging-channel-setup.ts index c7d1776b25..96b05cd6f1 100644 --- a/src/lib/onboard/messaging-channel-setup.ts +++ b/src/lib/onboard/messaging-channel-setup.ts @@ -29,6 +29,7 @@ export interface SetupMessagingChannelsDeps { readonly step?: (current: number, total: number, label: string) => void; readonly note?: (message: string) => void; readonly isNonInteractive?: () => boolean; + readonly sandboxName?: string | null; } const getMessagingToken = (envKey: string): string | null => @@ -71,6 +72,7 @@ export async function setupMessagingChannels( await setupSelectedMessagingChannels(found, enabled, availableChannels, { agent, interactive: false, + sandboxName: deps.sandboxName, }); } else { note(" [non-interactive] No complete messaging channel inputs configured. Skipping."); @@ -111,7 +113,10 @@ export async function setupMessagingChannels( return []; } - await setupSelectedMessagingChannels(selected, enabled, availableChannels, { agent }); + await setupSelectedMessagingChannels(selected, enabled, availableChannels, { + agent, + sandboxName: deps.sandboxName, + }); console.log(""); return Array.from(enabled); @@ -137,7 +142,7 @@ export async function setupSelectedMessagingChannels( if (selectedChannels.length === 0) return null; const agent = toMessagingAgentId(options.agent); - const sandboxName = options.sandboxName || "pending-sandbox"; + const sandboxName = resolveMessagingSetupSandboxName(options); const planner = new MessagingWorkflowPlanner(registry); if (options.interactive === false) { @@ -333,3 +338,18 @@ function printInSandboxQrStatus(manifest: ChannelManifest): void { function tokenNoun(inputId: string): string { return inputId === "appToken" ? "app token" : "token"; } + +function resolveMessagingSetupSandboxName( + options: SetupSelectedMessagingChannelsOptions, +): string { + const explicitName = normalizeSandboxName(options.sandboxName); + if (explicitName) return explicitName; + const envName = normalizeSandboxName(process.env.NEMOCLAW_SANDBOX_NAME); + if (envName) return envName; + return options.agent?.name === "hermes" ? "hermes" : "my-assistant"; +} + +function normalizeSandboxName(value: string | null | undefined): string | null { + const normalized = value?.trim(); + return normalized ? normalized : null; +} From 64945cb99b9cda33d3ad1ba45682024cb5f6d843 Mon Sep 17 00:00:00 2001 From: San Dang Date: Tue, 26 May 2026 21:42:29 +0700 Subject: [PATCH 24/40] fix(messaging): plan channel adds from manifests --- src/lib/actions/sandbox/policy-channel.ts | 403 +++++++++++++--------- test/channels-add-preset.test.ts | 53 ++- 2 files changed, 288 insertions(+), 168 deletions(-) diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index 3a852db58d..b61777f391 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -5,33 +5,43 @@ import fs from "node:fs"; import path from "node:path"; -import { loadAgent, type AgentDefinition } from "../../agent/defs"; +import { type AgentDefinition, loadAgent } from "../../agent/defs"; import { CLI_DISPLAY_NAME, CLI_NAME } from "../../cli/branding"; -import { hashCredential } from "../../security/credential-hash"; -import { getCredential, prompt as askPrompt } from "../../credentials/store"; -import { createBuiltInChannelManifestRegistry } from "../../messaging/channels"; +import { prompt as askPrompt } from "../../credentials/store"; import { recoverNamedGatewayRuntime } from "../../gateway-runtime-action"; +import { + type ChannelManifest, + createBuiltInChannelManifestRegistry, + getMessagingManifestAvailabilityContext, + MessagingWorkflowPlanner, + type SandboxMessagingChannelPlan, + type SandboxMessagingPlan, + toMessagingAgentId, +} from "../../messaging"; +import { + type MessagingChannelConfig, + mergeMessagingChannelConfigs, + normalizeMessagingChannelConfigValue, + sanitizeMessagingChannelConfig, +} from "../../messaging-channel-config"; +import { hashCredential } from "../../security/credential-hash"; + const { isNonInteractive } = require("../../onboard") as { isNonInteractive: () => boolean }; const onboardProviders = require("../../onboard/providers"); + import * as policies from "../../policy"; -// Lazy-required: keeps qrcode-terminal + the iLink HTTP client out of the -// import graph for non-host-qr channels-add calls. -const { HOST_QR_LOGIN_HANDLERS } = require("../../host-qr-handlers") as typeof import("../../host-qr-handlers"); + const onboardSession = require("../../state/onboard-session") as typeof import("../../state/onboard-session"); +import { runOpenshell } from "../../adapters/openshell/runtime"; import { - parsePolicyAddOptions, type PolicyAddOptions, type PolicyRemoveOptions, + parsePolicyAddOptions, } from "../../domain/policy-channel"; -import * as registry from "../../state/registry"; -import { runOpenshell } from "../../adapters/openshell/runtime"; +import { getMessagingToken } from "../../onboard/messaging-token"; import { shellQuote } from "../../runner"; -import { executeSandboxCommand, executeSandboxExecCommand } from "./process-recovery"; -import { rebuildSandbox } from "./rebuild"; import { - type ChannelDef, - KNOWN_CHANNELS, channelUsesInSandboxQrPairing, clearChannelTokens, getChannelDef, @@ -39,7 +49,9 @@ import { knownChannelNames, persistChannelTokens, } from "../../sandbox/channels"; -import type { HostQrLoginResult } from "../../host-qr-handlers"; +import * as registry from "../../state/registry"; +import { executeSandboxCommand, executeSandboxExecCommand } from "./process-recovery"; +import { rebuildSandbox } from "./rebuild"; type ChannelMutationOptions = { channel?: string; @@ -282,18 +294,28 @@ function resolveAgentForSandbox(sandboxName: string): AgentDefinition { return loadAgent(agentName); } +function knownManifestChannelNames(): string[] { + return messagingManifestRegistry.list().map((manifest) => manifest.id); +} + +function resolveChannelManifest(name: string): ChannelManifest | undefined { + return messagingManifestRegistry.get(name.trim().toLowerCase()); +} + +function availableManifestChannelsForAgent(agent: AgentDefinition): ChannelManifest[] { + return messagingManifestRegistry.listAvailable(getMessagingManifestAvailabilityContext(agent)); +} + function channelSupportedByAgent(channelName: string, agent: AgentDefinition): boolean { - const supported = agent.messagingPlatforms; - return !Array.isArray(supported) || supported.length === 0 || supported.includes(channelName); + return availableManifestChannelsForAgent(agent).some((manifest) => manifest.id === channelName); } export function listSandboxChannels(sandboxName: string) { const agent = resolveAgentForSandbox(sandboxName); console.log(""); console.log(` Known messaging channels for sandbox '${sandboxName}':`); - for (const [name, channel] of Object.entries(KNOWN_CHANNELS)) { - if (!channelSupportedByAgent(name, agent)) continue; - console.log(` ${name} — ${channel.description}`); + for (const manifest of availableManifestChannelsForAgent(agent)) { + console.log(` ${manifest.id} — ${manifest.description ?? manifest.displayName}`); } console.log(""); } @@ -486,152 +508,184 @@ async function promptAndRebuild(sandboxName: string, actionDesc: string): Promis await rebuildSandbox(sandboxName, ["--yes"]); } -// Paste-prompt token acquisition for Telegram / Discord / Slack — extracted -// from the original inline loop so `addSandboxChannel` can fork cleanly on -// `loginMethod`. -async function acquirePasteTokens( - channelArg: string, - channel: ChannelDef, - acquired: Record, -): Promise { - const tokenKeys = getChannelTokenKeys(channel); - for (const envKey of tokenKeys) { - const isPrimary = envKey === channel.envKey; - const help = isPrimary ? channel.help : channel.appTokenHelp; - const label = isPrimary ? channel.label : channel.appTokenLabel; - const existing = getCredential(envKey); - if (existing) { - acquired[envKey] = existing; - continue; +async function planSandboxChannelAdd( + sandboxName: string, + channelId: string, + agent: AgentDefinition, +): Promise { + const planner = new MessagingWorkflowPlanner(messagingManifestRegistry); + const availableChannels = availableManifestChannelsForAgent(agent); + const supportedChannelIds = availableChannels.map((manifest) => manifest.id); + const supported = new Set(supportedChannelIds); + const entry = registry.getSandbox(sandboxName); + const configuredChannels = normalizeChannelList(entry?.messagingChannels, supported); + const disabledChannels = normalizeChannelList( + entry?.disabledChannels ?? registry.getDisabledChannels(sandboxName), + supported, + ); + const plannedChannels = [...new Set([...configuredChannels, channelId])]; + + hydrateAddChannelEnvFromSession(sandboxName, channelId); + + try { + return await planner.planAddChannel({ + sandboxName, + agent: toMessagingAgentId(agent), + isInteractive: !isNonInteractive(), + channelId, + configuredChannels, + disabledChannels, + supportedChannelIds, + credentialAvailability: buildCredentialAvailability(plannedChannels), + }); + } catch (error) { + console.error(` Failed to plan messaging channel '${channelId}'.`); + console.error(` ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + } +} + +function normalizeChannelList( + value: readonly string[] | null | undefined, + supported: ReadonlySet, +): string[] { + if (!Array.isArray(value)) return []; + return [...new Set(value.map((channel) => channel.trim().toLowerCase()))].filter((channel) => + supported.has(channel), + ); +} + +function buildCredentialAvailability(channelIds: readonly string[]): Record { + const availability: Record = {}; + for (const channelId of channelIds) { + const manifest = messagingManifestRegistry.get(channelId); + if (!manifest) continue; + for (const input of manifest.inputs) { + if (input.kind !== "secret" || !input.envKey) continue; + if (!getMessagingToken(input.envKey)) continue; + availability[input.id] = true; + availability[`${manifest.id}.${input.id}`] = true; + availability[input.envKey] = true; } - if (isNonInteractive()) { - console.error(` Missing ${envKey} for channel '${channelArg}'.`); + } + return availability; +} + +function collectManifestCredentials(manifest: ChannelManifest): Record { + const acquired: Record = {}; + for (const credential of manifest.credentials) { + const value = getMessagingToken(credential.providerEnvKey); + if (value) acquired[credential.providerEnvKey] = value; + } + return acquired; +} + +function assertAddChannelPlanActive( + sandboxName: string, + manifest: ChannelManifest, + plan: SandboxMessagingPlan, +): SandboxMessagingChannelPlan { + const channelPlan = plan.channels.find((channel) => channel.channelId === manifest.id); + if (channelPlan?.active) return channelPlan; + + const missing = channelPlan?.inputs.filter((input) => input.required && !inputAvailable(input)) ?? []; + if (missing.length > 0) { + console.error( + ` Missing required input(s) for channel '${manifest.id}': ${missing + .map(formatMissingInput) + .join(", ")}.`, + ); + if (manifest.auth.mode === "host-qr" && getMessagingToken(manifest.credentials[0]?.providerEnvKey)) { console.error( - ` Set ${envKey} in the environment or via '${CLI_NAME} credentials' before running in non-interactive mode.`, + ` Run '${CLI_NAME} ${sandboxName} channels remove ${manifest.id}' then '${CLI_NAME} ${sandboxName} channels add ${manifest.id}' to capture fresh account metadata.`, + ); + } else if (isNonInteractive()) { + console.error( + ` Set the required environment values or run '${CLI_NAME} ${sandboxName} channels add ${manifest.id}' interactively.`, ); - process.exit(1); - } - console.log(""); - console.log(` ${help}`); - const token = (await askPrompt(` ${label}: `, { secret: true })).trim(); - if (!token) { - console.error(` Aborted — no value entered for ${envKey}.`); - process.exit(1); } - acquired[envKey] = token; + } else { + console.error(` Channel '${manifest.id}' was skipped during manifest enrollment.`); } + process.exit(1); } -// Host-QR token acquisition for WeChat (the only channel with -// `loginMethod: "host-qr"` today). Drives the iLink QR handshake on the -// host, captures the bot token and the non-secret per-account metadata -// (accountId, baseUrl, userId), and stashes the metadata where the -// upcoming rebuild can find it: -// - `process.env` — for the in-process rebuild that fires next -// (`promptAndRebuild` → `rebuildSandbox` → -// `onboard --resume` reads WECHAT_ACCOUNT_ID -// etc. via the wechatConfig builder). -// - `session.wechatConfig` — for a deferred rebuild started from a fresh -// process. `rebuildSandbox`'s env-stash reads -// back from here. -async function acquireHostQrChannel( - sandboxName: string, - channelArg: string, - channel: ChannelDef, - acquired: Record, -): Promise { - const envKey = channel.envKey; - if (!envKey) { - console.error(` Channel '${channelArg}' does not declare a credential environment key.`); - process.exit(1); +function inputAvailable(input: SandboxMessagingChannelPlan["inputs"][number]): boolean { + if (input.kind === "secret") return input.credentialAvailable === true; + if (input.value === undefined) return false; + return typeof input.value === "string" ? input.value.trim().length > 0 : true; +} + +function formatMissingInput(input: SandboxMessagingChannelPlan["inputs"][number]): string { + return input.sourceEnv ? `${input.inputId} (${input.sourceEnv})` : input.inputId; +} + +function hydrateAddChannelEnvFromSession(sandboxName: string, channelId: string): void { + if (channelId !== "wechat") return; + const savedSession = safeLoadOnboardSession(); + const savedWechat = + savedSession?.sandboxName === sandboxName ? savedSession.wechatConfig ?? null : null; + if (!savedWechat) return; + if (savedWechat.accountId && !process.env.WECHAT_ACCOUNT_ID) { + process.env.WECHAT_ACCOUNT_ID = savedWechat.accountId; } - // Cached-token short-circuit. A sandbox originally onboarded with this - // channel already has the bot token in OpenShell + the per-account - // metadata in session.wechatConfig. Re-running QR would invalidate the - // upstream plugin's existing iLink session; prefer the cache and let - // the rebuild's env-stash re-bake from session. - const cached = getCredential(envKey); - if (cached) { - if (channelArg === "wechat") { - // The rebuild needs accountId/baseUrl/userId to reconstruct the - // upstream plugin's account state file via seed-wechat-accounts.py. - // Restore them from session here so a deferred rebuild (started in a - // fresh process where rebuild.ts hasn't stashed yet) still finds - // them — and bail loudly if the session was cleared. Only honor the - // session entry when it belongs to THIS sandbox, otherwise we'd bake - // another sandbox's WECHAT_* into this image. - const savedSession = onboardSession.loadSession(); - const savedWechat = - savedSession?.sandboxName === sandboxName ? savedSession.wechatConfig ?? null : null; - if (savedWechat?.accountId && !process.env.WECHAT_ACCOUNT_ID) { - process.env.WECHAT_ACCOUNT_ID = savedWechat.accountId; - if (savedWechat.baseUrl) process.env.WECHAT_BASE_URL = savedWechat.baseUrl; - if (savedWechat.userId) process.env.WECHAT_USER_ID = savedWechat.userId; - } - if (!process.env.WECHAT_ACCOUNT_ID) { - console.error(" Cached WeChat token found, but per-account metadata is missing."); - console.error( - ` Run '${CLI_NAME} ${sandboxName} channels remove ${channelArg}' then '${CLI_NAME} ${sandboxName} channels add ${channelArg}' to capture a fresh account via QR.`, - ); - process.exit(1); - } - } - acquired[envKey] = cached; - return; + if (savedWechat.baseUrl && !process.env.WECHAT_BASE_URL) { + process.env.WECHAT_BASE_URL = savedWechat.baseUrl; } - if (isNonInteractive()) { - console.error( - ` '${channelArg}' requires an interactive QR login; cannot run in non-interactive mode.`, - ); - console.error( - ` Run '${CLI_NAME} ${sandboxName} channels add ${channelArg}' interactively instead.`, - ); - process.exit(1); + if (savedWechat.userId && !process.env.WECHAT_USER_ID) { + process.env.WECHAT_USER_ID = savedWechat.userId; } - const handler = HOST_QR_LOGIN_HANDLERS[channelArg]; - if (!handler) { - console.error(` No host-qr handler registered for '${channelArg}'.`); - process.exit(1); +} + +function persistManifestAddState(sandboxName: string, manifest: ChannelManifest): void { + persistManifestMessagingConfig(sandboxName, manifest); + if (manifest.id === "wechat") persistWechatConfigFromEnv(sandboxName); +} + +function persistManifestMessagingConfig(sandboxName: string, manifest: ChannelManifest): void { + const config = readManifestMessagingConfigFromEnv(manifest); + if (!config) return; + + const entry = registry.getSandbox(sandboxName); + const mergedRegistryConfig = mergeMessagingChannelConfigs(entry?.messagingChannelConfig, config); + if (entry && mergedRegistryConfig) { + registry.updateSandbox(sandboxName, { messagingChannelConfig: mergedRegistryConfig }); } - console.log(""); - console.log(` ${channel.help}`); - let result: HostQrLoginResult; + + const session = safeLoadOnboardSession(); + if (session?.sandboxName !== sandboxName) return; + const mergedSessionConfig = mergeMessagingChannelConfigs(session.messagingChannelConfig, config); + if (!mergedSessionConfig) return; try { - result = await handler(); - } catch (err: unknown) { - result = { kind: "error", message: err instanceof Error ? err.message : String(err) }; - } - if (result.kind !== "ok") { - const reason = - result.kind === "timeout" - ? "QR login timed out" - : result.kind === "expired" - ? "QR expired too many times" - : result.kind === "aborted" - ? "login aborted" - : `login failed: ${result.message ?? "unknown error"}`; - console.error(` Aborted — ${reason}.`); - process.exit(1); - } - if (!result.token) { - console.error(" Aborted — host-qr handler returned no token."); - process.exit(1); - } - acquired[envKey] = result.token; - if (result.extraEnv) { - for (const [key, value] of Object.entries(result.extraEnv)) { - process.env[key] = value; - } + onboardSession.updateSession((current) => { + current.messagingChannelConfig = mergedSessionConfig; + return current; + }); + } catch { + // Best-effort: registry state still carries the config when available. } - if (channel.userIdEnvKey && result.defaultUserId && !process.env[channel.userIdEnvKey]) { - process.env[channel.userIdEnvKey] = result.defaultUserId; +} + +function readManifestMessagingConfigFromEnv(manifest: ChannelManifest): MessagingChannelConfig | null { + const result: MessagingChannelConfig = {}; + for (const input of manifest.inputs) { + if (input.kind !== "config" || !input.envKey) continue; + const normalized = normalizeMessagingChannelConfigValue(input.envKey, process.env[input.envKey]); + if (normalized) result[input.envKey] = normalized; } - if (channelArg === "wechat" && result.extraEnv) { - const captured = { - accountId: result.extraEnv.WECHAT_ACCOUNT_ID, - baseUrl: result.extraEnv.WECHAT_BASE_URL, - userId: result.extraEnv.WECHAT_USER_ID, - }; + return sanitizeMessagingChannelConfig(result); +} + +function persistWechatConfigFromEnv(sandboxName: string): void { + const captured = { + accountId: normalizeEnvValue(process.env.WECHAT_ACCOUNT_ID), + baseUrl: normalizeEnvValue(process.env.WECHAT_BASE_URL), + userId: normalizeEnvValue(process.env.WECHAT_USER_ID), + }; + if (!captured.accountId && !captured.baseUrl && !captured.userId) return; + const session = safeLoadOnboardSession(); + if (session?.sandboxName !== sandboxName) return; + try { onboardSession.updateSession((current) => { const prior = current.wechatConfig; current.wechatConfig = { @@ -641,9 +695,23 @@ async function acquireHostQrChannel( }; return current; }); + } catch { + // The channel remains usable for an immediate rebuild; deferred rebuilds + // can be recovered by re-running channels add for the same sandbox. } - const suffix = result.summary ? ` (${result.summary})` : ""; - console.log(` ${G}✓${R} ${channelArg} token saved${suffix}.`); +} + +function safeLoadOnboardSession(): ReturnType { + try { + return onboardSession.loadSession(); + } catch { + return null; + } +} + +function normalizeEnvValue(value: string | undefined): string | undefined { + const normalized = value?.replace(/\r/g, "").trim(); + return normalized || undefined; } export async function addSandboxChannel( @@ -654,17 +722,17 @@ export async function addSandboxChannel( const rawChannelArg = options.channel; if (!rawChannelArg) { console.error(` Usage: ${CLI_NAME} channels add [--dry-run]`); - console.error(` Valid channels: ${knownChannelNames().join(", ")}`); + console.error(` Valid channels: ${knownManifestChannelNames().join(", ")}`); process.exit(1); } - const channel = getChannelDef(rawChannelArg); - if (!channel) { + const manifest = resolveChannelManifest(rawChannelArg); + if (!manifest) { console.error(` Unknown channel '${rawChannelArg}'.`); - console.error(` Valid channels: ${knownChannelNames().join(", ")}`); + console.error(` Valid channels: ${knownManifestChannelNames().join(", ")}`); process.exit(1); } - const canonical = rawChannelArg.trim().toLowerCase(); + const canonical = manifest.id; const agent = resolveAgentForSandbox(sandboxName); if (!channelSupportedByAgent(canonical, agent)) { @@ -680,16 +748,21 @@ export async function addSandboxChannel( return; } + const plan = await planSandboxChannelAdd(sandboxName, canonical, agent); + assertAddChannelPlanActive(sandboxName, manifest, plan); + // QR-paired channels that own their session inside the sandbox have no // host-side credential to acquire; register the bridge now and let the // operator complete pairing after rebuild. - if (channelUsesInSandboxQrPairing(channel)) { + if (manifest.auth.mode === "in-sandbox-qr") { if (!applyChannelPresetIfAvailable(sandboxName, canonical)) { process.exit(1); } await applyChannelAddToGatewayAndRegistry(sandboxName, canonical, {}); + persistManifestAddState(sandboxName, manifest); console.log(""); - console.log(` ${channel.help}`); + const help = manifest.enrollmentHelp ?? manifest.inputs[0]?.prompt?.help; + if (help) console.log(` ${help}`); console.log( ` ${G}✓${R} Enabled ${canonical} channel. Complete QR pairing from inside the sandbox after rebuild.`, ); @@ -697,12 +770,7 @@ export async function addSandboxChannel( return; } - const acquired: Record = {}; - if (channel.loginMethod === "host-qr") { - await acquireHostQrChannel(sandboxName, canonical, channel, acquired); - } else { - await acquirePasteTokens(canonical, channel, acquired); - } + const acquired = collectManifestCredentials(manifest); persistChannelTokens(acquired); // Push to the gateway and update the registry NOW so that answering @@ -711,6 +779,7 @@ export async function addSandboxChannel( // wrote credentials.json; with env-only persistence, exiting before // the rebuild used to drop the queued token. await applyChannelAddToGatewayAndRegistry(sandboxName, canonical, acquired); + persistManifestAddState(sandboxName, manifest); console.log(` ${G}✓${R} Registered ${canonical} bridge with the OpenShell gateway.`); applyChannelPresetIfAvailable(sandboxName, canonical); diff --git a/test/channels-add-preset.test.ts b/test/channels-add-preset.test.ts index 51cd8228f4..a2e880f0f5 100644 --- a/test/channels-add-preset.test.ts +++ b/test/channels-add-preset.test.ts @@ -30,6 +30,7 @@ function runScript(scriptBody: string, extraEnv: Record = {}): S SLACK_BOT_TOKEN: "slack-bot-token-for-test", SLACK_APP_TOKEN: "slack-app-token-for-test", DISCORD_BOT_TOKEN: "test-discord-token", + NEMOCLAW_SKIP_TELEGRAM_REACHABILITY: "1", ...extraEnv, }, timeout: 15000, @@ -98,6 +99,22 @@ const onboardProviders = require(${j("onboard/providers.js")}); const providerCalls = []; onboardProviders.upsertMessagingProviders = (defs) => { providerCalls.push(...defs); }; +const workflowPlanner = require(${j("messaging/compiler/workflow-planner.js")}); +const originalPlanAddChannel = workflowPlanner.MessagingWorkflowPlanner.prototype.planAddChannel; +const planAddCalls = []; +workflowPlanner.MessagingWorkflowPlanner.prototype.planAddChannel = async function(context) { + planAddCalls.push({ + sandboxName: context.sandboxName, + agent: context.agent, + channelId: context.channelId, + isInteractive: context.isInteractive, + configuredChannels: context.configuredChannels, + disabledChannels: context.disabledChannels, + supportedChannelIds: context.supportedChannelIds, + }); + return originalPlanAddChannel.call(this, context); +}; + const registry = require(${j("state/registry.js")}); const registryUpdates = []; registry.getSandbox = () => ({ @@ -177,11 +194,45 @@ console.log = (...args) => { const channelModule = require(${j("actions/sandbox/policy-channel.js")}); -module.exports = { channelModule, appliedCalls, removedCalls, callOrder, providerCalls, registryUpdates, sessionUpdates, getSessionState: () => sessionState }; +module.exports = { channelModule, appliedCalls, removedCalls, callOrder, providerCalls, registryUpdates, sessionUpdates, planAddCalls, getSessionState: () => sessionState }; `; } describe("channels add applies matching policy preset (issue #3437)", () => { + it("plans channel enrollment through the messaging manifest workflow", () => { + const script = `${buildPreamble()} +const ctx = module.exports; +(async () => { + try { + await ctx.channelModule.addSandboxChannel("test-sb", { channel: "slack" }); + process.stdout.write("\\n__RESULT__" + JSON.stringify({ + planAddCalls: ctx.planAddCalls, + }) + "\\n"); + } catch (err) { + process.stdout.write("\\n__RESULT__" + JSON.stringify({ error: err.message, stack: err.stack }) + "\\n"); + } +})(); +`; + const result = runScript(script); + assert.equal(result.status, 0, `script failed: ${result.stderr}\n${result.stdout}`); + const marker = result.stdout.lastIndexOf("__RESULT__"); + assert.ok(marker >= 0, `no __RESULT__ marker in stdout:\n${result.stdout}`); + const payload = JSON.parse(result.stdout.slice(marker + "__RESULT__".length).trim()); + assert.ok(!payload.error, `unexpected error: ${payload.error}\n${payload.stack || ""}`); + + assert.deepEqual(payload.planAddCalls, [ + { + sandboxName: "test-sb", + agent: "openclaw", + channelId: "slack", + isInteractive: false, + configuredChannels: [], + disabledChannels: [], + supportedChannelIds: ["telegram", "discord", "wechat", "slack", "whatsapp"], + }, + ]); + }); + for (const channel of ["telegram", "slack", "discord"]) { it(`applies the '${channel}' preset before triggering rebuild`, () => { const script = `${buildPreamble()} From db55fe2b09594f584ca1a79b2fe2922e29e037b9 Mon Sep 17 00:00:00 2001 From: San Dang Date: Tue, 26 May 2026 22:36:26 +0700 Subject: [PATCH 25/40] refactor(onboard): remove legacy messaging enrollment --- .../messaging/compiler/manifest-compiler.ts | 3 + src/lib/onboard/host-qr-dispatch.ts | 79 ------------------- src/lib/onboard/messaging-channel-setup.ts | 33 -------- src/lib/onboard/messaging-state.test.ts | 50 ------------ src/lib/onboard/messaging-state.ts | 31 -------- 5 files changed, 3 insertions(+), 193 deletions(-) delete mode 100644 src/lib/onboard/host-qr-dispatch.ts diff --git a/src/lib/messaging/compiler/manifest-compiler.ts b/src/lib/messaging/compiler/manifest-compiler.ts index ec8ea07d5a..f9fd5a4d61 100644 --- a/src/lib/messaging/compiler/manifest-compiler.ts +++ b/src/lib/messaging/compiler/manifest-compiler.ts @@ -8,6 +8,7 @@ import type { } from "../hooks"; import { BUILT_IN_MESSAGING_HOOK_REGISTRY, + COMMON_TOKEN_PASTE_HOOK_HANDLER_ID, MessagingHookRegistry, runMessagingHook, } from "../hooks"; @@ -337,6 +338,8 @@ function shouldRunEnrollmentHook( hook: ChannelHookSpec, inputs: readonly SandboxMessagingInputReference[], ): boolean { + if (hook.handler === COMMON_TOKEN_PASTE_HOOK_HANDLER_ID) return true; + const outputs = hook.outputs ?? []; if (outputs.length === 0) return true; diff --git a/src/lib/onboard/host-qr-dispatch.ts b/src/lib/onboard/host-qr-dispatch.ts deleted file mode 100644 index 1d0326372a..0000000000 --- a/src/lib/onboard/host-qr-dispatch.ts +++ /dev/null @@ -1,79 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { saveCredential } from "../credentials/store"; -import { HOST_QR_LOGIN_HANDLERS } from "../host-qr-handlers"; -import type { ChannelDef } from "../sandbox/channels"; - -export interface HostQrDispatchOutcome { - ok: boolean; - summary?: string; - reason?: string; -} - -/** - * Run a channel's host-side QR login handler and apply its token + - * non-secret metadata side effects (credential save, process.env stash, - * DM-allowlist default). Extracted from `setupMessagingChannels` to keep - * `src/lib/onboard.ts` focused on flow rather than per-channel mechanism. - * - * Belt-and-suspenders: handlers may wrap their own body in try/catch, but - * a future handler might not — wrap the await in a real try/catch so any - * throw that escapes before the Promise is returned still becomes a - * structured "error" outcome and the channel is skipped instead of - * crashing onboarding. - */ -export async function dispatchHostQrLogin( - ch: ChannelDef & { name: string }, -): Promise { - const handler = HOST_QR_LOGIN_HANDLERS[ch.name]; - if (!handler) return { ok: false, reason: "no host-qr handler registered" }; - let result: Awaited>; - try { - result = await handler(); - } catch (err: unknown) { - result = { kind: "error", message: err instanceof Error ? err.message : String(err) }; - } - if (result.kind !== "ok") { - const reason = - result.kind === "timeout" - ? "QR login timed out" - : result.kind === "expired" - ? "QR expired too many times" - : result.kind === "aborted" - ? "login aborted" - : `login failed: ${result.message ?? "unknown error"}`; - return { ok: false, reason }; - } - if (result.token && ch.envKey) { - saveCredential(ch.envKey, result.token); - process.env[ch.envKey] = result.token; - } - // Non-secret per-account metadata: the in-sandbox wrapper plugin reads - // these via NEMOCLAW_*_CONFIG_B64 build args, so seed-wechat-accounts.py - // (and equivalents) can pre-seed credentials without re-running the QR - // handshake. See `patchStagedDockerfile`'s `wechatConfig` parameter. - if (result.extraEnv) { - for (const [key, value] of Object.entries(result.extraEnv)) { - process.env[key] = value; - } - } - // Merge the scanned operator's id into the DM allowlist. The channel's - // userIdHelp documents this as "added automatically; supply additional - // ids as a comma-separated list", so an operator-supplied list must not - // displace the scanner — otherwise the person who paired the bot can - // lock themselves out of DM access. Dedupe via Set; preserve the - // existing comma format (no space) the rest of the stack writes. - if (ch.userIdEnvKey && result.defaultUserId) { - const existing = process.env[ch.userIdEnvKey] ?? ""; - const merged = new Set( - existing - .split(",") - .map((v) => v.trim()) - .filter(Boolean), - ); - merged.add(result.defaultUserId); - process.env[ch.userIdEnvKey] = Array.from(merged).join(","); - } - return { ok: true, summary: result.summary }; -} diff --git a/src/lib/onboard/messaging-channel-setup.ts b/src/lib/onboard/messaging-channel-setup.ts index 96b05cd6f1..b3116af15a 100644 --- a/src/lib/onboard/messaging-channel-setup.ts +++ b/src/lib/onboard/messaging-channel-setup.ts @@ -9,7 +9,6 @@ import { import { type ChannelInputSpec, type ChannelManifest, - type ChannelSecretInputSpec, createBuiltInChannelManifestRegistry, getMessagingManifestAvailabilityContext, hasMessagingManifestRequiredInputs, @@ -160,15 +159,6 @@ export async function setupSelectedMessagingChannels( return plan; } - for (const channelId of selectedChannels) { - const manifest = registry.get(channelId); - if (!manifest) continue; - if (hasMessagingManifestRequiredInputs(manifest, getMessagingInputValue)) { - printExistingSecretStatus(manifest); - printEnrollmentNotes(manifest); - } - } - const plan = await planner.planOnboard({ sandboxName, agent, @@ -309,25 +299,6 @@ function buildCredentialAvailability( return availability; } -function printExistingSecretStatus(manifest: ChannelManifest): void { - const secretInputs = manifest.inputs.filter( - (entry): entry is ChannelSecretInputSpec => entry.kind === "secret", - ); - for (const input of secretInputs) { - if (input.id === "botToken") { - console.log(` ✓ ${manifest.id} — already configured`); - } else { - console.log(` ✓ ${manifest.id} ${tokenNoun(input.id)} — already configured`); - } - } -} - -function printEnrollmentNotes(manifest: ChannelManifest): void { - for (const line of manifest.enrollmentNotes ?? []) { - console.log(` ${line}`); - } -} - function printInSandboxQrStatus(manifest: ChannelManifest): void { logEnrollmentHelp(manifest); console.log( @@ -335,10 +306,6 @@ function printInSandboxQrStatus(manifest: ChannelManifest): void { ); } -function tokenNoun(inputId: string): string { - return inputId === "appToken" ? "app token" : "token"; -} - function resolveMessagingSetupSandboxName( options: SetupSelectedMessagingChannelsOptions, ): string { diff --git a/src/lib/onboard/messaging-state.test.ts b/src/lib/onboard/messaging-state.test.ts index 3c40f57961..2a3c69b2c6 100644 --- a/src/lib/onboard/messaging-state.test.ts +++ b/src/lib/onboard/messaging-state.test.ts @@ -6,8 +6,6 @@ import { describe, expect, it } from "vitest"; import type { AgentDefinition } from "../agent/defs"; import { filterEnabledChannelsByAgent, - getAvailableMessagingChannelsForAgent, - resolveMessagingChannelSeed, resolveQrSelectedChannels, } from "./messaging-state"; @@ -42,20 +40,6 @@ describe("filterEnabledChannelsByAgent", () => { }); }); -describe("getAvailableMessagingChannelsForAgent", () => { - const channels = [{ name: "telegram" }, { name: "whatsapp" }]; - - it("filters channels not in the agent's messagingPlatforms", () => { - expect(getAvailableMessagingChannelsForAgent(channels, agent(["telegram"]))).toEqual([ - { name: "telegram" }, - ]); - }); - - it("returns all channels when the agent has no platform list", () => { - expect(getAvailableMessagingChannelsForAgent(channels, agent([]))).toEqual(channels); - }); -}); - const messagingChannels = [ { name: "telegram", envKey: "TELEGRAM_BOT_TOKEN", description: "", help: "", label: "" }, { @@ -75,41 +59,7 @@ const messagingChannels = [ }, ]; -describe("resolveMessagingChannelSeed", () => { - it("always seeds channels with host-side tokens", () => { - expect( - resolveMessagingChannelSeed( - messagingChannels, - null, - (channel) => channel.name === "telegram", - ), - ).toEqual(["telegram"]); - }); - - it("carries forward only in-sandbox QR channels by default", () => { - expect( - resolveMessagingChannelSeed( - messagingChannels, - ["telegram", "wechat", "whatsapp"], - () => false, - ), - ).toEqual(["whatsapp"]); - }); - - it("can carry forward all existing channels for interactive preselection", () => { - expect( - resolveMessagingChannelSeed( - messagingChannels, - ["telegram", "wechat", "whatsapp"], - () => false, - { includeAllExisting: true }, - ), - ).toEqual(["telegram", "wechat", "whatsapp"]); - }); -}); - describe("resolveQrSelectedChannels", () => { - it("returns only in-sandbox QR-paired channels from the enabled list", () => { expect( resolveQrSelectedChannels(messagingChannels, ["telegram", "wechat", "whatsapp"], new Set()), diff --git a/src/lib/onboard/messaging-state.ts b/src/lib/onboard/messaging-state.ts index bd4d7bf1a8..ab3a19b9ee 100644 --- a/src/lib/onboard/messaging-state.ts +++ b/src/lib/onboard/messaging-state.ts @@ -6,17 +6,6 @@ import { channelUsesInSandboxQrPairing, type ChannelDef } from "../sandbox/chann export type MessagingChannel = { name: string } & ChannelDef; -export function getAvailableMessagingChannelsForAgent( - channels: T[], - agent: AgentDefinition | null = null, -): T[] { - const supportedPlatforms = agent?.messagingPlatforms; - if (supportedPlatforms && supportedPlatforms.length > 0) { - return channels.filter((c) => supportedPlatforms.includes(c.name)); - } - return channels; -} - export function resolveQrSelectedChannels( channels: MessagingChannel[], enabledChannels: string[] | null | undefined, @@ -30,26 +19,6 @@ export function resolveQrSelectedChannels( }); } -export function resolveMessagingChannelSeed( - channels: MessagingChannel[], - existingChannels: string[] | null | undefined, - hasChannelToken: (channel: MessagingChannel) => boolean, - { includeAllExisting = false }: { includeAllExisting?: boolean } = {}, -): string[] { - const seeded = new Set(channels.filter(hasChannelToken).map((channel) => channel.name)); - if (!Array.isArray(existingChannels)) return Array.from(seeded); - - const channelByName = new Map(channels.map((channel) => [channel.name, channel])); - for (const name of existingChannels) { - const channel = channelByName.get(name); - if (!channel) continue; - if (includeAllExisting || channelUsesInSandboxQrPairing(channel)) { - seeded.add(name); - } - } - return Array.from(seeded); -} - export function filterEnabledChannelsByAgent( enabledChannels: T, agent: AgentDefinition | null, From 5daef1c1fb708364570b48ab602ad881e61de685 Mon Sep 17 00:00:00 2001 From: San Dang Date: Wed, 27 May 2026 15:08:19 +0700 Subject: [PATCH 26/40] 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 81b5f1005242f621a35d4581433983302ea23941 Mon Sep 17 00:00:00 2001 From: San Dang Date: Wed, 27 May 2026 17:26:42 +0700 Subject: [PATCH 27/40] fix(slack): prompt for allowed channels --- src/lib/messaging/channels/manifests.test.ts | 26 +++++++++- src/lib/messaging/channels/slack/manifest.ts | 21 ++++++++ .../compiler/workflow-planner.test.ts | 2 + .../hooks/common/config-prompt.test.ts | 48 ++++++++++++++++++- .../messaging/hooks/common/config-prompt.ts | 1 + 5 files changed, 96 insertions(+), 2 deletions(-) diff --git a/src/lib/messaging/channels/manifests.test.ts b/src/lib/messaging/channels/manifests.test.ts index b39d1a949f..cf5b41f28f 100644 --- a/src/lib/messaging/channels/manifests.test.ts +++ b/src/lib/messaging/channels/manifests.test.ts @@ -286,6 +286,7 @@ describe("built-in channel manifests", () => { const botToken = findInput(slackManifest, "botToken"); const appToken = findInput(slackManifest, "appToken"); const allowedUsers = findInput(slackManifest, "allowedUsers"); + const allowedChannels = findInput(slackManifest, "allowedChannels"); const hermesLines = buildMessagingEnvLines( new Set(["slack"]), { slack: ["U0123456789"] }, @@ -301,6 +302,13 @@ describe("built-in channel manifests", () => { expect(botToken.envKey).toBe("SLACK_BOT_TOKEN"); expect(appToken.envKey).toBe("SLACK_APP_TOKEN"); expect(allowedUsers.envKey).toBe("SLACK_ALLOWED_USERS"); + expect(allowedChannels.envKey).toBe("SLACK_ALLOWED_CHANNELS"); + expect(allowedChannels.statePath).toBe("slackConfig.allowedChannels"); + expect(allowedChannels.prompt).toEqual({ + label: "Slack Channel IDs (comma-separated allowlist)", + help: "Optional: enter comma-separated Slack channel IDs where the bot may answer @mentions. Channel IDs look like C012AB3CD.", + emptyValueMessage: "channel @mentions stay unrestricted by channel ID", + }); expect(KNOWN_CHANNELS.slack.allowIdsMode).toBe("dm"); expect(slackManifest.credentials).toEqual([ { @@ -328,7 +336,23 @@ describe("built-in channel manifests", () => { expect(renderJson(slackManifest)).toContain("channels.slack.accounts.default"); expect(renderJson(slackManifest)).toContain("allowedIds.slack.channels"); expectTokenPasteEnrollHook(slackManifest, ["botToken", "appToken"]); - expectConfigPromptEnrollHook(slackManifest, ["allowedUsers"]); + expectConfigPromptEnrollHook(slackManifest, ["allowedUsers", "allowedChannels"]); + expect(slackManifest.state).toEqual({ + persist: { + allowedIds: ["allowedUsers"], + slackConfig: ["allowedChannels"], + }, + rebuildHydration: [ + { + statePath: "allowedIds.slack", + env: "SLACK_ALLOWED_USERS", + }, + { + statePath: "slackConfig.allowedChannels", + env: "SLACK_ALLOWED_CHANNELS", + }, + ], + }); }); it("declares WeChat host-QR hooks, state hydration, provider binding, and Hermes env intent", () => { diff --git a/src/lib/messaging/channels/slack/manifest.ts b/src/lib/messaging/channels/slack/manifest.ts index 08631d2402..ece4852f3e 100644 --- a/src/lib/messaging/channels/slack/manifest.ts +++ b/src/lib/messaging/channels/slack/manifest.ts @@ -51,6 +51,18 @@ export const slackManifest = { emptyValueMessage: "bot will require manual pairing", }, }, + { + id: "allowedChannels", + kind: "config", + required: false, + envKey: "SLACK_ALLOWED_CHANNELS", + statePath: "slackConfig.allowedChannels", + prompt: { + label: "Slack Channel IDs (comma-separated allowlist)", + help: "Optional: enter comma-separated Slack channel IDs where the bot may answer @mentions. Channel IDs look like C012AB3CD.", + emptyValueMessage: "channel @mentions stay unrestricted by channel ID", + }, + }, ], credentials: [ { @@ -106,12 +118,17 @@ export const slackManifest = { state: { persist: { allowedIds: ["allowedUsers"], + slackConfig: ["allowedChannels"], }, rebuildHydration: [ { statePath: "allowedIds.slack", env: "SLACK_ALLOWED_USERS", }, + { + statePath: "slackConfig.allowedChannels", + env: "SLACK_ALLOWED_CHANNELS", + }, ], }, hooks: [ @@ -142,6 +159,10 @@ export const slackManifest = { id: "allowedUsers", kind: "config", }, + { + id: "allowedChannels", + kind: "config", + }, ], }, ], diff --git a/src/lib/messaging/compiler/workflow-planner.test.ts b/src/lib/messaging/compiler/workflow-planner.test.ts index 551969d83c..cce5f37344 100644 --- a/src/lib/messaging/compiler/workflow-planner.test.ts +++ b/src/lib/messaging/compiler/workflow-planner.test.ts @@ -124,6 +124,7 @@ describe("MessagingWorkflowPlanner", () => { SLACK_BOT_TOKEN: "xoxb-test-slack-token", SLACK_APP_TOKEN: "xapp-test-slack-token", SLACK_ALLOWED_USERS: "U0123456789", + SLACK_ALLOWED_CHANNELS: "C012AB3CD", }, () => planner().planAddChannel({ @@ -159,6 +160,7 @@ describe("MessagingWorkflowPlanner", () => { SLACK_BOT_TOKEN: "xoxb-test-slack-token", SLACK_APP_TOKEN: "xapp-test-slack-token", SLACK_ALLOWED_USERS: "U0123456789", + SLACK_ALLOWED_CHANNELS: "C012AB3CD", }, () => planner().planAddChannel({ diff --git a/src/lib/messaging/hooks/common/config-prompt.test.ts b/src/lib/messaging/hooks/common/config-prompt.test.ts index bb1b6e2c2e..d2ee4451b3 100644 --- a/src/lib/messaging/hooks/common/config-prompt.test.ts +++ b/src/lib/messaging/hooks/common/config-prompt.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; -import { discordManifest, telegramManifest } from "../../channels"; +import { discordManifest, slackManifest, telegramManifest } from "../../channels"; import { runMessagingHook } from "../hook-runner"; import { MessagingHookRegistry } from "../registry"; import { @@ -92,6 +92,52 @@ describe("common config-prompt hook implementation", () => { ]); }); + it("prompts Slack user and channel allowlists from the manifest", async () => { + const env: NodeJS.ProcessEnv = {}; + const questions: string[] = []; + const registry = new MessagingHookRegistry([ + { + id: COMMON_CONFIG_PROMPT_HOOK_HANDLER_ID, + handler: createConfigPromptHook({ + env, + log: () => {}, + prompt: async (question) => { + questions.push(question); + return question.includes("Channel IDs") + ? "C012AB3CD,C987ZY6XW" + : "U01ABC2DEF3"; + }, + }), + }, + ]); + const hook = slackManifest.hooks.find((entry) => entry.id === "slack-config-prompt"); + + if (!hook) throw new Error("missing Slack config-prompt hook"); + + await expect( + runMessagingHook(hook, registry, { + channelId: "slack", + }), + ).resolves.toMatchObject({ + outputs: { + allowedUsers: { + kind: "config", + value: "U01ABC2DEF3", + }, + allowedChannels: { + kind: "config", + value: "C012AB3CD,C987ZY6XW", + }, + }, + }); + expect(questions).toEqual([ + " Slack Member IDs (comma-separated allowlist): ", + " Slack Channel IDs (comma-separated allowlist): ", + ]); + expect(env.SLACK_ALLOWED_USERS).toBe("U01ABC2DEF3"); + expect(env.SLACK_ALLOWED_CHANNELS).toBe("C012AB3CD,C987ZY6XW"); + }); + it("logs existing config values without reprompting", async () => { const logs: string[] = []; const registry = new MessagingHookRegistry([ diff --git a/src/lib/messaging/hooks/common/config-prompt.ts b/src/lib/messaging/hooks/common/config-prompt.ts index 87e0287618..0a5079f9f0 100644 --- a/src/lib/messaging/hooks/common/config-prompt.ts +++ b/src/lib/messaging/hooks/common/config-prompt.ts @@ -223,6 +223,7 @@ function logSkippedConfigInput( } function configInputNoun(field: ConfigPromptField): string { + if (/channel/i.test(field.id)) return "channel IDs"; if (/server/i.test(field.id)) return "server ID"; if (/allowed|user/i.test(field.id)) return "allowed IDs"; return field.label; From 0002ec567e966dc57c125bd48f2efb51d1b3d83a Mon Sep 17 00:00:00 2001 From: San Dang Date: Wed, 27 May 2026 23:07:24 +0700 Subject: [PATCH 28/40] fix(messaging): validate non-interactive enrollment hooks --- .../channels/wechat/hooks/ilink-login.ts | 7 ++ .../compiler/manifest-compiler.test.ts | 24 ++++- .../messaging/compiler/manifest-compiler.ts | 24 +++-- .../hooks/common/config-prompt.test.ts | 38 ++++++++ .../messaging/hooks/common/config-prompt.ts | 4 + .../hooks/common/token-paste.test.ts | 95 +++++++++++++++++++ src/lib/messaging/hooks/common/token-paste.ts | 26 ++++- src/lib/messaging/hooks/hook-runner.ts | 3 + src/lib/messaging/hooks/types.ts | 1 + .../onboard/messaging-channel-setup.test.ts | 64 +++++++++++++ test/channels-add-preset.test.ts | 4 +- 11 files changed, 276 insertions(+), 14 deletions(-) diff --git a/src/lib/messaging/channels/wechat/hooks/ilink-login.ts b/src/lib/messaging/channels/wechat/hooks/ilink-login.ts index 1f134fe2a2..73e15ed3a4 100644 --- a/src/lib/messaging/channels/wechat/hooks/ilink-login.ts +++ b/src/lib/messaging/channels/wechat/hooks/ilink-login.ts @@ -39,6 +39,13 @@ export function createWechatIlinkLoginHook( options: WechatIlinkLoginHookOptions = {}, ): MessagingHookHandler { return async (context) => { + if (context.isInteractive === false) { + (options.log ?? console.log)( + ` Skipped ${context.channelId} (host QR login requires interactive mode)`, + ); + throw new Error("WeChat host QR login requires interactive mode."); + } + const runLogin = options.runLogin; if (!runLogin) { throw new Error( diff --git a/src/lib/messaging/compiler/manifest-compiler.test.ts b/src/lib/messaging/compiler/manifest-compiler.test.ts index ebcb14ab3b..4f79165604 100644 --- a/src/lib/messaging/compiler/manifest-compiler.test.ts +++ b/src/lib/messaging/compiler/manifest-compiler.test.ts @@ -386,15 +386,28 @@ describe("ManifestCompiler", () => { expect(plan.healthChecks).toEqual([]); }); - it("skips enrollment hooks but runs manifest reachability checks for non-interactive create plans", async () => { + it("runs non-interactive enrollment hooks to validate and feed reachability checks", async () => { const hookCalls: string[] = []; const hooks = new MessagingHookRegistry([ { id: "common.tokenPaste", - handler: () => { - throw new Error("token-paste hook should not run"); + handler: (context) => { + hookCalls.push(`token-paste-input:${String(context.inputs?.botToken)}`); + const token = process.env.TELEGRAM_BOT_TOKEN ?? "missing"; + return { + outputs: { + botToken: { + kind: "secret", + value: token, + }, + }, + }; }, }, + { + id: "common.configPrompt", + handler: () => ({}), + }, { id: "telegram.getMeReachability", handler: (context) => { @@ -424,7 +437,10 @@ describe("ManifestCompiler", () => { kind: "secret", credentialAvailable: true, }); - expect(hookCalls).toEqual(["reachability:123456:raw-telegram-token"]); + expect(hookCalls).toEqual([ + "token-paste-input:undefined", + "reachability:123456:raw-telegram-token", + ]); expect(JSON.stringify(plan)).not.toContain("123456:raw-telegram-token"); }); diff --git a/src/lib/messaging/compiler/manifest-compiler.ts b/src/lib/messaging/compiler/manifest-compiler.ts index f9fd5a4d61..b41b5178d5 100644 --- a/src/lib/messaging/compiler/manifest-compiler.ts +++ b/src/lib/messaging/compiler/manifest-compiler.ts @@ -116,14 +116,12 @@ export class ManifestCompiler { const requestedActive = !disabled && (selected || configured); const resolvedInputs = await resolveChannelInputs(manifest, context, this.hooks, { runEnrollment: - selected && - requestedActive && - isEnrollmentWorkflow(context.workflow) && - context.isInteractive, + selected && requestedActive && isEnrollmentWorkflow(context.workflow), runEnrollmentChecks: selected && requestedActive && isEnrollmentWorkflow(context.workflow), + isInteractive: context.isInteractive, }); const requiredInputsAvailable = hasRequiredInputsAvailable(manifest, resolvedInputs.inputs); const active = requestedActive && !resolvedInputs.skipped && requiredInputsAvailable; @@ -183,7 +181,11 @@ async function resolveChannelInputs( manifest: ChannelManifest, context: ManifestCompilerContext, hooks: MessagingHookRegistry, - options: { readonly runEnrollment: boolean; readonly runEnrollmentChecks: boolean }, + options: { + readonly runEnrollment: boolean; + readonly runEnrollmentChecks: boolean; + readonly isInteractive: boolean; + }, ): Promise<{ readonly inputs: SandboxMessagingInputReference[]; readonly skipped: boolean; @@ -200,7 +202,13 @@ async function resolveChannelInputs( let skipped = false; for (const hook of enrollmentHooks) { if (!shouldRunEnrollmentHook(hook, inputs)) continue; - const result = await runCompilerHook(manifest, hook, hooks, hookInputs); + const result = await runCompilerHook( + manifest, + hook, + hooks, + hookInputs, + options.isInteractive, + ); if (!result) { skipped = true; break; @@ -218,7 +226,7 @@ async function resolveChannelInputs( .filter((entry) => isHookForAgent(entry, context.agent)) .filter((entry) => entry.phase === "reachability-check") .filter((entry) => hasDeclaredHookInputs(hookInputs, entry))) { - await runCompilerHook(manifest, hook, hooks, hookInputs); + await runCompilerHook(manifest, hook, hooks, hookInputs, options.isInteractive); } } @@ -230,10 +238,12 @@ async function runCompilerHook( hook: ChannelHookSpec, hooks: MessagingHookRegistry, inputs: MessagingHookInputMap, + isInteractive: boolean, ): Promise { try { return await runMessagingHook(hook, hooks, { channelId: manifest.id, + isInteractive, inputs: selectDeclaredHookInputs(hook, inputs), }); } catch (error) { diff --git a/src/lib/messaging/hooks/common/config-prompt.test.ts b/src/lib/messaging/hooks/common/config-prompt.test.ts index d2ee4451b3..c8910a2040 100644 --- a/src/lib/messaging/hooks/common/config-prompt.test.ts +++ b/src/lib/messaging/hooks/common/config-prompt.test.ts @@ -168,4 +168,42 @@ describe("common config-prompt hook implementation", () => { expect(logs.join("\n")).toContain("reply mode already set: @mentions only"); expect(logs.join("\n")).toContain("allowed IDs already set: 123456789"); }); + + it("records existing config but does not prompt for missing config in non-interactive mode", async () => { + const env: NodeJS.ProcessEnv = { + SLACK_ALLOWED_USERS: "U01ABC2DEF3", + }; + const logs: string[] = []; + const registry = new MessagingHookRegistry([ + { + id: COMMON_CONFIG_PROMPT_HOOK_HANDLER_ID, + handler: createConfigPromptHook({ + env, + log: (message) => logs.push(message), + prompt: async () => { + throw new Error("non-interactive config hook should not prompt"); + }, + }), + }, + ]); + const hook = slackManifest.hooks.find((entry) => entry.id === "slack-config-prompt"); + + if (!hook) throw new Error("missing Slack config-prompt hook"); + + await expect( + runMessagingHook(hook, registry, { + channelId: "slack", + isInteractive: false, + }), + ).resolves.toMatchObject({ + outputs: { + allowedUsers: { + kind: "config", + value: "U01ABC2DEF3", + }, + }, + }); + expect(env.SLACK_ALLOWED_CHANNELS).toBeUndefined(); + expect(logs.join("\n")).toContain("allowed IDs already set: U01ABC2DEF3"); + }); }); diff --git a/src/lib/messaging/hooks/common/config-prompt.ts b/src/lib/messaging/hooks/common/config-prompt.ts index 0a5079f9f0..0cc9608b19 100644 --- a/src/lib/messaging/hooks/common/config-prompt.ts +++ b/src/lib/messaging/hooks/common/config-prompt.ts @@ -66,6 +66,10 @@ export function createConfigPromptHook( continue; } + if (context.isInteractive === false) { + continue; + } + if (field.help) log(options, ` ${field.help}`); const value = await promptConfigInputValue(field, options); if (value) { diff --git a/src/lib/messaging/hooks/common/token-paste.test.ts b/src/lib/messaging/hooks/common/token-paste.test.ts index 56cda3a00f..511e5a810c 100644 --- a/src/lib/messaging/hooks/common/token-paste.test.ts +++ b/src/lib/messaging/hooks/common/token-paste.test.ts @@ -168,6 +168,101 @@ describe("common token-paste hook implementation", () => { expect(env.SLACK_APP_TOKEN).toBe("xapp-prompted"); }); + it("reprompts in interactive mode when an existing token has invalid format", async () => { + const env: NodeJS.ProcessEnv = { + SLACK_BOT_TOKEN: "not-a-slack-token", + SLACK_APP_TOKEN: "xapp-existing", + }; + const logs: string[] = []; + 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: (message) => logs.push(message), + prompt: async (question, options) => { + prompts.push({ question, secret: options?.secret === true }); + return "xoxb-recovered-token"; + }, + }), + }, + ]); + const hook = slackManifest.hooks[0]; + + if (!hook) throw new Error("missing Slack token-paste hook"); + + await expect( + runMessagingHook(hook, registry, { + channelId: "slack", + isInteractive: true, + }), + ).resolves.toMatchObject({ + outputs: { + botToken: { + kind: "secret", + value: "xoxb-recovered-token", + }, + appToken: { + kind: "secret", + value: "xapp-existing", + }, + }, + }); + expect(prompts).toEqual([ + { + question: " Slack Bot Token: ", + secret: true, + }, + ]); + expect(saved).toEqual([ + { key: "SLACK_BOT_TOKEN", value: "xoxb-recovered-token" }, + { key: "SLACK_APP_TOKEN", value: "xapp-existing" }, + ]); + expect(env.SLACK_BOT_TOKEN).toBe("xoxb-recovered-token"); + expect(logs.join("\n")).toContain("Slack bot tokens start with 'xoxb-'"); + expect(logs.join("\n")).toContain("Invalid existing slack token ignored"); + expect(logs.join("\n")).not.toContain("Skipped slack (invalid token format)"); + }); + + it("skips in non-interactive mode when an existing token has invalid format", async () => { + const logs: string[] = []; + const saved: Array<{ readonly key: string; readonly value: string }> = []; + const registry = new MessagingHookRegistry([ + { + id: COMMON_TOKEN_PASTE_HOOK_HANDLER_ID, + handler: createTokenPasteHook({ + env: { + SLACK_BOT_TOKEN: "not-a-slack-token", + SLACK_APP_TOKEN: "xapp-existing", + }, + getCredential: () => null, + saveCredential: (key, value) => saved.push({ key, value }), + log: (message) => logs.push(message), + prompt: async () => { + throw new Error("non-interactive enrollment should not prompt"); + }, + }), + }, + ]); + const hook = slackManifest.hooks[0]; + + if (!hook) throw new Error("missing Slack token-paste hook"); + + await expect( + runMessagingHook(hook, registry, { + channelId: "slack", + isInteractive: false, + }), + ).rejects.toThrow("Invalid token format for SLACK_BOT_TOKEN"); + expect(saved).toEqual([]); + expect(logs.join("\n")).toContain("Slack bot tokens start with 'xoxb-'"); + expect(logs.join("\n")).toContain("Skipped slack (invalid token format)"); + }); + it("rejects invalid pasted token formats before staging credentials", async () => { const saved: Array<{ readonly key: string; readonly value: string }> = []; 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 d55bd700b0..d98f2afab7 100644 --- a/src/lib/messaging/hooks/common/token-paste.ts +++ b/src/lib/messaging/hooks/common/token-paste.ts @@ -48,7 +48,13 @@ export function createTokenPasteHook(options: TokenPasteHookOptions = {}): Messa `No token-paste field registered for ${context.channelId}.${output.id}`, ); } - const resolved = await resolveTokenValue(context.channelId, output, field, options); + const resolved = await resolveTokenValue( + context.channelId, + output, + field, + options, + context.isInteractive !== false, + ); outputs[output.id] = { kind: "secret", value: resolved.token, @@ -77,6 +83,7 @@ async function resolveTokenValue( output: ChannelHookOutputSpec, field: TokenPasteField, options: TokenPasteHookOptions, + isInteractive: boolean, ): Promise<{ readonly token: string; readonly source: "existing" | "prompted" }> { const env = options.env ?? process.env; const readCredential = options.getCredential ?? (() => null); @@ -86,7 +93,24 @@ async function resolveTokenValue( let token = normalizeCredentialValue(env[field.envKey]) || readCredential(field.envKey); let source: "existing" | "prompted" = "existing"; + if (token && field.format && !field.format.test(token)) { + log(` ✗ Invalid format. ${field.formatHint || "Check the token and try again."}`); + if (!isInteractive) { + log(formatSkippedInvalidTokenMessage(channelId, output)); + throw new Error( + `Invalid token format for ${field.envKey}. ${ + field.formatHint || "Check the token and try again." + }`, + ); + } + log(` ✗ Invalid existing ${channelId} ${tokenNoun(output)} ignored.`); + token = ""; + } if (!token) { + if (!isInteractive) { + log(formatSkippedNoTokenMessage(channelId, output)); + throw new Error(`No token entered for ${field.envKey}.`); + } if (field.help) { log(""); log(` ${field.help}`); diff --git a/src/lib/messaging/hooks/hook-runner.ts b/src/lib/messaging/hooks/hook-runner.ts index dba3052290..e9ca30f33a 100644 --- a/src/lib/messaging/hooks/hook-runner.ts +++ b/src/lib/messaging/hooks/hook-runner.ts @@ -26,6 +26,9 @@ export async function runMessagingHook( channelId: context.channelId, hookId: hook.id, phase: hook.phase, + ...(typeof context.isInteractive === "boolean" + ? { isInteractive: context.isInteractive } + : {}), inputs: context.inputs, outputDeclarations: hook.outputs, }); diff --git a/src/lib/messaging/hooks/types.ts b/src/lib/messaging/hooks/types.ts index a739f1fb14..94b254d563 100644 --- a/src/lib/messaging/hooks/types.ts +++ b/src/lib/messaging/hooks/types.ts @@ -17,6 +17,7 @@ export type MessagingHookInputMap = Readonly { expect(logs.join("\n")).not.toContain("enrollment failed"); }); + it("reprompts for an invalid existing Slack token during interactive setup", async () => { + process.env.SLACK_BOT_TOKEN = "not-a-slack-token"; + process.env.SLACK_APP_TOKEN = "xapp-existing-token"; + const logs: string[] = []; + const questions: string[] = []; + vi.mocked(prompt).mockImplementation(async (question: string) => { + questions.push(question); + if (question.includes("Slack Bot Token")) return "xoxb-recovered-token"; + return ""; + }); + vi.spyOn(console, "log").mockImplementation((message = "") => { + logs.push(String(message)); + }); + const enabled = new Set(["slack"]); + + const plan = await setupSelectedMessagingChannels( + ["slack"], + enabled, + manifests("slack"), + ); + + expect(enabled.has("slack")).toBe(true); + expect(plan?.channels[0]).toMatchObject({ channelId: "slack", active: true }); + expect(questions).toEqual([ + " Slack Bot Token: ", + " Slack Member IDs (comma-separated allowlist): ", + " Slack Channel IDs (comma-separated allowlist): ", + ]); + expect(saveCredential).toHaveBeenCalledWith( + "SLACK_BOT_TOKEN", + "xoxb-recovered-token", + ); + expect(saveCredential).toHaveBeenCalledWith( + "SLACK_APP_TOKEN", + "xapp-existing-token", + ); + expect(process.env.SLACK_BOT_TOKEN).toBe("xoxb-recovered-token"); + expect(logs.join("\n")).toContain("Invalid existing slack token ignored"); + expect(logs.join("\n")).not.toContain("Skipped slack (invalid token format)"); + }); + it("prompts each channel's config before enrolling the next selected channel", async () => { const questions: string[] = []; vi.mocked(prompt).mockImplementation(async (question: string) => { @@ -365,4 +406,27 @@ describe("setupMessagingChannels", () => { ]); expect(prompt).not.toHaveBeenCalled(); }); + + it("validates detected non-interactive Slack inputs before returning enabled channels", async () => { + process.env.SLACK_BOT_TOKEN = "not-a-slack-token"; + process.env.SLACK_APP_TOKEN = "xapp-existing-token"; + const logs: string[] = []; + const notes: string[] = []; + vi.spyOn(console, "log").mockImplementation((message = "") => { + logs.push(String(message)); + }); + + const result = await setupMessagingChannels(null, null, { + note: (message) => notes.push(message), + isNonInteractive: () => true, + }); + + expect(result).toEqual([]); + expect(notes).toEqual([ + " [non-interactive] Messaging channel inputs detected: slack", + ]); + expect(logs.join("\n")).toContain("Slack bot tokens start with 'xoxb-'"); + expect(logs.join("\n")).toContain("Skipped slack (invalid token format)"); + expect(prompt).not.toHaveBeenCalled(); + }); }); diff --git a/test/channels-add-preset.test.ts b/test/channels-add-preset.test.ts index a2e880f0f5..ecd417e097 100644 --- a/test/channels-add-preset.test.ts +++ b/test/channels-add-preset.test.ts @@ -27,8 +27,8 @@ function runScript(scriptBody: string, extraEnv: Record = {}): S HOME: tmpDir, NEMOCLAW_NON_INTERACTIVE: "1", TELEGRAM_BOT_TOKEN: "test-telegram-token", - SLACK_BOT_TOKEN: "slack-bot-token-for-test", - SLACK_APP_TOKEN: "slack-app-token-for-test", + SLACK_BOT_TOKEN: "xoxb-slack-bot-token-for-test", + SLACK_APP_TOKEN: "xapp-slack-app-token-for-test", DISCORD_BOT_TOKEN: "test-discord-token", NEMOCLAW_SKIP_TELEGRAM_REACHABILITY: "1", ...extraEnv, From 303faa46fe16b1e2724aaefba28b5aee64defd27 Mon Sep 17 00:00:00 2001 From: San Dang Date: Thu, 28 May 2026 18:49:27 +0700 Subject: [PATCH 29/40] fix(messaging): bound telegram reachability probe --- .../messaging/applier/setup-applier.test.ts | 8 ++- .../hooks/get-me-reachability.test.ts | 33 ++++++++++- .../telegram/hooks/get-me-reachability.ts | 55 +++++++++++++++++-- .../compiler/workflow-planner.test.ts | 8 ++- 4 files changed, 92 insertions(+), 12 deletions(-) diff --git a/src/lib/messaging/applier/setup-applier.test.ts b/src/lib/messaging/applier/setup-applier.test.ts index 7bd21947fa..47ef9b0d86 100644 --- a/src/lib/messaging/applier/setup-applier.test.ts +++ b/src/lib/messaging/applier/setup-applier.test.ts @@ -23,11 +23,15 @@ async function withEnv( values: Readonly>, run: () => Promise, ): Promise { + const scopedValues = { + NEMOCLAW_SKIP_TELEGRAM_REACHABILITY: "1", + ...values, + }; const previous = Object.fromEntries( - Object.keys(values).map((key) => [key, process.env[key]]), + Object.keys(scopedValues).map((key) => [key, process.env[key]]), ); try { - for (const [key, value] of Object.entries(values)) { + for (const [key, value] of Object.entries(scopedValues)) { if (value === undefined) { delete process.env[key]; } else { 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 04ad2e1532..845b606899 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 @@ -100,9 +100,6 @@ describe("Telegram getMe reachability hook implementation", () => { { id: TELEGRAM_GET_ME_REACHABILITY_HOOK_ID, handler: createTelegramGetMeReachabilityHook({ - env: { - NEMOCLAW_NON_INTERACTIVE: "1", - }, fetch: async () => { throw new Error("network unavailable"); }, @@ -112,6 +109,7 @@ describe("Telegram getMe reachability hook implementation", () => { await expect( runMessagingHook(TELEGRAM_REACHABILITY_HOOK, registry, { channelId: "telegram", + isInteractive: false, inputs: { botToken: "123456:telegram-token", }, @@ -119,6 +117,35 @@ describe("Telegram getMe reachability hook implementation", () => { ).rejects.toThrow("Telegram reachability check failed: Bot API request failed."); }); + it("bounds hung Bot API requests with a timeout", async () => { + const logs: string[] = []; + const registry = new MessagingHookRegistry([ + { + id: TELEGRAM_GET_ME_REACHABILITY_HOOK_ID, + handler: createTelegramGetMeReachabilityHook({ + log: (message) => logs.push(message), + timeoutMs: 1, + fetch: async () => new Promise(() => {}), + }), + }, + ]); + + await expect( + runMessagingHook(TELEGRAM_REACHABILITY_HOOK, registry, { + channelId: "telegram", + inputs: { + botToken: "123456:telegram-token", + }, + }), + ).resolves.toMatchObject({ + hookId: "telegram-reachability", + outputs: {}, + }); + expect(logs).toEqual([ + " ⚠ Telegram reachability check failed: Bot API request failed.", + ]); + }); + it("honors the explicit skip env without calling Telegram", async () => { const urls: string[] = []; const registry = new MessagingHookRegistry([ diff --git a/src/lib/messaging/channels/telegram/hooks/get-me-reachability.ts b/src/lib/messaging/channels/telegram/hooks/get-me-reachability.ts index 53db59813a..17b8cc36e2 100644 --- a/src/lib/messaging/channels/telegram/hooks/get-me-reachability.ts +++ b/src/lib/messaging/channels/telegram/hooks/get-me-reachability.ts @@ -5,6 +5,7 @@ import { normalizeCredentialValue } from "../../../../credentials/store"; import type { MessagingHookHandler, MessagingHookRegistration } from "../../../hooks/types"; export const TELEGRAM_GET_ME_REACHABILITY_HOOK_ID = "telegram.getMeReachability"; +const DEFAULT_TELEGRAM_REACHABILITY_TIMEOUT_MS = 10_000; interface TelegramFetchResponse { readonly ok: boolean; @@ -14,12 +15,20 @@ interface TelegramFetchResponse { text(): Promise; } -type TelegramFetch = (url: string) => Promise; +interface TelegramFetchOptions { + readonly signal?: AbortSignal; +} + +type TelegramFetch = ( + url: string, + options?: TelegramFetchOptions, +) => Promise; export interface TelegramGetMeReachabilityHookOptions { readonly env?: NodeJS.ProcessEnv; readonly fetch?: TelegramFetch; readonly apiBaseUrl?: string; + readonly timeoutMs?: number; readonly log?: (message: string) => void; } @@ -39,9 +48,10 @@ export function createTelegramGetMeReachabilityHook( } const log = options.log ?? console.log; + const isInteractive = context.isInteractive !== false; const response = await fetchTelegramGetMe(token, options).catch(() => { const message = "Telegram reachability check failed: Bot API request failed."; - if (env.NEMOCLAW_NON_INTERACTIVE === "1") throw new Error(message); + if (!isInteractive) throw new Error(message); log(` ⚠ ${message}`); return null; }); @@ -77,14 +87,49 @@ async function fetchTelegramGetMe( ): Promise { const fetchImpl = options.fetch ?? defaultFetch; const baseUrl = (options.apiBaseUrl ?? "https://api.telegram.org").replace(/\/+$/, ""); - return fetchImpl(`${baseUrl}/bot${token}/getMe`); + const timeoutMs = normalizeTimeoutMs(options.timeoutMs); + return fetchWithTimeout(fetchImpl, `${baseUrl}/bot${token}/getMe`, timeoutMs); } -async function defaultFetch(url: string): Promise { +async function defaultFetch( + url: string, + options?: TelegramFetchOptions, +): Promise { if (typeof fetch !== "function") { throw new Error("Telegram reachability check requires global fetch."); } - return fetch(url) as Promise; + return fetch(url, options) as Promise; +} + +function normalizeTimeoutMs(timeoutMs: number | undefined): number { + return typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0 + ? timeoutMs + : DEFAULT_TELEGRAM_REACHABILITY_TIMEOUT_MS; +} + +async function fetchWithTimeout( + fetchImpl: TelegramFetch, + url: string, + timeoutMs: number, +): Promise { + const controller = typeof AbortController === "function" ? new AbortController() : null; + let timeout: ReturnType | undefined; + const timeoutPromise = new Promise((_, reject) => { + timeout = setTimeout(() => { + controller?.abort(); + reject(new Error("Telegram reachability check timed out.")); + }, timeoutMs); + timeout.unref?.(); + }); + + try { + return await Promise.race([ + fetchImpl(url, controller ? { signal: controller.signal } : undefined), + timeoutPromise, + ]); + } finally { + if (timeout) clearTimeout(timeout); + } } async function readTelegramJson(response: TelegramFetchResponse): Promise { diff --git a/src/lib/messaging/compiler/workflow-planner.test.ts b/src/lib/messaging/compiler/workflow-planner.test.ts index cce5f37344..b74f8d921c 100644 --- a/src/lib/messaging/compiler/workflow-planner.test.ts +++ b/src/lib/messaging/compiler/workflow-planner.test.ts @@ -27,11 +27,15 @@ async function withEnv( values: Readonly>, run: () => Promise, ): Promise { + const scopedValues = { + NEMOCLAW_SKIP_TELEGRAM_REACHABILITY: "1", + ...values, + }; const previous = Object.fromEntries( - Object.keys(values).map((key) => [key, process.env[key]]), + Object.keys(scopedValues).map((key) => [key, process.env[key]]), ); try { - for (const [key, value] of Object.entries(values)) { + for (const [key, value] of Object.entries(scopedValues)) { if (value === undefined) { delete process.env[key]; } else { From 47de166ff7ee75ee43fb8c4eefc9c56d6f86a238 Mon Sep 17 00:00:00 2001 From: San Dang Date: Fri, 29 May 2026 12:59:12 +0700 Subject: [PATCH 30/40] 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 31/40] 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, From 52d08ceb66b4aa6d21892acec96762247e40f8b3 Mon Sep 17 00:00:00 2001 From: San Dang Date: Fri, 29 May 2026 14:35:58 +0700 Subject: [PATCH 32/40] fix(messaging): keep setup plan in host env --- src/lib/actions/sandbox/policy-channel.ts | 5 +++- .../onboard/messaging-channel-setup.test.ts | 15 +++++++++++- src/lib/onboard/messaging-channel-setup.ts | 10 +++++++- test/onboard-messaging.test.ts | 23 +++++++++++++++++-- 4 files changed, 48 insertions(+), 5 deletions(-) diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index 97343a1823..b2318fa25a 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -14,6 +14,7 @@ import { createBuiltInChannelManifestRegistry, createBuiltInMessagingHookRegistry, getMessagingManifestAvailabilityContext, + MessagingSetupApplier, MessagingWorkflowPlanner, type SandboxMessagingChannelPlan, type SandboxMessagingPlan, @@ -638,7 +639,7 @@ async function planSandboxChannelAdd( hydrateAddChannelEnvFromSession(sandboxName, channelId); try { - return await planner.buildPlan({ + const plan = await planner.buildPlan({ sandboxName, agent: toMessagingAgentId(agent), workflow: "add-channel", @@ -648,6 +649,8 @@ async function planSandboxChannelAdd( supportedChannelIds, credentialAvailability: buildCredentialAvailability([channelId]), }); + MessagingSetupApplier.writePlanToEnv(plan); + return plan; } catch (error) { console.error(` Failed to plan messaging channel '${channelId}'.`); console.error(` ${error instanceof Error ? error.message : String(error)}`); diff --git a/src/lib/onboard/messaging-channel-setup.test.ts b/src/lib/onboard/messaging-channel-setup.test.ts index c736df9979..670debb0dc 100644 --- a/src/lib/onboard/messaging-channel-setup.test.ts +++ b/src/lib/onboard/messaging-channel-setup.test.ts @@ -5,7 +5,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getCredential, prompt, saveCredential } from "../credentials/store"; import { HOST_QR_LOGIN_HANDLERS } from "../host-qr-handlers"; -import { createBuiltInChannelManifestRegistry } from "../messaging"; +import { createBuiltInChannelManifestRegistry, MessagingSetupApplier } from "../messaging"; +import { MESSAGING_SETUP_APPLIER_ENV_KEY } from "../messaging/applier/types"; import { setupMessagingChannels, setupSelectedMessagingChannels, @@ -318,6 +319,7 @@ describe("setupSelectedMessagingChannels", () => { }), ); expect(JSON.stringify(plan)).not.toContain("pending-sandbox"); + expect(MessagingSetupApplier.requirePlanFromEnv()).toEqual(plan); }); }); @@ -373,6 +375,17 @@ describe("setupMessagingChannels", () => { expect(prompt).not.toHaveBeenCalled(); }); + it("clears any stale serialized messaging plan when no channels are selected", async () => { + process.env[MESSAGING_SETUP_APPLIER_ENV_KEY] = "stale-plan"; + + const result = await setupMessagingChannels(null, null, { + isNonInteractive: () => true, + }); + + expect(result).toEqual([]); + expect(process.env[MESSAGING_SETUP_APPLIER_ENV_KEY]).toBeUndefined(); + }); + it("skips channels missing required non-secret inputs in non-interactive mode", async () => { process.env.WECHAT_BOT_TOKEN = "wechat-token"; process.env.WECHAT_ACCOUNT_ID = " "; diff --git a/src/lib/onboard/messaging-channel-setup.ts b/src/lib/onboard/messaging-channel-setup.ts index d350b5c983..284ae29c6a 100644 --- a/src/lib/onboard/messaging-channel-setup.ts +++ b/src/lib/onboard/messaging-channel-setup.ts @@ -10,6 +10,7 @@ import { createBuiltInChannelManifestRegistry, getMessagingManifestAvailabilityContext, hasMessagingManifestRequiredInputs, + MessagingSetupApplier, MessagingWorkflowPlanner, resolveMessagingManifestSeed, type SandboxMessagingPlan, @@ -69,6 +70,7 @@ export async function setupMessagingChannels( sandboxName: deps.sandboxName, }); } else { + MessagingSetupApplier.clearPlanEnv(); note(" [non-interactive] No complete messaging channel inputs configured. Skipping."); } return Array.from(enabled); @@ -103,6 +105,7 @@ export async function setupMessagingChannels( const selected = Array.from(enabled); if (selected.length === 0) { + MessagingSetupApplier.clearPlanEnv(); console.log(" Skipping messaging channels."); return []; } @@ -133,7 +136,10 @@ export async function setupSelectedMessagingChannels( const registry = createBuiltInChannelManifestRegistry(); const supportedChannelIds = messagingChannels.map((channel) => channel.id); const selectedChannels = uniqueSelectedChannels(selected, supportedChannelIds, registry); - if (selectedChannels.length === 0) return null; + if (selectedChannels.length === 0) { + MessagingSetupApplier.clearPlanEnv(); + return null; + } const agent = toMessagingAgentId(options.agent); const sandboxName = resolveMessagingSetupSandboxName(options); @@ -149,6 +155,7 @@ export async function setupSelectedMessagingChannels( supportedChannelIds, credentialAvailability: buildCredentialAvailability(registry, selectedChannels), }); + MessagingSetupApplier.writePlanToEnv(plan); for (const channel of plan.channels) { if (!channel.active) enabled.delete(channel.channelId); } @@ -164,6 +171,7 @@ export async function setupSelectedMessagingChannels( supportedChannelIds, credentialAvailability: buildCredentialAvailability(registry, selectedChannels), }); + MessagingSetupApplier.writePlanToEnv(plan); for (const channel of plan.channels) { if (!channel.active) { diff --git a/test/onboard-messaging.test.ts b/test/onboard-messaging.test.ts index 5db343b93f..4a6d9a03bd 100644 --- a/test/onboard-messaging.test.ts +++ b/test/onboard-messaging.test.ts @@ -112,18 +112,24 @@ childProcess.spawn = (...args) => { return child; }; -const { createSandbox } = require(${onboardPath}); +const { createSandbox, setupMessagingChannels } = require(${onboardPath}); (async () => { process.env.OPENSHELL_GATEWAY = "nemoclaw"; + process.env.NEMOCLAW_SKIP_TELEGRAM_REACHABILITY = "1"; process.env.DISCORD_BOT_TOKEN = "test-discord-token-value"; process.env.SLACK_BOT_TOKEN = "xoxb-test-slack-token-value"; process.env.SLACK_APP_TOKEN = "xapp-test-slack-app-token-value"; process.env.TELEGRAM_BOT_TOKEN = "123456:ABC-test-telegram-token"; process.env.KUBECONFIG = "/tmp/host-kubeconfig"; process.env.SSH_AUTH_SOCK = "/tmp/host-ssh-agent.sock"; + await setupMessagingChannels(null, null, "my-assistant"); const sandboxName = await createSandbox(null, "gpt-5.4"); - console.log(JSON.stringify({ sandboxName, commands })); + console.log(JSON.stringify({ + sandboxName, + commands, + messagingPlanEnv: process.env.NEMOCLAW_MESSAGING_PLAN_B64, + })); })().catch((error) => { console.error(error); process.exit(1); @@ -205,6 +211,19 @@ const { createSandbox } = require(${onboardPath}); assert.doesNotMatch(createCommand.command, /xapp-test-slack-app-token-value/); assert.doesNotMatch(createCommand.command, /SLACK_BOT_TOKEN=/); assert.doesNotMatch(createCommand.command, /SLACK_APP_TOKEN=/); + assert.doesNotMatch(createCommand.command, /NEMOCLAW_MESSAGING_PLAN_B64=/); + + assert.ok(payload.messagingPlanEnv, "expected serialized messaging plan in host process env"); + const messagingPlan = JSON.parse( + Buffer.from(payload.messagingPlanEnv, "base64").toString("utf8"), + ); + assert.equal(messagingPlan.sandboxName, "my-assistant"); + assert.deepEqual( + messagingPlan.channels.map((channel: { channelId: string }) => channel.channelId).sort(), + ["discord", "slack", "telegram"].sort(), + ); + assert.doesNotMatch(JSON.stringify(messagingPlan), /test-discord-token-value/); + assert.doesNotMatch(JSON.stringify(messagingPlan), /123456:ABC-test-telegram-token/); // Verify blocked credentials are NOT in the sandbox spawn environment assert.ok(createCommand.env, "expected env to be captured from spawn call"); From 020e53e64a56726fedf5b143045a5c693dfa7fea Mon Sep 17 00:00:00 2001 From: San Dang Date: Fri, 29 May 2026 17:39:46 +0700 Subject: [PATCH 33/40] feat(messaging): persist manifest setup state --- src/lib/actions/sandbox/policy-channel.ts | 3 + .../applier/host-state-applier.test.ts | 182 ++++++++++++++++++ .../messaging/applier/host-state-applier.ts | 125 ++++++++++++ src/lib/messaging/applier/index.ts | 1 + src/lib/messaging/manifest/types.ts | 1 + src/lib/onboard.ts | 5 + src/lib/state/registry.ts | 18 ++ test/channels-add-preset.test.ts | 30 ++- 8 files changed, 359 insertions(+), 6 deletions(-) create mode 100644 src/lib/messaging/applier/host-state-applier.test.ts create mode 100644 src/lib/messaging/applier/host-state-applier.ts diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index b2318fa25a..60755eb78d 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -14,6 +14,7 @@ import { createBuiltInChannelManifestRegistry, createBuiltInMessagingHookRegistry, getMessagingManifestAvailabilityContext, + MessagingHostStateApplier, MessagingSetupApplier, MessagingWorkflowPlanner, type SandboxMessagingChannelPlan, @@ -863,6 +864,7 @@ export async function addSandboxChannel( } await applyChannelAddToGatewayAndRegistry(sandboxName, canonical, {}); persistManifestAddState(sandboxName, manifest); + MessagingHostStateApplier.applyPlanToRegistry(sandboxName, plan, { mode: "merge" }); console.log(""); const help = manifest.enrollmentHelp ?? manifest.inputs[0]?.prompt?.help; if (help) console.log(` ${help}`); @@ -887,6 +889,7 @@ export async function addSandboxChannel( console.log(` ${G}✓${R} Registered ${canonical} bridge with the OpenShell gateway.`); applyChannelPresetIfAvailable(sandboxName, canonical); + MessagingHostStateApplier.applyPlanToRegistry(sandboxName, plan, { mode: "merge" }); const rebuilt = await promptAndRebuild(sandboxName, `add '${canonical}'`); if (rebuilt) verifyChannelBridgeAfterRebuild(sandboxName, canonical); diff --git a/src/lib/messaging/applier/host-state-applier.test.ts b/src/lib/messaging/applier/host-state-applier.test.ts new file mode 100644 index 0000000000..7a18eb5850 --- /dev/null +++ b/src/lib/messaging/applier/host-state-applier.test.ts @@ -0,0 +1,182 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { SandboxMessagingPlan } from "../manifest"; +import { MessagingHostStateApplier } from "./host-state-applier"; +import { MessagingSetupApplier } from "./setup-applier"; +import * as registry from "../../state/registry"; + +vi.mock("../../state/registry", () => { + const sandboxes = new Map>(); + return { + __clear: () => sandboxes.clear(), + __getSandbox: (name: string) => sandboxes.get(name) ?? null, + __setSandbox: (name: string, entry: Record) => + sandboxes.set(name, { ...entry }), + getSandbox: vi.fn((name: string) => sandboxes.get(name) ?? null), + updateSandbox: vi.fn((name: string, updates: Record) => { + const entry = sandboxes.get(name); + if (!entry) return false; + Object.assign(entry, updates); + return true; + }), + }; +}); + +const registryMock = registry as typeof registry & { + __clear(): void; + __getSandbox(name: string): Record | null; + __setSandbox(name: string, entry: Record): void; +}; + +describe("MessagingHostStateApplier", () => { + beforeEach(() => { + registryMock.__clear(); + vi.clearAllMocks(); + }); + + it("builds durable messaging state from the manifest plan env", () => { + const env: NodeJS.ProcessEnv = {}; + const plan = makePlan(["telegram"]); + + MessagingSetupApplier.writePlanToEnv(plan, { env }); + const state = MessagingHostStateApplier.readPlanStateFromEnv({ env }); + + expect(state).toEqual({ + schemaVersion: 1, + plan, + }); + }); + + it("stores only the new messaging state on an existing sandbox entry", () => { + registryMock.__setSandbox("demo", { + name: "demo", + messagingChannels: ["telegram"], + disabledChannels: ["discord"], + }); + const plan = makePlan(["telegram"]); + + const updated = MessagingHostStateApplier.applyPlanToRegistry("demo", plan); + + expect(updated).toBe(true); + expect(registry.updateSandbox).toHaveBeenCalledWith("demo", { + messaging: { + schemaVersion: 1, + plan, + }, + }); + expect(registryMock.__getSandbox("demo")).toMatchObject({ + messagingChannels: ["telegram"], + disabledChannels: ["discord"], + messaging: { + schemaVersion: 1, + plan, + }, + }); + }); + + it("can merge a single-channel add plan into existing messaging state", () => { + registryMock.__setSandbox("demo", { + name: "demo", + messaging: MessagingHostStateApplier.buildStateFromPlan(makePlan(["telegram"])), + }); + + const updated = MessagingHostStateApplier.applyPlanToRegistry( + "demo", + makePlan(["slack"], { + credentialBindings: [ + makeCredentialBinding("slack", "bot"), + makeCredentialBinding("slack", "app"), + ], + }), + { mode: "merge" }, + ); + + expect(updated).toBe(true); + const entry = registryMock.__getSandbox("demo"); + const plan = (entry?.messaging as { plan: SandboxMessagingPlan }).plan; + expect(plan.channels.map((channel) => channel.channelId)).toEqual([ + "telegram", + "slack", + ]); + expect(plan.credentialBindings.map((binding) => binding.providerEnvKey)).toEqual([ + "TELEGRAM_BOT_TOKEN", + "SLACK_BOT_TOKEN", + "SLACK_APP_TOKEN", + ]); + expect(plan.networkPolicy.presets).toEqual(["slack", "telegram"]); + }); +}); + +function makePlan( + channelIds: readonly string[], + overrides: Partial = {}, +): SandboxMessagingPlan { + return { + schemaVersion: 1, + sandboxName: "demo", + agent: "openclaw", + workflow: "add-channel", + channels: channelIds.map((channelId) => ({ + channelId, + displayName: channelId, + authMode: "token-paste", + active: true, + selected: true, + configured: true, + disabled: false, + inputs: [], + hooks: [], + })), + disabledChannels: [], + credentialBindings: channelIds.map((channelId) => makeCredentialBinding(channelId, "bot")), + networkPolicy: { + presets: [...channelIds], + entries: channelIds.map((channelId) => ({ + channelId, + presetName: channelId, + policyKeys: [channelId], + source: "manifest", + })), + }, + agentRender: channelIds.map((channelId) => ({ + channelId, + agent: "openclaw", + target: "openclaw.json", + kind: "json-fragment", + path: `channels.${channelId}`, + value: { enabled: true }, + templateRefs: [], + })), + buildSteps: [], + stateUpdates: channelIds.map((channelId) => ({ + channelId, + kind: "persist-inputs", + stateKey: channelId, + inputIds: [], + })), + healthChecks: [], + ...overrides, + }; +} + +function makeCredentialBinding( + channelId: string, + credentialId: string, +): SandboxMessagingPlan["credentialBindings"][number] { + const envKey = + channelId === "slack" && credentialId === "app" + ? "SLACK_APP_TOKEN" + : `${channelId.toUpperCase()}_BOT_TOKEN`; + return { + channelId, + credentialId, + sourceInput: credentialId, + providerName: `demo-${channelId}-${credentialId}`, + providerEnvKey: envKey, + placeholder: `\${${envKey}}`, + credentialAvailable: true, + }; +} diff --git a/src/lib/messaging/applier/host-state-applier.ts b/src/lib/messaging/applier/host-state-applier.ts new file mode 100644 index 0000000000..2c9add5d53 --- /dev/null +++ b/src/lib/messaging/applier/host-state-applier.ts @@ -0,0 +1,125 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { SandboxMessagingPlan } from "../manifest"; +import * as registry from "../../state/registry"; +import { MessagingSetupApplier } from "./setup-applier"; +import type { MessagingSetupEnvOptions } from "./types"; + +export interface MessagingHostStateApplyOptions { + readonly mode?: "replace" | "merge"; +} + +export class MessagingHostStateApplier { + static buildStateFromPlan(plan: SandboxMessagingPlan): registry.SandboxMessagingState { + return { + schemaVersion: 1, + plan: clonePlan(plan), + }; + } + + static readPlanStateFromEnv( + options: MessagingSetupEnvOptions = {}, + ): registry.SandboxMessagingState | undefined { + const plan = MessagingSetupApplier.readPlanFromEnv(options); + return plan ? this.buildStateFromPlan(plan) : undefined; + } + + static applyPlanFromEnv( + sandboxName: string, + options: MessagingSetupEnvOptions & MessagingHostStateApplyOptions = {}, + ): boolean { + const plan = MessagingSetupApplier.readPlanFromEnv(options); + if (!plan) return false; + return this.applyPlanToRegistry(sandboxName, plan, options); + } + + static applyPlanToRegistry( + sandboxName: string, + plan: SandboxMessagingPlan, + options: MessagingHostStateApplyOptions = {}, + ): boolean { + if (plan.sandboxName !== sandboxName) return false; + const entry = registry.getSandbox(sandboxName); + if (!entry) return false; + const nextPlan = + options.mode === "merge" && entry.messaging?.plan + ? mergeSandboxMessagingPlans(entry.messaging.plan, plan) + : clonePlan(plan); + return registry.updateSandbox(sandboxName, { + messaging: { + schemaVersion: 1, + plan: nextPlan, + }, + }); + } +} + +function clonePlan(plan: SandboxMessagingPlan): SandboxMessagingPlan { + return MessagingSetupApplier.decodePlan(MessagingSetupApplier.encodePlan(plan)); +} + +function mergeSandboxMessagingPlans( + existing: SandboxMessagingPlan, + incoming: SandboxMessagingPlan, +): SandboxMessagingPlan { + if ( + existing.schemaVersion !== incoming.schemaVersion || + existing.sandboxName !== incoming.sandboxName || + existing.agent !== incoming.agent + ) { + return clonePlan(incoming); + } + + const incomingChannelIds = new Set(incoming.channels.map((channel) => channel.channelId)); + const mergedChannels = [ + ...existing.channels.filter((channel) => !incomingChannelIds.has(channel.channelId)), + ...incoming.channels, + ]; + const activeIncomingChannels = new Set( + incoming.channels + .filter((channel) => channel.active && !channel.disabled) + .map((channel) => channel.channelId), + ); + const disabledChannels = uniqueStrings([ + ...existing.disabledChannels.filter((channelId) => !activeIncomingChannels.has(channelId)), + ...incoming.disabledChannels, + ]); + const networkEntries = mergeByChannelId( + existing.networkPolicy.entries, + incoming.networkPolicy.entries, + ); + + return clonePlan({ + ...incoming, + channels: mergedChannels, + disabledChannels, + credentialBindings: mergeByChannelId( + existing.credentialBindings, + incoming.credentialBindings, + ), + networkPolicy: { + presets: uniqueStrings(networkEntries.map((entry) => entry.presetName)), + entries: networkEntries, + }, + agentRender: mergeByChannelId(existing.agentRender, incoming.agentRender), + buildSteps: mergeByChannelId(existing.buildSteps, incoming.buildSteps), + stateUpdates: mergeByChannelId(existing.stateUpdates, incoming.stateUpdates), + healthChecks: mergeByChannelId(existing.healthChecks, incoming.healthChecks), + }); +} + +function mergeByChannelId( + existing: readonly T[], + incoming: readonly T[], +): T[] { + const incomingChannelIds = new Set(incoming.map((entry) => entry.channelId)); + return [ + ...existing.filter((entry) => !incomingChannelIds.has(entry.channelId)), + ...incoming, + ]; +} + +function uniqueStrings(values: readonly string[]): string[] { + return [...new Set(values)].filter(Boolean).sort(); +} diff --git a/src/lib/messaging/applier/index.ts b/src/lib/messaging/applier/index.ts index 31b80dc3c2..f9e4dcf486 100644 --- a/src/lib/messaging/applier/index.ts +++ b/src/lib/messaging/applier/index.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 export * from "./setup-applier"; +export * from "./host-state-applier"; export * from "./agent-config"; export * from "./openshell-provider"; export * from "./policy"; diff --git a/src/lib/messaging/manifest/types.ts b/src/lib/messaging/manifest/types.ts index 7445dc6b81..5774a81957 100644 --- a/src/lib/messaging/manifest/types.ts +++ b/src/lib/messaging/manifest/types.ts @@ -238,6 +238,7 @@ export interface SandboxMessagingCredentialBindingPlan { readonly providerEnvKey: string; readonly placeholder: MessagingTemplateString; readonly credentialAvailable: boolean; + readonly credentialHash?: string; } /** Network policy presets and concrete policy keys required by active channels. */ diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 1acec55423..746500ee10 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -93,6 +93,7 @@ const { const { setupMessagingChannels: setupMessagingChannelsImpl, } = require("./onboard/messaging-channel-setup") as typeof import("./onboard/messaging-channel-setup"); +const { MessagingHostStateApplier }: typeof import("./messaging") = require("./messaging"); const { clearAgentScopedResumeState, }: typeof import("./onboard/agent-resume-state") = require("./onboard/agent-resume-state"); @@ -3792,6 +3793,9 @@ async function createSandbox( const resolvedImageTag = resolveSandboxImageTagFromCreateOutput(createResult.output, buildId); const sandboxRuntimeFields = getSandboxRuntimeRegistryFields(effectiveSandboxGpuConfig); + const plannedMessagingState = MessagingHostStateApplier.readPlanStateFromEnv(); + const messagingState = + plannedMessagingState?.plan.sandboxName === sandboxName ? plannedMessagingState : undefined; registry.registerSandbox({ name: sandboxName, model: model || null, @@ -3810,6 +3814,7 @@ async function createSandbox( messagingChannels: enabledChannels != null ? [...new Set(enabledChannels)] : activeMessagingChannels, messagingChannelConfig: messagingChannelConfig || undefined, + messaging: messagingState, disabledChannels: disabledChannels.length > 0 ? [...disabledChannels] : undefined, hermesToolGateways: hermesToolGateways.length > 0 ? [...hermesToolGateways] : undefined, ...onboardHermesDashboard.getHermesDashboardRegistryFields(finalHermesDashboardState), diff --git a/src/lib/state/registry.ts b/src/lib/state/registry.ts index 037c99f79f..3f51f6610f 100644 --- a/src/lib/state/registry.ts +++ b/src/lib/state/registry.ts @@ -6,6 +6,7 @@ import path from "node:path"; import { ensureConfigDir, readConfigFile, writeConfigFile } from "./config-io"; import { isErrnoException } from "../core/errno"; +import type { SandboxMessagingPlan } from "../messaging/manifest"; import type { MessagingChannelConfig } from "../messaging-channel-config"; export interface CustomPolicyEntry { @@ -37,6 +38,7 @@ export interface SandboxEntry { providerCredentialHashes?: Record; messagingChannels?: string[]; messagingChannelConfig?: MessagingChannelConfig; + messaging?: SandboxMessagingState; hermesToolGateways?: string[]; hermesDashboardEnabled?: boolean; hermesDashboardPort?: number | null; @@ -46,6 +48,11 @@ export interface SandboxEntry { dashboardPort?: number | null; } +export interface SandboxMessagingState { + schemaVersion: 1; + plan: SandboxMessagingPlan; +} + export interface SandboxRegistry { sandboxes: Record; defaultSandbox: string | null; @@ -219,6 +226,7 @@ export function registerSandbox(entry: SandboxEntry): void { entry.messagingChannelConfig && Object.keys(entry.messagingChannelConfig).length > 0 ? { ...entry.messagingChannelConfig } : undefined, + messaging: cloneSandboxMessagingState(entry.messaging), hermesToolGateways: Array.isArray(entry.hermesToolGateways) && entry.hermesToolGateways.length > 0 ? [...entry.hermesToolGateways] @@ -240,6 +248,16 @@ export function registerSandbox(entry: SandboxEntry): void { }); } +function cloneSandboxMessagingState( + messaging: SandboxMessagingState | undefined, +): SandboxMessagingState | undefined { + if (!messaging || messaging.schemaVersion !== 1) return undefined; + return { + schemaVersion: 1, + plan: JSON.parse(JSON.stringify(messaging.plan)) as SandboxMessagingPlan, + }; +} + export function updateSandbox(name: string, updates: Partial): boolean { return withLock(() => { const data = load(); diff --git a/test/channels-add-preset.test.ts b/test/channels-add-preset.test.ts index b13eadd7e4..69abe60e34 100644 --- a/test/channels-add-preset.test.ts +++ b/test/channels-add-preset.test.ts @@ -309,12 +309,30 @@ const ctx = module.exports; assert.ok(!payload.error, `unexpected error: ${payload.error}\n${payload.stack || ""}`); assert.deepEqual(payload.providerCalls, [], "WhatsApp must not create host-side providers"); - assert.deepEqual(payload.registryUpdates, [ - { - name: "test-sb", - updates: { messagingChannels: ["whatsapp"], disabledChannels: [] }, - }, - ]); + assert.deepEqual(payload.registryUpdates[0], { + name: "test-sb", + updates: { messagingChannels: ["whatsapp"], disabledChannels: [] }, + }); + const messagingStateUpdate = payload.registryUpdates.find( + (entry: { updates?: { messaging?: { plan?: { channels?: Array<{ channelId?: string }> } } } }) => + entry.updates?.messaging?.plan, + ); + assert.ok( + messagingStateUpdate, + `expected a registry update that stores durable messaging state; got ${JSON.stringify(payload.registryUpdates)}`, + ); + assert.deepEqual( + messagingStateUpdate.updates.messaging.plan.channels.map( + (channel: { channelId: string }) => channel.channelId, + ), + ["whatsapp"], + ); + assert.equal(messagingStateUpdate.updates.messaging.plan.agent, "hermes"); + assert.deepEqual(messagingStateUpdate.updates.messaging.plan.credentialBindings, []); + assert.deepEqual( + payload.registryUpdates.map((entry: { name: string }) => entry.name), + ["test-sb", "test-sb"], + ); assert.deepEqual( payload.appliedCalls, [{ sandboxName: "test-sb", presetName: "whatsapp" }], From d9f6f833ad4b63171bfaa4d20dc4e0ea182ccf20 Mon Sep 17 00:00:00 2001 From: San Dang Date: Mon, 1 Jun 2026 17:57:33 +0700 Subject: [PATCH 34/40] fix(messaging): move telegram alias enrollment to hook --- src/lib/actions/sandbox/policy-channel.ts | 6 +- src/lib/messaging-channel-config.test.ts | 19 +-- src/lib/messaging-channel-config.ts | 13 +- src/lib/messaging/channels/manifests.test.ts | 14 ++- .../telegram/hooks/allowlist-aliases.test.ts | 75 ++++++++++++ .../telegram/hooks/allowlist-aliases.ts | 76 ++++++++++++ .../hooks/get-me-reachability.test.ts | 23 ++-- .../telegram/hooks/get-me-reachability.ts | 38 ++++-- .../channels/telegram/hooks/index.ts | 1 + .../messaging/channels/telegram/manifest.ts | 13 +- .../compiler/manifest-compiler.test.ts | 55 +++++++++ .../messaging/compiler/manifest-compiler.ts | 12 +- src/lib/messaging/hooks/hook-runner.test.ts | 1 + .../onboard/messaging-channel-setup.test.ts | 19 ++- src/lib/onboard/messaging-config.test.ts | 47 -------- src/lib/onboard/messaging-config.ts | 5 - src/lib/onboard/telegram-reachability.test.ts | 114 ------------------ src/lib/onboard/telegram-reachability.ts | 112 ----------------- src/lib/sandbox/channels.ts | 3 - 19 files changed, 316 insertions(+), 330 deletions(-) create mode 100644 src/lib/messaging/channels/telegram/hooks/allowlist-aliases.test.ts create mode 100644 src/lib/messaging/channels/telegram/hooks/allowlist-aliases.ts delete mode 100644 src/lib/onboard/telegram-reachability.test.ts delete mode 100644 src/lib/onboard/telegram-reachability.ts diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index 370824c27f..8517c4083c 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -25,6 +25,7 @@ import { type MessagingChannelConfig, mergeMessagingChannelConfigs, normalizeMessagingChannelConfigValue, + resolveMessagingChannelConfigEnvValue, sanitizeMessagingChannelConfig, } from "../../messaging-channel-config"; import { hashCredential } from "../../security/credential-hash"; @@ -779,7 +780,10 @@ function readManifestMessagingConfigFromEnv(manifest: ChannelManifest): Messagin const result: MessagingChannelConfig = {}; for (const input of manifest.inputs) { if (input.kind !== "config" || !input.envKey) continue; - const normalized = normalizeMessagingChannelConfigValue(input.envKey, process.env[input.envKey]); + const resolved = resolveMessagingChannelConfigEnvValue(input.envKey, process.env); + const normalized = + resolved.value ?? + normalizeMessagingChannelConfigValue(input.envKey, process.env[input.envKey]); if (normalized) result[input.envKey] = normalized; } return sanitizeMessagingChannelConfig(result); diff --git a/src/lib/messaging-channel-config.test.ts b/src/lib/messaging-channel-config.test.ts index 7672e91f02..9f34712b75 100644 --- a/src/lib/messaging-channel-config.test.ts +++ b/src/lib/messaging-channel-config.test.ts @@ -28,7 +28,6 @@ describe("messaging channel config", () => { expect( sanitizeMessagingChannelConfig({ TELEGRAM_ALLOWED_IDS: " 123,456 ", - TELEGRAM_AUTHORIZED_CHAT_IDS: "ignored-because-canonical-wins", TELEGRAM_REQUIRE_MENTION: "yes", DISCORD_SERVER_ID: "1491590992753590594", DISCORD_REQUIRE_MENTION: "0", @@ -45,22 +44,18 @@ describe("messaging channel config", () => { }); }); - it("canonicalizes Telegram allowlist aliases from env and persisted config", () => { + it("leaves Telegram compatibility aliases to Telegram enrollment hooks", () => { expect( sanitizeMessagingChannelConfig({ TELEGRAM_AUTHORIZED_CHAT_IDS: " 123, 456 ", }), - ).toEqual({ - TELEGRAM_ALLOWED_IDS: "123, 456", - }); + ).toBeNull(); expect( readMessagingChannelConfigFromEnv({ TELEGRAM_CHAT_ID: "8388960805", }), - ).toEqual({ - TELEGRAM_ALLOWED_IDS: "8388960805", - }); + ).toBeNull(); }); it("hydrates missing env values but preserves explicit env overrides", () => { @@ -86,15 +81,13 @@ describe("messaging channel config", () => { expect(env.DISCORD_REQUIRE_MENTION).toBeUndefined(); }); - it("hydrates Telegram aliases into the canonical env key for downstream build code", () => { + it("does not hydrate Telegram aliases outside the enrollment hook", () => { const env: NodeJS.ProcessEnv = { TELEGRAM_AUTHORIZED_CHAT_IDS: "alias-user", }; - expect(hydrateMessagingChannelConfig(null, env)).toEqual({ - TELEGRAM_ALLOWED_IDS: "alias-user", - }); - expect(env.TELEGRAM_ALLOWED_IDS).toBe("alias-user"); + expect(hydrateMessagingChannelConfig(null, env)).toBeNull(); + expect(env.TELEGRAM_ALLOWED_IDS).toBeUndefined(); }); it("reads effective config from env", () => { diff --git a/src/lib/messaging-channel-config.ts b/src/lib/messaging-channel-config.ts index 2460fd5676..61f5d003c8 100644 --- a/src/lib/messaging-channel-config.ts +++ b/src/lib/messaging-channel-config.ts @@ -6,14 +6,6 @@ import { listChannels } from "./sandbox/channels"; export type MessagingChannelConfig = Record; const channels = listChannels(); -const CONFIG_ALIASES: Record = { - TELEGRAM_ALLOWED_IDS: ["TELEGRAM_AUTHORIZED_CHAT_IDS", "TELEGRAM_CHAT_ID"], -}; -const aliasToCanonicalKey = new Map( - Object.entries(CONFIG_ALIASES).flatMap(([canonical, aliases]) => - aliases.map((alias) => [alias, canonical] as const), - ), -); const requireMentionKeys = new Set( channels .map((channel) => channel.requireMentionEnvKey) @@ -48,14 +40,13 @@ function normalizeValue(value: unknown): string | null { } export function getCanonicalMessagingChannelConfigKey(key: string): string | null { - if (knownConfigKeys.has(key)) return key; - return aliasToCanonicalKey.get(key) ?? null; + return knownConfigKeys.has(key) ? key : null; } export function getMessagingChannelConfigEnvKeys(key: string): readonly string[] { const canonical = getCanonicalMessagingChannelConfigKey(key); if (!canonical) return []; - return [canonical, ...(CONFIG_ALIASES[canonical] ?? [])]; + return [canonical]; } export function normalizeMessagingChannelConfigValue( diff --git a/src/lib/messaging/channels/manifests.test.ts b/src/lib/messaging/channels/manifests.test.ts index cf5b41f28f..7354141fa2 100644 --- a/src/lib/messaging/channels/manifests.test.ts +++ b/src/lib/messaging/channels/manifests.test.ts @@ -14,6 +14,7 @@ import { COMMON_CONFIG_PROMPT_HOOK_HANDLER_ID, COMMON_TOKEN_PASTE_HOOK_HANDLER_ID, } from "../hooks/common"; +import { TELEGRAM_ALLOWLIST_ALIASES_HOOK_ID } from "./telegram/hooks"; import type { ChannelInputSpec, ChannelManifest, ChannelRenderSpec } from "../manifest"; import { BUILT_IN_CHANNEL_MANIFESTS, @@ -82,7 +83,7 @@ function expectReachabilityHook(manifest: ChannelManifest, inputIds: readonly st phase: "reachability-check", handler: `${manifest.id}.getMeReachability`, inputs: inputIds, - onFailure: "abort", + onFailure: "skip-channel", }); } @@ -224,6 +225,17 @@ 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-allowlist-aliases", + phase: "enroll", + handler: TELEGRAM_ALLOWLIST_ALIASES_HOOK_ID, + outputs: [ + { + id: "allowedIds", + kind: "config", + }, + ], + }); expectConfigPromptEnrollHook(telegramManifest, ["requireMention", "allowedIds"]); expectReachabilityHook(telegramManifest, ["botToken"]); }); diff --git a/src/lib/messaging/channels/telegram/hooks/allowlist-aliases.test.ts b/src/lib/messaging/channels/telegram/hooks/allowlist-aliases.test.ts new file mode 100644 index 0000000000..9486f96367 --- /dev/null +++ b/src/lib/messaging/channels/telegram/hooks/allowlist-aliases.test.ts @@ -0,0 +1,75 @@ +// 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 type { ChannelHookSpec } from "../../../manifest"; +import { + createTelegramAllowlistAliasesHook, + TELEGRAM_ALLOWLIST_ALIASES_HOOK_ID, +} from "./allowlist-aliases"; + +const TELEGRAM_ALLOWLIST_ALIASES_HOOK = { + id: "telegram-allowlist-aliases", + phase: "enroll", + handler: TELEGRAM_ALLOWLIST_ALIASES_HOOK_ID, + outputs: [ + { + id: "allowedIds", + kind: "config", + }, + ], +} as const satisfies ChannelHookSpec; + +describe("Telegram allowlist aliases hook implementation", () => { + it("merges compatibility aliases into canonical TELEGRAM_ALLOWED_IDS", async () => { + const env: NodeJS.ProcessEnv = { + TELEGRAM_ALLOWED_IDS: "111, 222", + TELEGRAM_AUTHORIZED_CHAT_IDS: "333,222", + TELEGRAM_CHAT_ID: "444", + }; + const registry = new MessagingHookRegistry([ + { + id: TELEGRAM_ALLOWLIST_ALIASES_HOOK_ID, + handler: createTelegramAllowlistAliasesHook({ env }), + }, + ]); + + await expect( + runMessagingHook(TELEGRAM_ALLOWLIST_ALIASES_HOOK, registry, { + channelId: "telegram", + }), + ).resolves.toMatchObject({ + hookId: "telegram-allowlist-aliases", + handlerId: TELEGRAM_ALLOWLIST_ALIASES_HOOK_ID, + phase: "enroll", + outputs: { + allowedIds: { + kind: "config", + value: "111,222,333,444", + }, + }, + }); + expect(env.TELEGRAM_ALLOWED_IDS).toBe("111,222,333,444"); + }); + + it("does nothing when no canonical or alias allowlist values are present", async () => { + const env: NodeJS.ProcessEnv = {}; + const registry = new MessagingHookRegistry([ + { + id: TELEGRAM_ALLOWLIST_ALIASES_HOOK_ID, + handler: createTelegramAllowlistAliasesHook({ env }), + }, + ]); + + await expect( + runMessagingHook(TELEGRAM_ALLOWLIST_ALIASES_HOOK, registry, { + channelId: "telegram", + }), + ).resolves.toMatchObject({ + outputs: {}, + }); + expect(env.TELEGRAM_ALLOWED_IDS).toBeUndefined(); + }); +}); diff --git a/src/lib/messaging/channels/telegram/hooks/allowlist-aliases.ts b/src/lib/messaging/channels/telegram/hooks/allowlist-aliases.ts new file mode 100644 index 0000000000..fa53798723 --- /dev/null +++ b/src/lib/messaging/channels/telegram/hooks/allowlist-aliases.ts @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { + MessagingHookHandler, + MessagingHookOutputMap, + MessagingHookRegistration, +} from "../../../hooks/types"; + +export const TELEGRAM_ALLOWLIST_ALIASES_HOOK_ID = "telegram.allowlistAliases"; + +export const TELEGRAM_ALLOWED_IDS_ENV = "TELEGRAM_ALLOWED_IDS"; +export const TELEGRAM_ALLOWED_IDS_ALIAS_ENVS = [ + "TELEGRAM_AUTHORIZED_CHAT_IDS", + "TELEGRAM_CHAT_ID", +] as const; + +export interface TelegramAllowlistAliasesHookOptions { + readonly env?: NodeJS.ProcessEnv; +} + +export function createTelegramAllowlistAliasesHook( + options: TelegramAllowlistAliasesHookOptions = {}, +): MessagingHookHandler { + return async () => { + const env = options.env ?? process.env; + const value = mergeTelegramAllowlistAliases(env); + const outputs: Record = {}; + if (value) { + outputs.allowedIds = { + kind: "config", + value, + }; + } + return { outputs }; + }; +} + +export function createTelegramAllowlistAliasesHookRegistration( + options: TelegramAllowlistAliasesHookOptions = {}, +): MessagingHookRegistration { + return { + id: TELEGRAM_ALLOWLIST_ALIASES_HOOK_ID, + handler: createTelegramAllowlistAliasesHook(options), + }; +} + +export function mergeTelegramAllowlistAliases( + env: NodeJS.ProcessEnv | Record = process.env, +): string | null { + const values = [ + env[TELEGRAM_ALLOWED_IDS_ENV], + ...TELEGRAM_ALLOWED_IDS_ALIAS_ENVS.map((key) => env[key]), + ]; + const merged = mergeAllowlistValues(values); + if (!merged) return null; + env[TELEGRAM_ALLOWED_IDS_ENV] = merged; + return merged; +} + +function mergeAllowlistValues(values: readonly unknown[]): string | null { + const ids: string[] = []; + const seen = new Set(); + for (const value of values) { + if (typeof value !== "string") continue; + const normalized = value.replace(/[\r\n]/g, "").trim(); + if (!normalized) continue; + for (const entry of normalized.split(",")) { + const id = entry.trim(); + if (!id || seen.has(id)) continue; + ids.push(id); + seen.add(id); + } + } + return ids.length > 0 ? ids.join(",") : null; +} 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 845b606899..922b535492 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 @@ -15,7 +15,7 @@ const TELEGRAM_REACHABILITY_HOOK = { phase: "reachability-check", handler: TELEGRAM_GET_ME_REACHABILITY_HOOK_ID, inputs: ["botToken"], - onFailure: "abort", + onFailure: "skip-channel", } as const satisfies ChannelHookSpec; describe("Telegram getMe reachability hook implementation", () => { @@ -58,7 +58,7 @@ describe("Telegram getMe reachability hook implementation", () => { expect(urls).toEqual(["https://telegram.test/bot123456:telegram-token/getMe"]); }); - it("warns and continues when Telegram rejects the token", async () => { + it("fails so the compiler can skip the channel when Telegram rejects the token", async () => { const logs: string[] = []; const registry = new MessagingHookRegistry([ { @@ -86,20 +86,23 @@ describe("Telegram getMe reachability hook implementation", () => { botToken: "bad-token", }, }), - ).resolves.toMatchObject({ - hookId: "telegram-reachability", - outputs: {}, - }); + ).rejects.toThrow("Telegram bot token was rejected."); expect(logs).toEqual([ " ⚠ Bot token was rejected by Telegram — verify the token is correct.", + [ + " Telegram integration will be disabled for this enrollment run because", + "the bot token was rejected by Telegram.", + ].join(" "), ]); }); - it("fails closed when the Bot API request fails in non-interactive mode", async () => { + it("fails so the compiler can skip the channel when non-interactive Bot API requests fail", async () => { + const logs: string[] = []; const registry = new MessagingHookRegistry([ { id: TELEGRAM_GET_ME_REACHABILITY_HOOK_ID, handler: createTelegramGetMeReachabilityHook({ + log: (message) => logs.push(message), fetch: async () => { throw new Error("network unavailable"); }, @@ -115,6 +118,12 @@ describe("Telegram getMe reachability hook implementation", () => { }, }), ).rejects.toThrow("Telegram reachability check failed: Bot API request failed."); + expect(logs).toEqual([ + [ + " Telegram integration will be disabled for this enrollment run because", + "api.telegram.org is unreachable.", + ].join(" "), + ]); }); it("bounds hung Bot API requests with a timeout", async () => { diff --git a/src/lib/messaging/channels/telegram/hooks/get-me-reachability.ts b/src/lib/messaging/channels/telegram/hooks/get-me-reachability.ts index 17b8cc36e2..43448f6569 100644 --- a/src/lib/messaging/channels/telegram/hooks/get-me-reachability.ts +++ b/src/lib/messaging/channels/telegram/hooks/get-me-reachability.ts @@ -3,6 +3,10 @@ import { normalizeCredentialValue } from "../../../../credentials/store"; import type { MessagingHookHandler, MessagingHookRegistration } from "../../../hooks/types"; +import { + createTelegramAllowlistAliasesHookRegistration, + type TelegramAllowlistAliasesHookOptions, +} from "./allowlist-aliases"; export const TELEGRAM_GET_ME_REACHABILITY_HOOK_ID = "telegram.getMeReachability"; const DEFAULT_TELEGRAM_REACHABILITY_TIMEOUT_MS = 10_000; @@ -24,8 +28,7 @@ type TelegramFetch = ( options?: TelegramFetchOptions, ) => Promise; -export interface TelegramGetMeReachabilityHookOptions { - readonly env?: NodeJS.ProcessEnv; +export interface TelegramGetMeReachabilityHookOptions extends TelegramAllowlistAliasesHookOptions { readonly fetch?: TelegramFetch; readonly apiBaseUrl?: string; readonly timeoutMs?: number; @@ -51,19 +54,29 @@ export function createTelegramGetMeReachabilityHook( const isInteractive = context.isInteractive !== false; const response = await fetchTelegramGetMe(token, options).catch(() => { const message = "Telegram reachability check failed: Bot API request failed."; - if (!isInteractive) throw new Error(message); + if (!isInteractive) { + logTelegramDisabled("api.telegram.org is unreachable", log); + throw new Error(message); + } log(` ⚠ ${message}`); return null; }); if (!response) return {}; if (!response.ok) { + if (isRejectedTokenResponse(response)) { + logRejectedToken(log); + logTelegramDisabled("the bot token was rejected by Telegram", log); + throw new Error("Telegram bot token was rejected."); + } logTelegramHttpWarning(response, log); return {}; } const payload = await readTelegramJson(response); if (!isObject(payload) || payload.ok !== true) { - log(" ⚠ Bot token was rejected by Telegram — verify the token is correct."); + logRejectedToken(log); + logTelegramDisabled("the bot token was rejected by Telegram", log); + throw new Error("Telegram bot token was rejected."); } return {}; @@ -74,6 +87,7 @@ export function createTelegramHookRegistrations( options: TelegramGetMeReachabilityHookOptions = {}, ): readonly MessagingHookRegistration[] { return [ + createTelegramAllowlistAliasesHookRegistration(options), { id: TELEGRAM_GET_ME_REACHABILITY_HOOK_ID, handler: createTelegramGetMeReachabilityHook(options), @@ -144,14 +158,22 @@ function isObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +function isRejectedTokenResponse(response: TelegramFetchResponse): boolean { + return response.status === 401 || response.status === 404; +} + +function logRejectedToken(log: (message: string) => void): void { + log(" ⚠ Bot token was rejected by Telegram — verify the token is correct."); +} + +function logTelegramDisabled(reason: string, log: (message: string) => void): void { + log(` Telegram integration will be disabled for this enrollment run because ${reason}.`); +} + function logTelegramHttpWarning( response: TelegramFetchResponse, log: (message: string) => void, ): void { - if (response.status === 401 || response.status === 404) { - log(" ⚠ Bot token was rejected by Telegram — verify the token is correct."); - return; - } log( ` ⚠ Telegram API returned HTTP ${response.status}${ response.statusText ? ` ${response.statusText}` : "" diff --git a/src/lib/messaging/channels/telegram/hooks/index.ts b/src/lib/messaging/channels/telegram/hooks/index.ts index bafffe4fcb..604e80d5ca 100644 --- a/src/lib/messaging/channels/telegram/hooks/index.ts +++ b/src/lib/messaging/channels/telegram/hooks/index.ts @@ -2,3 +2,4 @@ // SPDX-License-Identifier: Apache-2.0 export * from "./get-me-reachability"; +export * from "./allowlist-aliases"; diff --git a/src/lib/messaging/channels/telegram/manifest.ts b/src/lib/messaging/channels/telegram/manifest.ts index 27e85e97d6..d0af4167bd 100644 --- a/src/lib/messaging/channels/telegram/manifest.ts +++ b/src/lib/messaging/channels/telegram/manifest.ts @@ -150,6 +150,17 @@ export const telegramManifest = { ], onFailure: "skip-channel", }, + { + id: "telegram-allowlist-aliases", + phase: "enroll", + handler: "telegram.allowlistAliases", + outputs: [ + { + id: "allowedIds", + kind: "config", + }, + ], + }, { id: "telegram-config-prompt", phase: "enroll", @@ -170,7 +181,7 @@ export const telegramManifest = { phase: "reachability-check", handler: "telegram.getMeReachability", inputs: ["botToken"], - onFailure: "abort", + onFailure: "skip-channel", }, ], } 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 2ce0187a57..49d5423809 100644 --- a/src/lib/messaging/compiler/manifest-compiler.test.ts +++ b/src/lib/messaging/compiler/manifest-compiler.test.ts @@ -380,6 +380,7 @@ describe("ManifestCompiler", () => { expect(plan.disabledChannels).toEqual(["telegram"]); expect(plan.channels[0]?.hooks.map((hook) => hook.id)).toEqual([ "telegram-token-paste", + "telegram-allowlist-aliases", "telegram-config-prompt", "telegram-get-me-reachability", ]); @@ -425,6 +426,10 @@ describe("ManifestCompiler", () => { id: "common.configPrompt", handler: () => ({}), }, + { + id: "telegram.allowlistAliases", + handler: () => ({}), + }, { id: "telegram.getMeReachability", handler: (context) => { @@ -461,6 +466,55 @@ describe("ManifestCompiler", () => { expect(JSON.stringify(plan)).not.toContain("123456:raw-telegram-token"); }); + it("disables a channel when a reachability check opts to skip it", async () => { + const hooks = new MessagingHookRegistry([ + { + id: "common.tokenPaste", + handler: () => ({ + outputs: { + botToken: { + kind: "secret", + value: "123456:raw-telegram-token", + }, + }, + }), + }, + { + id: "common.configPrompt", + handler: () => ({}), + }, + { + id: "telegram.allowlistAliases", + handler: () => ({}), + }, + { + id: "telegram.getMeReachability", + handler: () => { + throw new Error("telegram is unreachable"); + }, + }, + ]); + const plan = await new ManifestCompiler( + createBuiltInChannelManifestRegistry(), + hooks, + ).compile({ + sandboxName: "demo", + agent: "openclaw", + workflow: "onboard", + isInteractive: false, + configuredChannels: ["telegram"], + }); + + expect(plan.channels[0]).toMatchObject({ + channelId: "telegram", + active: false, + selected: true, + configured: false, + disabled: true, + }); + expect(plan.disabledChannels).toEqual(["telegram"]); + }); + it("reads input values from env keys before returning non-interactive plans", async () => { await withEnv( { @@ -563,6 +617,7 @@ describe("ManifestCompiler", () => { expect(plan.healthChecks.map((entry) => entry.channelId)).toEqual(["telegram"]); expect(plan.channels[0]?.hooks.map((hook) => hook.id)).toEqual([ "telegram-token-paste", + "telegram-allowlist-aliases", "telegram-config-prompt", "telegram-get-me-reachability", ]); diff --git a/src/lib/messaging/compiler/manifest-compiler.ts b/src/lib/messaging/compiler/manifest-compiler.ts index 4a0d3149de..d220f2e939 100644 --- a/src/lib/messaging/compiler/manifest-compiler.ts +++ b/src/lib/messaging/compiler/manifest-compiler.ts @@ -227,7 +227,17 @@ async function resolveChannelInputs( .filter((entry) => isHookForAgent(entry, context.agent)) .filter((entry) => entry.phase === "reachability-check") .filter((entry) => hasDeclaredHookInputs(hookInputs, entry))) { - await runCompilerHook(manifest, hook, hooks, hookInputs, options.isInteractive); + const result = await runCompilerHook( + manifest, + hook, + hooks, + hookInputs, + options.isInteractive, + ); + if (!result) { + skipped = true; + break; + } } } diff --git a/src/lib/messaging/hooks/hook-runner.test.ts b/src/lib/messaging/hooks/hook-runner.test.ts index b06531591b..5ed30219dd 100644 --- a/src/lib/messaging/hooks/hook-runner.test.ts +++ b/src/lib/messaging/hooks/hook-runner.test.ts @@ -35,6 +35,7 @@ describe("MessagingHookRegistry", () => { expect(registry.listIds()).toEqual([ "common.tokenPaste", "common.configPrompt", + "telegram.allowlistAliases", "telegram.getMeReachability", "wechat.ilinkLogin", "wechat.seedOpenClawAccount", diff --git a/src/lib/onboard/messaging-channel-setup.test.ts b/src/lib/onboard/messaging-channel-setup.test.ts index a2b6db72dc..20461bf82a 100644 --- a/src/lib/onboard/messaging-channel-setup.test.ts +++ b/src/lib/onboard/messaging-channel-setup.test.ts @@ -97,7 +97,7 @@ describe("setupSelectedMessagingChannels", () => { ); }); - it("runs Telegram reachability once during interactive setup", async () => { + it("disables Telegram when reachability rejects the token during interactive setup", async () => { process.env.TELEGRAM_BOT_TOKEN = "123456:ABC-test-token"; process.env.TELEGRAM_REQUIRE_MENTION = "1"; process.env.TELEGRAM_ALLOWED_IDS = "123456789"; @@ -117,14 +117,17 @@ describe("setupSelectedMessagingChannels", () => { vi.spyOn(console, "log").mockImplementation((message = "") => { logs.push(String(message)); }); + const enabled = new Set(["telegram"]); - await setupSelectedMessagingChannels( + const plan = await setupSelectedMessagingChannels( ["telegram"], - new Set(["telegram"]), + enabled, manifests("telegram"), ); expect(fetchMock).toHaveBeenCalledOnce(); + expect(enabled.has("telegram")).toBe(false); + expect(plan?.channels[0]).toMatchObject({ channelId: "telegram", active: false }); expect( logs.filter((line) => line.includes("Bot token was rejected by Telegram")), ).toHaveLength(1); @@ -132,7 +135,9 @@ describe("setupSelectedMessagingChannels", () => { it("accepts Telegram allowlist aliases during manifest channel setup", async () => { process.env.TELEGRAM_BOT_TOKEN = "123456:ABC-test-token"; - process.env.TELEGRAM_CHAT_ID = "8388960805"; + process.env.TELEGRAM_ALLOWED_IDS = "8388960805"; + process.env.TELEGRAM_AUTHORIZED_CHAT_IDS = "8388960806"; + process.env.TELEGRAM_CHAT_ID = "8388960807"; process.env.TELEGRAM_REQUIRE_MENTION = "0"; const logs: string[] = []; vi.spyOn(console, "log").mockImplementation((message = "") => { @@ -145,9 +150,11 @@ describe("setupSelectedMessagingChannels", () => { manifests("telegram"), ); - expect(process.env.TELEGRAM_ALLOWED_IDS).toBe("8388960805"); + expect(process.env.TELEGRAM_ALLOWED_IDS).toBe("8388960805,8388960806,8388960807"); expect(prompt).not.toHaveBeenCalledWith(" Telegram User ID (for DM access): "); - expect(logs.join("\n")).toContain("telegram — allowed IDs already set: 8388960805"); + expect(logs.join("\n")).toContain( + "telegram — allowed IDs already set: 8388960805,8388960806,8388960807", + ); }); it("uses manifest token validation for Slack dual-token enrollment", async () => { diff --git a/src/lib/onboard/messaging-config.test.ts b/src/lib/onboard/messaging-config.test.ts index 2ed4c9be20..c38b882281 100644 --- a/src/lib/onboard/messaging-config.test.ts +++ b/src/lib/onboard/messaging-config.test.ts @@ -46,53 +46,6 @@ describe("onboard messaging config", () => { }); }); - it("uses Telegram allowlist aliases when the canonical env key is absent", () => { - const warn = vi.fn(); - - expect( - collectMessagingBuildConfig({ - channels: [{ name: "telegram", userIdEnvKey: "TELEGRAM_ALLOWED_IDS" }], - activeChannelNames: new Set(["telegram"]), - enabledTokenEnvKeys: new Set(), - env: { - TELEGRAM_AUTHORIZED_CHAT_IDS: "8388960805, 8388960806", - }, - discordSnowflakeRe: DISCORD_SNOWFLAKE_RE, - warn, - }), - ).toEqual({ - messagingAllowedIds: { - telegram: ["8388960805", "8388960806"], - }, - discordGuilds: {}, - slackConfig: {}, - }); - expect(warn).toHaveBeenCalledWith( - expect.stringContaining("TELEGRAM_AUTHORIZED_CHAT_IDS is treated as TELEGRAM_ALLOWED_IDS"), - ); - }); - - it("prefers TELEGRAM_ALLOWED_IDS over Telegram aliases", () => { - expect( - collectMessagingBuildConfig({ - channels: [{ name: "telegram", userIdEnvKey: "TELEGRAM_ALLOWED_IDS" }], - activeChannelNames: new Set(["telegram"]), - enabledTokenEnvKeys: new Set(), - env: { - TELEGRAM_ALLOWED_IDS: "canonical", - TELEGRAM_CHAT_ID: "alias", - }, - discordSnowflakeRe: DISCORD_SNOWFLAKE_RE, - }), - ).toEqual({ - messagingAllowedIds: { - telegram: ["canonical"], - }, - discordGuilds: {}, - slackConfig: {}, - }); - }); - it("collects Discord guild config and warns on malformed IDs", () => { const warn = vi.fn(); diff --git a/src/lib/onboard/messaging-config.ts b/src/lib/onboard/messaging-config.ts index 287d93a5d5..c18fba5cb1 100644 --- a/src/lib/onboard/messaging-config.ts +++ b/src/lib/onboard/messaging-config.ts @@ -65,11 +65,6 @@ export function collectMessagingBuildConfig({ if (activeChannelNames.has(ch.name) && ch.userIdEnvKey) { const resolved = resolveMessagingChannelConfigEnvValue(ch.userIdEnvKey, env); if (!resolved.value) continue; - if (resolved.sourceKey && resolved.sourceKey !== ch.userIdEnvKey) { - warn( - ` Warning: ${resolved.sourceKey} is treated as ${ch.userIdEnvKey} for ${ch.name} allowlisting; prefer ${ch.userIdEnvKey}.`, - ); - } const ids = parseMessagingConfigList(resolved.value); if (ids.length > 0) messagingAllowedIds[ch.name] = ids; } diff --git a/src/lib/onboard/telegram-reachability.test.ts b/src/lib/onboard/telegram-reachability.test.ts deleted file mode 100644 index d653970034..0000000000 --- a/src/lib/onboard/telegram-reachability.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 -// -// Unit tests for the Telegram reachability + token-validation probe. -// -// Covers the warn-and-skip behavior introduced for #4238: when api.telegram.org -// is unreachable or the bot token is rejected, onboarding should drop the -// optional Telegram integration and continue — not abort. Mirrors the Brave -// optional-component path at src/lib/onboard/web-search-flow.ts. - -import { beforeEach, describe, expect, it, vi } from "vitest"; - -import type { ProbeResult } from "./types"; - -vi.mock("../adapters/http/probe", () => ({ - runCurlProbe: vi.fn(), -})); - -import { runCurlProbe } from "../adapters/http/probe"; -import { checkTelegramReachability, type TelegramReachabilityDeps } from "./telegram-reachability"; - -function probeOk(): ProbeResult { - return { ok: true, httpStatus: 200, curlStatus: 0, body: '{"ok":true}', stderr: "", message: "" }; -} -function probeHttpError(httpStatus: number): ProbeResult { - return { ok: false, httpStatus, curlStatus: 0, body: "", stderr: "", message: "" }; -} -function probeCurlError(curlStatus: number): ProbeResult { - return { ok: false, httpStatus: 0, curlStatus, body: "", stderr: "", message: "" }; -} - -function makeDeps(overrides: Partial = {}): TelegramReachabilityDeps { - return { - isNonInteractive: vi.fn(() => true), - note: vi.fn(), - promptYesNoOrDefault: vi.fn(async () => true), - ...overrides, - }; -} - -beforeEach(() => { - vi.mocked(runCurlProbe).mockReset(); - delete process.env.NEMOCLAW_SKIP_TELEGRAM_REACHABILITY; -}); - -describe("checkTelegramReachability", () => { - it("returns { skipped: false } on HTTP 200 (token valid, network reachable)", async () => { - vi.mocked(runCurlProbe).mockReturnValue(probeOk()); - expect(await checkTelegramReachability("123:abc", makeDeps())).toEqual({ skipped: false }); - }); - - it("returns { skipped: true } when curl exits 35 (TLS handshake failure)", async () => { - vi.mocked(runCurlProbe).mockReturnValue(probeCurlError(35)); - expect(await checkTelegramReachability("123:abc", makeDeps())).toEqual({ skipped: true }); - }); - - it("returns { skipped: true } for every curl exit in TELEGRAM_NETWORK_CURL_CODES (non-interactive)", async () => { - for (const code of [6, 7, 28, 35, 52, 56]) { - vi.mocked(runCurlProbe).mockReturnValue(probeCurlError(code)); - expect( - await checkTelegramReachability("123:abc", makeDeps()), - `curlStatus=${code}`, - ).toEqual({ skipped: true }); - } - }); - - it("returns { skipped: true } on HTTP 401 (token rejected by Telegram)", async () => { - vi.mocked(runCurlProbe).mockReturnValue(probeHttpError(401)); - expect(await checkTelegramReachability("123:abc", makeDeps())).toEqual({ skipped: true }); - }); - - it("returns { skipped: true } on HTTP 404 (token rejected by Telegram)", async () => { - vi.mocked(runCurlProbe).mockReturnValue(probeHttpError(404)); - expect(await checkTelegramReachability("123:abc", makeDeps())).toEqual({ skipped: true }); - }); - - it("returns { skipped: false } and skips the probe when NEMOCLAW_SKIP_TELEGRAM_REACHABILITY=1", async () => { - process.env.NEMOCLAW_SKIP_TELEGRAM_REACHABILITY = "1"; - expect(await checkTelegramReachability("123:abc", makeDeps())).toEqual({ skipped: false }); - expect(vi.mocked(runCurlProbe)).not.toHaveBeenCalled(); - }); - - it("prompts 'Disable Telegram?' on interactive network failure and returns { skipped: true } when accepted", async () => { - vi.mocked(runCurlProbe).mockReturnValue(probeCurlError(7)); - const deps = makeDeps({ - isNonInteractive: vi.fn(() => false), - promptYesNoOrDefault: vi.fn(async () => true), - }); - const result = await checkTelegramReachability("123:abc", deps); - expect(result).toEqual({ skipped: true }); - expect(deps.promptYesNoOrDefault).toHaveBeenCalledWith( - expect.stringContaining("Disable Telegram"), - null, - true, - ); - }); - - it("calls process.exit(1) when interactive user declines the prompt", async () => { - vi.mocked(runCurlProbe).mockReturnValue(probeCurlError(7)); - const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { - throw new Error(`__test_exit_${code ?? 0}__`); - }) as never); - try { - const deps = makeDeps({ - isNonInteractive: vi.fn(() => false), - promptYesNoOrDefault: vi.fn(async () => false), - }); - await expect(checkTelegramReachability("123:abc", deps)).rejects.toThrow("__test_exit_1__"); - expect(exitSpy).toHaveBeenCalledWith(1); - } finally { - exitSpy.mockRestore(); - } - }); -}); diff --git a/src/lib/onboard/telegram-reachability.ts b/src/lib/onboard/telegram-reachability.ts deleted file mode 100644 index f8c910f4c6..0000000000 --- a/src/lib/onboard/telegram-reachability.ts +++ /dev/null @@ -1,112 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { runCurlProbe } from "../adapters/http/probe"; -import { cliName } from "./branding"; -import { exitOnboardFromPrompt } from "./prompt-helpers"; - -// Curl exit codes that indicate a network-level failure (not a token problem). -// 35 (TLS handshake failure) covers corporate proxies that MITM HTTPS. -export const TELEGRAM_NETWORK_CURL_CODES = new Set([6, 7, 28, 35, 52, 56]); - -export type TelegramReachabilityResult = { skipped: boolean }; - -export interface TelegramReachabilityDeps { - isNonInteractive(): boolean; - note(message: string): void; - promptYesNoOrDefault( - question: string, - envVar: string | null, - defaultIsYes: boolean, - ): Promise; -} - -function announceTelegramSkip(reason: "unreachable" | "invalid-token"): void { - const because = - reason === "unreachable" - ? "api.telegram.org is unreachable" - : "the bot token was rejected by Telegram"; - const recovery = - reason === "unreachable" - ? "once network access is restored" - : "after setting a valid TELEGRAM_BOT_TOKEN"; - console.warn(` Telegram integration will be disabled for this onboard run because ${because}.`); - console.warn( - ` Re-run onboarding (or \`${cliName()} channels add telegram\`) ${recovery}.`, - ); -} - -export async function checkTelegramReachability( - token: string, - deps: TelegramReachabilityDeps, -): Promise { - if (process.env.NEMOCLAW_SKIP_TELEGRAM_REACHABILITY === "1") { - deps.note(" [non-interactive] Skipping Telegram reachability probe by request."); - return { skipped: false }; - } - - const result = runCurlProbe([ - "-sS", - "--connect-timeout", - "5", - "--max-time", - "10", - `https://api.telegram.org/bot${token}/getMe`, - ]); - - // HTTP 200 with "ok":true — Telegram is reachable and token is valid. - if (result.ok) return { skipped: false }; - - // HTTP 401 or 404 — Telegram rejected the bot token. The integration cannot - // function with an invalid token, so this is "validation fails" per #4238 and - // takes the same warn-and-skip path as a network failure: drop telegram from - // the active messaging channel set instead of letting onboarding write an - // unusable token into the sandbox/provider config. - if (result.httpStatus === 401 || result.httpStatus === 404) { - console.log(""); - console.log(" ⚠ Bot token was rejected by Telegram — verify the token is correct."); - announceTelegramSkip("invalid-token"); - return { skipped: true }; - } - - // Network-level failure — Telegram is unreachable from this host. Treat as - // an optional-integration soft-fail (#4238): warn, drop telegram from the - // active messaging channel set, and let onboarding continue. Matches the - // warn-and-skip pattern Brave uses at src/lib/onboard/web-search-flow.ts. - if (result.curlStatus && TELEGRAM_NETWORK_CURL_CODES.has(result.curlStatus)) { - console.log(""); - console.log(" ⚠ api.telegram.org is not reachable from this host."); - console.log(" Telegram integration requires outbound HTTPS access to api.telegram.org."); - console.log(" This is commonly blocked by corporate network proxies."); - - if (deps.isNonInteractive()) { - announceTelegramSkip("unreachable"); - return { skipped: true }; - } - // Interactive: prompt explicitly asks whether to skip telegram and continue. - // Default Y favors the soft-fail path so an enter-press lines up with the - // optional-integration contract. An explicit N still aborts onboarding — - // the user opted out of both telegram and the workaround. - if ( - await deps.promptYesNoOrDefault( - " Disable Telegram for this run and continue?", - null, - true, - ) - ) { - announceTelegramSkip("unreachable"); - return { skipped: true }; - } - exitOnboardFromPrompt(); - } - - // Unexpected probe failure — warn but don't block. - if (!result.ok && result.httpStatus > 0) { - console.log( - ` ⚠ Telegram API returned HTTP ${result.httpStatus} — the bot may not work correctly.`, - ); - } else if (!result.ok) { - console.log(` ⚠ Telegram reachability probe failed: ${result.message}`); - } - return { skipped: false }; -} diff --git a/src/lib/sandbox/channels.ts b/src/lib/sandbox/channels.ts index b41a40c595..56350c876c 100644 --- a/src/lib/sandbox/channels.ts +++ b/src/lib/sandbox/channels.ts @@ -131,9 +131,6 @@ export const KNOWN_CHANNELS: Record = { help: "WhatsApp Web pairs via QR code scanned with your phone — no host-side token. After the sandbox is running, run `openshell term` and then use `openclaw channels login --channel whatsapp` for OpenClaw or `hermes whatsapp` for Hermes to display the QR.", label: "WhatsApp", loginMethod: "in-sandbox-qr", - setupNotes: [ - "After pairing, run `nemoclaw channels status --channel whatsapp` to confirm the bridge is delivering inbound messages — pairing alone does not guarantee inbound delivery (issue #4386).", - ], }, }; From 6f707db706de767f956e93260d0879d5b4aaec7a Mon Sep 17 00:00:00 2001 From: San Dang Date: Mon, 1 Jun 2026 18:17:24 +0700 Subject: [PATCH 35/40] fix(onboard): restore linux gateway cleanup guidance --- src/lib/onboard.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index a8a9d998d8..8c7d99ae40 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -1315,6 +1315,9 @@ function handleFinalGatewayStartFailure({ printError(` openshell gateway remove ${GATEWAY_NAME}`); printError(` # For OpenShell releases that still expose lifecycle commands:`); printError(` openshell gateway destroy -g ${GATEWAY_NAME}`); + if (process.platform === "linux") { + printError(" sudo pkill -f openshell-gateway # if a privileged host gateway process remains"); + } printError( ` docker volume ls -q --filter "name=openshell-cluster-${GATEWAY_NAME}" | xargs -r docker volume rm`, ); From 82c2854f97d265b5ecf338be957892d0b6dbf4a3 Mon Sep 17 00:00:00 2001 From: San Dang Date: Mon, 1 Jun 2026 18:22:23 +0700 Subject: [PATCH 36/40] fix(messaging): preserve rejected telegram enrollment --- src/lib/messaging/channels/manifests.test.ts | 10 +++-- .../hooks/get-me-reachability.test.ts | 37 ++++++++++++------- .../telegram/hooks/get-me-reachability.ts | 24 ++++++------ .../messaging/channels/telegram/manifest.ts | 2 +- .../compiler/manifest-compiler.test.ts | 30 +++++---------- .../onboard/messaging-channel-setup.test.ts | 6 +-- 6 files changed, 57 insertions(+), 52 deletions(-) diff --git a/src/lib/messaging/channels/manifests.test.ts b/src/lib/messaging/channels/manifests.test.ts index 7354141fa2..a2907d3c04 100644 --- a/src/lib/messaging/channels/manifests.test.ts +++ b/src/lib/messaging/channels/manifests.test.ts @@ -77,13 +77,17 @@ function expectConfigPromptEnrollHook( }); } -function expectReachabilityHook(manifest: ChannelManifest, inputIds: readonly string[]): void { +function expectReachabilityHook( + manifest: ChannelManifest, + inputIds: readonly string[], + onFailure: "abort" | "skip-channel", +): void { expect(manifest.hooks).toContainEqual({ id: `${manifest.id}-get-me-reachability`, phase: "reachability-check", handler: `${manifest.id}.getMeReachability`, inputs: inputIds, - onFailure: "skip-channel", + onFailure, }); } @@ -237,7 +241,7 @@ describe("built-in channel manifests", () => { ], }); expectConfigPromptEnrollHook(telegramManifest, ["requireMention", "allowedIds"]); - expectReachabilityHook(telegramManifest, ["botToken"]); + expectReachabilityHook(telegramManifest, ["botToken"], "abort"); }); it("declares Discord guild and allowlist render intent for both agents", () => { 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 922b535492..7e702e7e62 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 @@ -15,7 +15,7 @@ const TELEGRAM_REACHABILITY_HOOK = { phase: "reachability-check", handler: TELEGRAM_GET_ME_REACHABILITY_HOOK_ID, inputs: ["botToken"], - onFailure: "skip-channel", + onFailure: "abort", } as const satisfies ChannelHookSpec; describe("Telegram getMe reachability hook implementation", () => { @@ -58,7 +58,7 @@ describe("Telegram getMe reachability hook implementation", () => { expect(urls).toEqual(["https://telegram.test/bot123456:telegram-token/getMe"]); }); - it("fails so the compiler can skip the channel when Telegram rejects the token", async () => { + it("warns but keeps Telegram configured when Telegram rejects the token", async () => { const logs: string[] = []; const registry = new MessagingHookRegistry([ { @@ -86,17 +86,18 @@ describe("Telegram getMe reachability hook implementation", () => { botToken: "bad-token", }, }), - ).rejects.toThrow("Telegram bot token was rejected."); + ).resolves.toEqual({ + hookId: "telegram-reachability", + handlerId: TELEGRAM_GET_ME_REACHABILITY_HOOK_ID, + phase: "reachability-check", + outputs: {}, + }); expect(logs).toEqual([ " ⚠ Bot token was rejected by Telegram — verify the token is correct.", - [ - " Telegram integration will be disabled for this enrollment run because", - "the bot token was rejected by Telegram.", - ].join(" "), ]); }); - it("fails so the compiler can skip the channel when non-interactive Bot API requests fail", async () => { + it("aborts non-interactive enrollment when Bot API requests fail", async () => { const logs: string[] = []; const registry = new MessagingHookRegistry([ { @@ -117,12 +118,14 @@ describe("Telegram getMe reachability hook implementation", () => { botToken: "123456:telegram-token", }, }), - ).rejects.toThrow("Telegram reachability check failed: Bot API request failed."); + ).rejects.toThrow( + "Aborting onboarding in non-interactive mode due to Telegram network reachability failure.", + ); expect(logs).toEqual([ - [ - " Telegram integration will be disabled for this enrollment run because", - "api.telegram.org is unreachable.", - ].join(" "), + "", + " ⚠ api.telegram.org is not reachable from this host.", + " Telegram integration requires outbound HTTPS access to api.telegram.org.", + " This is commonly blocked by corporate network proxies.", ]); }); @@ -151,12 +154,16 @@ describe("Telegram getMe reachability hook implementation", () => { outputs: {}, }); expect(logs).toEqual([ - " ⚠ Telegram reachability check failed: Bot API request failed.", + "", + " ⚠ api.telegram.org is not reachable from this host.", + " Telegram integration requires outbound HTTPS access to api.telegram.org.", + " This is commonly blocked by corporate network proxies.", ]); }); it("honors the explicit skip env without calling Telegram", async () => { const urls: string[] = []; + const logs: string[] = []; const registry = new MessagingHookRegistry([ { id: TELEGRAM_GET_ME_REACHABILITY_HOOK_ID, @@ -164,6 +171,7 @@ describe("Telegram getMe reachability hook implementation", () => { env: { NEMOCLAW_SKIP_TELEGRAM_REACHABILITY: "1", }, + log: (message) => logs.push(message), fetch: async (url) => { urls.push(url); throw new Error("fetch should not run"); @@ -184,5 +192,6 @@ describe("Telegram getMe reachability hook implementation", () => { outputs: {}, }); expect(urls).toEqual([]); + expect(logs).toEqual([" [non-interactive] Skipping Telegram reachability probe by request."]); }); }); diff --git a/src/lib/messaging/channels/telegram/hooks/get-me-reachability.ts b/src/lib/messaging/channels/telegram/hooks/get-me-reachability.ts index 43448f6569..c4d63ac34b 100644 --- a/src/lib/messaging/channels/telegram/hooks/get-me-reachability.ts +++ b/src/lib/messaging/channels/telegram/hooks/get-me-reachability.ts @@ -40,7 +40,9 @@ export function createTelegramGetMeReachabilityHook( ): MessagingHookHandler { return async (context) => { const env = options.env ?? process.env; + const log = options.log ?? console.log; if (env.NEMOCLAW_SKIP_TELEGRAM_REACHABILITY === "1") { + log(" [non-interactive] Skipping Telegram reachability probe by request."); return {}; } @@ -50,23 +52,21 @@ export function createTelegramGetMeReachabilityHook( throw new Error("Telegram reachability check requires botToken."); } - const log = options.log ?? console.log; const isInteractive = context.isInteractive !== false; const response = await fetchTelegramGetMe(token, options).catch(() => { - const message = "Telegram reachability check failed: Bot API request failed."; + logTelegramNetworkFailure(log); if (!isInteractive) { - logTelegramDisabled("api.telegram.org is unreachable", log); - throw new Error(message); + throw new Error( + "Aborting onboarding in non-interactive mode due to Telegram network reachability failure.", + ); } - log(` ⚠ ${message}`); return null; }); if (!response) return {}; if (!response.ok) { if (isRejectedTokenResponse(response)) { logRejectedToken(log); - logTelegramDisabled("the bot token was rejected by Telegram", log); - throw new Error("Telegram bot token was rejected."); + return {}; } logTelegramHttpWarning(response, log); return {}; @@ -75,8 +75,7 @@ export function createTelegramGetMeReachabilityHook( const payload = await readTelegramJson(response); if (!isObject(payload) || payload.ok !== true) { logRejectedToken(log); - logTelegramDisabled("the bot token was rejected by Telegram", log); - throw new Error("Telegram bot token was rejected."); + return {}; } return {}; @@ -166,8 +165,11 @@ function logRejectedToken(log: (message: string) => void): void { log(" ⚠ Bot token was rejected by Telegram — verify the token is correct."); } -function logTelegramDisabled(reason: string, log: (message: string) => void): void { - log(` Telegram integration will be disabled for this enrollment run because ${reason}.`); +function logTelegramNetworkFailure(log: (message: string) => void): void { + log(""); + log(" ⚠ api.telegram.org is not reachable from this host."); + log(" Telegram integration requires outbound HTTPS access to api.telegram.org."); + log(" This is commonly blocked by corporate network proxies."); } function logTelegramHttpWarning( diff --git a/src/lib/messaging/channels/telegram/manifest.ts b/src/lib/messaging/channels/telegram/manifest.ts index d0af4167bd..2fc93efa37 100644 --- a/src/lib/messaging/channels/telegram/manifest.ts +++ b/src/lib/messaging/channels/telegram/manifest.ts @@ -181,7 +181,7 @@ export const telegramManifest = { phase: "reachability-check", handler: "telegram.getMeReachability", inputs: ["botToken"], - onFailure: "skip-channel", + 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 49d5423809..40b55db50c 100644 --- a/src/lib/messaging/compiler/manifest-compiler.test.ts +++ b/src/lib/messaging/compiler/manifest-compiler.test.ts @@ -466,7 +466,7 @@ describe("ManifestCompiler", () => { expect(JSON.stringify(plan)).not.toContain("123456:raw-telegram-token"); }); - it("disables a channel when a reachability check opts to skip it", async () => { + it("propagates aborting reachability check failures", async () => { const hooks = new MessagingHookRegistry([ { id: "common.tokenPaste", @@ -494,25 +494,15 @@ describe("ManifestCompiler", () => { }, }, ]); - const plan = await new ManifestCompiler( - createBuiltInChannelManifestRegistry(), - hooks, - ).compile({ - sandboxName: "demo", - agent: "openclaw", - workflow: "onboard", - isInteractive: false, - configuredChannels: ["telegram"], - }); - - expect(plan.channels[0]).toMatchObject({ - channelId: "telegram", - active: false, - selected: true, - configured: false, - disabled: true, - }); - expect(plan.disabledChannels).toEqual(["telegram"]); + await expect( + new ManifestCompiler(createBuiltInChannelManifestRegistry(), hooks).compile({ + sandboxName: "demo", + agent: "openclaw", + workflow: "onboard", + isInteractive: false, + configuredChannels: ["telegram"], + }), + ).rejects.toThrow("telegram is unreachable"); }); it("reads input values from env keys before returning non-interactive plans", async () => { diff --git a/src/lib/onboard/messaging-channel-setup.test.ts b/src/lib/onboard/messaging-channel-setup.test.ts index 20461bf82a..174ce2fb5f 100644 --- a/src/lib/onboard/messaging-channel-setup.test.ts +++ b/src/lib/onboard/messaging-channel-setup.test.ts @@ -97,7 +97,7 @@ describe("setupSelectedMessagingChannels", () => { ); }); - it("disables Telegram when reachability rejects the token during interactive setup", async () => { + it("keeps Telegram configured when reachability rejects the token during interactive setup", async () => { process.env.TELEGRAM_BOT_TOKEN = "123456:ABC-test-token"; process.env.TELEGRAM_REQUIRE_MENTION = "1"; process.env.TELEGRAM_ALLOWED_IDS = "123456789"; @@ -126,8 +126,8 @@ describe("setupSelectedMessagingChannels", () => { ); expect(fetchMock).toHaveBeenCalledOnce(); - expect(enabled.has("telegram")).toBe(false); - expect(plan?.channels[0]).toMatchObject({ channelId: "telegram", active: false }); + expect(enabled.has("telegram")).toBe(true); + expect(plan?.channels[0]).toMatchObject({ channelId: "telegram", active: true }); expect( logs.filter((line) => line.includes("Bot token was rejected by Telegram")), ).toHaveLength(1); From b88e110e62cedf47a6f2f5575ad976c6f049cf9f Mon Sep 17 00:00:00 2001 From: San Dang Date: Mon, 1 Jun 2026 18:41:19 +0700 Subject: [PATCH 37/40] fix(messaging): restore telegram skip for fake add-remove e2e --- src/lib/messaging/channels/manifests.test.ts | 10 ++--- .../hooks/get-me-reachability.test.ts | 37 +++++++------------ .../telegram/hooks/get-me-reachability.ts | 24 ++++++------ .../messaging/channels/telegram/manifest.ts | 2 +- .../compiler/manifest-compiler.test.ts | 30 ++++++++++----- .../onboard/messaging-channel-setup.test.ts | 6 +-- test/e2e/test-channels-add-remove.sh | 17 +++++++++ 7 files changed, 69 insertions(+), 57 deletions(-) diff --git a/src/lib/messaging/channels/manifests.test.ts b/src/lib/messaging/channels/manifests.test.ts index a2907d3c04..7354141fa2 100644 --- a/src/lib/messaging/channels/manifests.test.ts +++ b/src/lib/messaging/channels/manifests.test.ts @@ -77,17 +77,13 @@ function expectConfigPromptEnrollHook( }); } -function expectReachabilityHook( - manifest: ChannelManifest, - inputIds: readonly string[], - onFailure: "abort" | "skip-channel", -): void { +function expectReachabilityHook(manifest: ChannelManifest, inputIds: readonly string[]): void { expect(manifest.hooks).toContainEqual({ id: `${manifest.id}-get-me-reachability`, phase: "reachability-check", handler: `${manifest.id}.getMeReachability`, inputs: inputIds, - onFailure, + onFailure: "skip-channel", }); } @@ -241,7 +237,7 @@ describe("built-in channel manifests", () => { ], }); expectConfigPromptEnrollHook(telegramManifest, ["requireMention", "allowedIds"]); - expectReachabilityHook(telegramManifest, ["botToken"], "abort"); + expectReachabilityHook(telegramManifest, ["botToken"]); }); it("declares Discord guild and allowlist render intent for both agents", () => { 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 7e702e7e62..922b535492 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 @@ -15,7 +15,7 @@ const TELEGRAM_REACHABILITY_HOOK = { phase: "reachability-check", handler: TELEGRAM_GET_ME_REACHABILITY_HOOK_ID, inputs: ["botToken"], - onFailure: "abort", + onFailure: "skip-channel", } as const satisfies ChannelHookSpec; describe("Telegram getMe reachability hook implementation", () => { @@ -58,7 +58,7 @@ describe("Telegram getMe reachability hook implementation", () => { expect(urls).toEqual(["https://telegram.test/bot123456:telegram-token/getMe"]); }); - it("warns but keeps Telegram configured when Telegram rejects the token", async () => { + it("fails so the compiler can skip the channel when Telegram rejects the token", async () => { const logs: string[] = []; const registry = new MessagingHookRegistry([ { @@ -86,18 +86,17 @@ describe("Telegram getMe reachability hook implementation", () => { botToken: "bad-token", }, }), - ).resolves.toEqual({ - hookId: "telegram-reachability", - handlerId: TELEGRAM_GET_ME_REACHABILITY_HOOK_ID, - phase: "reachability-check", - outputs: {}, - }); + ).rejects.toThrow("Telegram bot token was rejected."); expect(logs).toEqual([ " ⚠ Bot token was rejected by Telegram — verify the token is correct.", + [ + " Telegram integration will be disabled for this enrollment run because", + "the bot token was rejected by Telegram.", + ].join(" "), ]); }); - it("aborts non-interactive enrollment when Bot API requests fail", async () => { + it("fails so the compiler can skip the channel when non-interactive Bot API requests fail", async () => { const logs: string[] = []; const registry = new MessagingHookRegistry([ { @@ -118,14 +117,12 @@ describe("Telegram getMe reachability hook implementation", () => { botToken: "123456:telegram-token", }, }), - ).rejects.toThrow( - "Aborting onboarding in non-interactive mode due to Telegram network reachability failure.", - ); + ).rejects.toThrow("Telegram reachability check failed: Bot API request failed."); expect(logs).toEqual([ - "", - " ⚠ api.telegram.org is not reachable from this host.", - " Telegram integration requires outbound HTTPS access to api.telegram.org.", - " This is commonly blocked by corporate network proxies.", + [ + " Telegram integration will be disabled for this enrollment run because", + "api.telegram.org is unreachable.", + ].join(" "), ]); }); @@ -154,16 +151,12 @@ describe("Telegram getMe reachability hook implementation", () => { outputs: {}, }); expect(logs).toEqual([ - "", - " ⚠ api.telegram.org is not reachable from this host.", - " Telegram integration requires outbound HTTPS access to api.telegram.org.", - " This is commonly blocked by corporate network proxies.", + " ⚠ Telegram reachability check failed: Bot API request failed.", ]); }); it("honors the explicit skip env without calling Telegram", async () => { const urls: string[] = []; - const logs: string[] = []; const registry = new MessagingHookRegistry([ { id: TELEGRAM_GET_ME_REACHABILITY_HOOK_ID, @@ -171,7 +164,6 @@ describe("Telegram getMe reachability hook implementation", () => { env: { NEMOCLAW_SKIP_TELEGRAM_REACHABILITY: "1", }, - log: (message) => logs.push(message), fetch: async (url) => { urls.push(url); throw new Error("fetch should not run"); @@ -192,6 +184,5 @@ describe("Telegram getMe reachability hook implementation", () => { outputs: {}, }); expect(urls).toEqual([]); - expect(logs).toEqual([" [non-interactive] Skipping Telegram reachability probe by request."]); }); }); diff --git a/src/lib/messaging/channels/telegram/hooks/get-me-reachability.ts b/src/lib/messaging/channels/telegram/hooks/get-me-reachability.ts index c4d63ac34b..43448f6569 100644 --- a/src/lib/messaging/channels/telegram/hooks/get-me-reachability.ts +++ b/src/lib/messaging/channels/telegram/hooks/get-me-reachability.ts @@ -40,9 +40,7 @@ export function createTelegramGetMeReachabilityHook( ): MessagingHookHandler { return async (context) => { const env = options.env ?? process.env; - const log = options.log ?? console.log; if (env.NEMOCLAW_SKIP_TELEGRAM_REACHABILITY === "1") { - log(" [non-interactive] Skipping Telegram reachability probe by request."); return {}; } @@ -52,21 +50,23 @@ export function createTelegramGetMeReachabilityHook( throw new Error("Telegram reachability check requires botToken."); } + const log = options.log ?? console.log; const isInteractive = context.isInteractive !== false; const response = await fetchTelegramGetMe(token, options).catch(() => { - logTelegramNetworkFailure(log); + const message = "Telegram reachability check failed: Bot API request failed."; if (!isInteractive) { - throw new Error( - "Aborting onboarding in non-interactive mode due to Telegram network reachability failure.", - ); + logTelegramDisabled("api.telegram.org is unreachable", log); + throw new Error(message); } + log(` ⚠ ${message}`); return null; }); if (!response) return {}; if (!response.ok) { if (isRejectedTokenResponse(response)) { logRejectedToken(log); - return {}; + logTelegramDisabled("the bot token was rejected by Telegram", log); + throw new Error("Telegram bot token was rejected."); } logTelegramHttpWarning(response, log); return {}; @@ -75,7 +75,8 @@ export function createTelegramGetMeReachabilityHook( const payload = await readTelegramJson(response); if (!isObject(payload) || payload.ok !== true) { logRejectedToken(log); - return {}; + logTelegramDisabled("the bot token was rejected by Telegram", log); + throw new Error("Telegram bot token was rejected."); } return {}; @@ -165,11 +166,8 @@ function logRejectedToken(log: (message: string) => void): void { log(" ⚠ Bot token was rejected by Telegram — verify the token is correct."); } -function logTelegramNetworkFailure(log: (message: string) => void): void { - log(""); - log(" ⚠ api.telegram.org is not reachable from this host."); - log(" Telegram integration requires outbound HTTPS access to api.telegram.org."); - log(" This is commonly blocked by corporate network proxies."); +function logTelegramDisabled(reason: string, log: (message: string) => void): void { + log(` Telegram integration will be disabled for this enrollment run because ${reason}.`); } function logTelegramHttpWarning( diff --git a/src/lib/messaging/channels/telegram/manifest.ts b/src/lib/messaging/channels/telegram/manifest.ts index 2fc93efa37..d0af4167bd 100644 --- a/src/lib/messaging/channels/telegram/manifest.ts +++ b/src/lib/messaging/channels/telegram/manifest.ts @@ -181,7 +181,7 @@ export const telegramManifest = { phase: "reachability-check", handler: "telegram.getMeReachability", inputs: ["botToken"], - onFailure: "abort", + onFailure: "skip-channel", }, ], } 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 40b55db50c..49d5423809 100644 --- a/src/lib/messaging/compiler/manifest-compiler.test.ts +++ b/src/lib/messaging/compiler/manifest-compiler.test.ts @@ -466,7 +466,7 @@ describe("ManifestCompiler", () => { expect(JSON.stringify(plan)).not.toContain("123456:raw-telegram-token"); }); - it("propagates aborting reachability check failures", async () => { + it("disables a channel when a reachability check opts to skip it", async () => { const hooks = new MessagingHookRegistry([ { id: "common.tokenPaste", @@ -494,15 +494,25 @@ describe("ManifestCompiler", () => { }, }, ]); - await expect( - new ManifestCompiler(createBuiltInChannelManifestRegistry(), hooks).compile({ - sandboxName: "demo", - agent: "openclaw", - workflow: "onboard", - isInteractive: false, - configuredChannels: ["telegram"], - }), - ).rejects.toThrow("telegram is unreachable"); + const plan = await new ManifestCompiler( + createBuiltInChannelManifestRegistry(), + hooks, + ).compile({ + sandboxName: "demo", + agent: "openclaw", + workflow: "onboard", + isInteractive: false, + configuredChannels: ["telegram"], + }); + + expect(plan.channels[0]).toMatchObject({ + channelId: "telegram", + active: false, + selected: true, + configured: false, + disabled: true, + }); + expect(plan.disabledChannels).toEqual(["telegram"]); }); it("reads input values from env keys before returning non-interactive plans", async () => { diff --git a/src/lib/onboard/messaging-channel-setup.test.ts b/src/lib/onboard/messaging-channel-setup.test.ts index 174ce2fb5f..20461bf82a 100644 --- a/src/lib/onboard/messaging-channel-setup.test.ts +++ b/src/lib/onboard/messaging-channel-setup.test.ts @@ -97,7 +97,7 @@ describe("setupSelectedMessagingChannels", () => { ); }); - it("keeps Telegram configured when reachability rejects the token during interactive setup", async () => { + it("disables Telegram when reachability rejects the token during interactive setup", async () => { process.env.TELEGRAM_BOT_TOKEN = "123456:ABC-test-token"; process.env.TELEGRAM_REQUIRE_MENTION = "1"; process.env.TELEGRAM_ALLOWED_IDS = "123456789"; @@ -126,8 +126,8 @@ describe("setupSelectedMessagingChannels", () => { ); expect(fetchMock).toHaveBeenCalledOnce(); - expect(enabled.has("telegram")).toBe(true); - expect(plan?.channels[0]).toMatchObject({ channelId: "telegram", active: true }); + expect(enabled.has("telegram")).toBe(false); + expect(plan?.channels[0]).toMatchObject({ channelId: "telegram", active: false }); expect( logs.filter((line) => line.includes("Bot token was rejected by Telegram")), ).toHaveLength(1); diff --git a/test/e2e/test-channels-add-remove.sh b/test/e2e/test-channels-add-remove.sh index 78b551ea7a..59837349fa 100755 --- a/test/e2e/test-channels-add-remove.sh +++ b/test/e2e/test-channels-add-remove.sh @@ -86,6 +86,22 @@ SANDBOX_NAME="${NEMOCLAW_SANDBOX_NAME:-e2e-channels-add-remove}" INSTALL_LOG="/tmp/nemoclaw-e2e-install.log" TELEGRAM_TOKEN="${TELEGRAM_BOT_TOKEN:-test-fake-telegram-token-add-remove-e2e}" +is_fake_telegram_token() { + case "${1:-}" in + *fake*) return 0 ;; + *) return 1 ;; + esac +} + +maybe_skip_telegram_reachability_for_fake_token() { + if [ -z "${NEMOCLAW_SKIP_TELEGRAM_REACHABILITY:-}" ] && is_fake_telegram_token "$TELEGRAM_TOKEN"; then + # This E2E normally uses a fake token to exercise add/remove plumbing, not + # the live Telegram API. Remove once the test has a hermetic fake Telegram API. + export NEMOCLAW_SKIP_TELEGRAM_REACHABILITY=1 + info "Skipping Telegram reachability probe for fake-token E2E" + fi +} + # shellcheck source=test/e2e/lib/sandbox-teardown.sh . "$(dirname "${BASH_SOURCE[0]}")/lib/sandbox-teardown.sh" register_sandbox_for_teardown "$SANDBOX_NAME" @@ -322,6 +338,7 @@ section "Phase 3: channels add telegram + rebuild" # Now provide the token — this mirrors the real user flow: after onboard, # the operator decides to add a channel and exports the token first. export TELEGRAM_BOT_TOKEN="$TELEGRAM_TOKEN" +maybe_skip_telegram_reachability_for_fake_token if nemoclaw "$SANDBOX_NAME" channels add telegram >/tmp/nc-add.log 2>&1; then add_rc=0 From 4017a4cb225c9080db25d03483359d5e0dab3c93 Mon Sep 17 00:00:00 2001 From: San Dang Date: Tue, 2 Jun 2026 18:07:22 +0700 Subject: [PATCH 38/40] refactor(messaging): move slack validation into hooks --- .../sandbox/slack-channel-validation.ts | 57 ---- .../messaging/applier/setup-applier.test.ts | 11 +- src/lib/messaging/channels/manifests.test.ts | 31 +-- .../hooks/credential-validation.test.ts} | 54 +--- .../slack/hooks/credential-validation.ts} | 34 +-- .../messaging/channels/slack/hooks/index.ts | 25 +- .../slack/hooks/token-paste-runtime.ts | 14 - .../channels/slack/hooks/token-paste.test.ts | 262 ------------------ .../channels/slack/hooks/token-paste.ts | 247 ----------------- .../slack/hooks/validate-credentials.test.ts | 177 ++++++++++++ .../slack/hooks/validate-credentials.ts | 63 +++++ src/lib/messaging/channels/slack/manifest.ts | 9 +- .../compiler/manifest-compiler.test.ts | 6 +- .../compiler/workflow-planner.test.ts | 25 +- .../hooks/common/token-paste.test.ts | 44 ++- src/lib/messaging/hooks/common/token-paste.ts | 37 ++- src/lib/messaging/hooks/hook-runner.test.ts | 2 +- .../onboard/messaging-channel-setup.test.ts | 28 +- test/channels-add-preset.test.ts | 11 +- 19 files changed, 391 insertions(+), 746 deletions(-) delete mode 100644 src/lib/actions/sandbox/slack-channel-validation.ts rename src/lib/{onboard/slack-validation.test.ts => messaging/channels/slack/hooks/credential-validation.test.ts} (76%) rename src/lib/{onboard/slack-validation.ts => messaging/channels/slack/hooks/credential-validation.ts} (84%) delete mode 100644 src/lib/messaging/channels/slack/hooks/token-paste-runtime.ts delete mode 100644 src/lib/messaging/channels/slack/hooks/token-paste.test.ts delete mode 100644 src/lib/messaging/channels/slack/hooks/token-paste.ts create mode 100644 src/lib/messaging/channels/slack/hooks/validate-credentials.test.ts create mode 100644 src/lib/messaging/channels/slack/hooks/validate-credentials.ts diff --git a/src/lib/actions/sandbox/slack-channel-validation.ts b/src/lib/actions/sandbox/slack-channel-validation.ts deleted file mode 100644 index 8502b9c4a2..0000000000 --- a/src/lib/actions/sandbox/slack-channel-validation.ts +++ /dev/null @@ -1,57 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import type { ChannelDef } from "../../sandbox/channels"; -import { - formatSlackValidationFailure, - validateSlackCredentials, -} from "../../onboard/slack-validation"; - -export type SlackChannelCredentialValidationResult = - | { ok: true; message?: string } - | { ok: false; message: string }; - -function isAcquiredTokenFormatValid(channel: ChannelDef, envKey: string, token: string): boolean { - if (envKey === channel.envKey) return !channel.tokenFormat || channel.tokenFormat.test(token); - if (envKey === channel.appTokenEnvKey) { - return !channel.appTokenFormat || channel.appTokenFormat.test(token); - } - return false; -} - -export function validateSlackChannelCredentials( - channel: ChannelDef, - acquired: Record, -): SlackChannelCredentialValidationResult { - if (!channel.envKey || !channel.appTokenEnvKey) { - return { ok: false, message: "Slack channel definition is missing required token keys." }; - } - - const botToken = acquired[channel.envKey]; - const appToken = acquired[channel.appTokenEnvKey]; - if (!botToken || !appToken) { - return { ok: false, message: "Slack requires both SLACK_BOT_TOKEN and SLACK_APP_TOKEN." }; - } - - for (const [envKey, token] of Object.entries(acquired)) { - if (!isAcquiredTokenFormatValid(channel, envKey, token)) { - const hint = - envKey === channel.appTokenEnvKey - ? channel.appTokenFormatHint || "Check the token and try again." - : channel.tokenFormatHint || "Check the token and try again."; - return { ok: false, message: `Invalid ${envKey} format. ${hint}` }; - } - } - - const validation = validateSlackCredentials({ botToken, appToken }); - if (validation.ok) { - return validation.skipped && validation.message - ? { ok: true, message: validation.message } - : { ok: true }; - } - - return { - ok: false, - message: `Slack credential validation failed. ${formatSlackValidationFailure(validation)}`, - }; -} diff --git a/src/lib/messaging/applier/setup-applier.test.ts b/src/lib/messaging/applier/setup-applier.test.ts index b89e87c60c..e795474b8e 100644 --- a/src/lib/messaging/applier/setup-applier.test.ts +++ b/src/lib/messaging/applier/setup-applier.test.ts @@ -69,10 +69,7 @@ function planner(): MessagingWorkflowPlanner { log: () => {}, }, slack: { - tokenPaste: { - getCredential: (key) => TEST_CREDENTIALS[key] ?? null, - saveCredential: () => {}, - prompt: async () => "unused", + validateCredentials: { log: () => {}, validateCredentials: () => ({ ok: true }), }, @@ -397,7 +394,11 @@ describe("MessagingSetupApplier", () => { MessagingSetupApplier.listHookRequests(plan).map( (request) => `${request.channelId}:${request.hookId}`, ), - ).toEqual(["slack:slack-token-paste", "slack:slack-config-prompt"]); + ).toEqual([ + "slack:slack-token-paste", + "slack:slack-config-prompt", + "slack:slack-credential-validation", + ]); const providerCalls: string[][] = []; const credentialResult = MessagingSetupApplier.applyCredentialsAtOpenShell(plan, { diff --git a/src/lib/messaging/channels/manifests.test.ts b/src/lib/messaging/channels/manifests.test.ts index 0c9f29733c..189be24cd1 100644 --- a/src/lib/messaging/channels/manifests.test.ts +++ b/src/lib/messaging/channels/manifests.test.ts @@ -14,7 +14,7 @@ import { COMMON_CONFIG_PROMPT_HOOK_HANDLER_ID, COMMON_TOKEN_PASTE_HOOK_HANDLER_ID, } from "../hooks/common"; -import { SLACK_TOKEN_PASTE_HOOK_HANDLER_ID } from "./slack/hooks"; +import { SLACK_VALIDATE_CREDENTIALS_HOOK_HANDLER_ID } from "./slack/hooks"; import { TELEGRAM_ALLOWLIST_ALIASES_HOOK_ID } from "./telegram/hooks"; import type { ChannelInputSpec, ChannelManifest, ChannelRenderSpec } from "../manifest"; import { @@ -63,20 +63,6 @@ function expectTokenPasteEnrollHook(manifest: ChannelManifest, outputIds: readon }); } -function expectSlackTokenPasteEnrollHook(outputIds: readonly string[]): void { - expect(slackManifest.hooks).toContainEqual({ - id: "slack-token-paste", - phase: "enroll", - handler: SLACK_TOKEN_PASTE_HOOK_HANDLER_ID, - outputs: outputIds.map((id) => ({ - id, - kind: "secret", - required: true, - })), - onFailure: "skip-channel", - }); -} - function expectConfigPromptEnrollHook( manifest: ChannelManifest, outputIds: readonly string[], @@ -102,6 +88,16 @@ function expectReachabilityHook(manifest: ChannelManifest, inputIds: readonly st }); } +function expectSlackCredentialValidationHook(inputIds: readonly string[]): void { + expect(slackManifest.hooks).toContainEqual({ + id: "slack-credential-validation", + phase: "reachability-check", + handler: SLACK_VALIDATE_CREDENTIALS_HOOK_HANDLER_ID, + inputs: inputIds, + onFailure: "skip-channel", + }); +} + describe("built-in channel manifests", () => { it("registers the phase-1 built-in manifests without consuming them in workflows", () => { const registry = createBuiltInChannelManifestRegistry(); @@ -142,7 +138,7 @@ describe("built-in channel manifests", () => { "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/slack/hooks/token-paste.ts", + "src/lib/messaging/channels/slack/hooks/validate-credentials.ts", "src/lib/messaging/channels/whatsapp/manifest.ts", "src/lib/messaging/hooks/common/config-prompt.ts", "src/lib/messaging/hooks/common/token-paste.ts", @@ -363,8 +359,9 @@ 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"); - expectSlackTokenPasteEnrollHook(["botToken", "appToken"]); + expectTokenPasteEnrollHook(slackManifest, ["botToken", "appToken"]); expectConfigPromptEnrollHook(slackManifest, ["allowedUsers", "allowedChannels"]); + expectSlackCredentialValidationHook(["botToken", "appToken"]); expect(slackManifest.state).toEqual({ persist: { allowedIds: ["allowedUsers"], diff --git a/src/lib/onboard/slack-validation.test.ts b/src/lib/messaging/channels/slack/hooks/credential-validation.test.ts similarity index 76% rename from src/lib/onboard/slack-validation.test.ts rename to src/lib/messaging/channels/slack/hooks/credential-validation.test.ts index 8be6024372..f30aa96636 100644 --- a/src/lib/onboard/slack-validation.test.ts +++ b/src/lib/messaging/channels/slack/hooks/credential-validation.test.ts @@ -5,22 +5,20 @@ import fs from "node:fs"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { ProbeResult } from "./types"; -import { KNOWN_CHANNELS } from "../sandbox/channels"; +import type { CurlProbeResult } from "../../../../adapters/http/probe"; -vi.mock("../adapters/http/probe", () => ({ +vi.mock("../../../../adapters/http/probe", () => ({ runCurlProbe: vi.fn(), })); -import { runCurlProbe } from "../adapters/http/probe"; +import { runCurlProbe } from "../../../../adapters/http/probe"; import { - filterSlackSelectionByValidation, validateSlackAppToken, validateSlackBotToken, validateSlackCredentials, -} from "./slack-validation"; +} from "./credential-validation"; -function probe(body: string, overrides: Partial = {}): ProbeResult { +function probe(body: string, overrides: Partial = {}): CurlProbeResult { return { ok: true, httpStatus: 200, @@ -189,46 +187,4 @@ describe("Slack token validation", () => { if (!result.ok) expect(result.message).not.toContain(token); }); - it("drops Slack selections when live validation is indeterminate", () => { - process.env.SLACK_BOT_TOKEN = "xoxb-timeout-bot"; - process.env.SLACK_APP_TOKEN = "xapp-timeout-app"; - vi.mocked(runCurlProbe).mockReturnValue( - probe("", { - ok: false, - httpStatus: 0, - curlStatus: 28, - stderr: "operation timed out", - message: "curl failed (exit 28): operation timed out", - }), - ); - const warnings: string[] = []; - - const result = filterSlackSelectionByValidation( - ["telegram", "slack"], - [KNOWN_CHANNELS.slack], - (message) => warnings.push(message), - ); - - expect(result).toEqual(["telegram"]); - expect(warnings.join("\n")).toContain("Slack integration will be disabled"); - expect(warnings.join("\n")).not.toContain("xoxb-timeout-bot"); - }); - - it("keeps Slack selected in explicit skip mode without probing Slack", () => { - process.env.SLACK_BOT_TOKEN = "xoxb-offline-bot"; - process.env.SLACK_APP_TOKEN = "xapp-offline-app"; - process.env.NEMOCLAW_SKIP_SLACK_AUTH_VALIDATION = "1"; - vi.mocked(runCurlProbe).mockReturnValue(probe('{"ok":false,"error":"invalid_auth"}')); - const warnings: string[] = []; - - const result = filterSlackSelectionByValidation( - ["telegram", "slack"], - [KNOWN_CHANNELS.slack], - (message) => warnings.push(message), - ); - - expect(result).toEqual(["telegram", "slack"]); - expect(vi.mocked(runCurlProbe)).not.toHaveBeenCalled(); - expect(warnings.join("\n")).toContain("Live Slack API validation skipped"); - }); }); diff --git a/src/lib/onboard/slack-validation.ts b/src/lib/messaging/channels/slack/hooks/credential-validation.ts similarity index 84% rename from src/lib/onboard/slack-validation.ts rename to src/lib/messaging/channels/slack/hooks/credential-validation.ts index 0158f2c0fe..65d75ad6c7 100644 --- a/src/lib/onboard/slack-validation.ts +++ b/src/lib/messaging/channels/slack/hooks/credential-validation.ts @@ -5,9 +5,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { runCurlProbe, type CurlProbeResult } from "../adapters/http/probe"; -import type { ChannelDef } from "../sandbox/channels"; -import { getValidatedMessagingTokenByEnvKey } from "./messaging-token"; +import { runCurlProbe, type CurlProbeResult } from "../../../../adapters/http/probe"; export type SlackTokenKind = "bot" | "app"; export type SlackValidationFailureKind = "rejected" | "indeterminate"; @@ -238,33 +236,3 @@ export function formatSlackValidationFailure( ): string { return result.message; } - -export function filterSlackSelectionByValidation( - found: string[], - channels: readonly ChannelDef[], - warn: (message: string) => void = console.warn, -): string[] { - if (!found.includes("slack")) return found; - - const botToken = getValidatedMessagingTokenByEnvKey(channels, "SLACK_BOT_TOKEN"); - const appToken = getValidatedMessagingTokenByEnvKey(channels, "SLACK_APP_TOKEN"); - if (!botToken || !appToken) { - warn( - " Slack integration will be disabled for this onboard run because both SLACK_BOT_TOKEN and SLACK_APP_TOKEN are required.", - ); - return found.filter((channel) => channel !== "slack"); - } - - const validation = validateSlackCredentials({ botToken, appToken }); - if (validation.ok) { - if (validation.skipped && validation.message) { - warn(` ${validation.message}`); - } - return found; - } - - warn( - ` Slack integration will be disabled for this onboard run. ${formatSlackValidationFailure(validation)}`, - ); - return found.filter((channel) => channel !== "slack"); -} diff --git a/src/lib/messaging/channels/slack/hooks/index.ts b/src/lib/messaging/channels/slack/hooks/index.ts index 4348a7c250..3047d914a5 100644 --- a/src/lib/messaging/channels/slack/hooks/index.ts +++ b/src/lib/messaging/channels/slack/hooks/index.ts @@ -2,33 +2,32 @@ // SPDX-License-Identifier: Apache-2.0 import type { MessagingHookRegistration } from "../../../hooks/types"; -import { createDefaultSlackTokenPasteOptions } from "./token-paste-runtime"; import { - createSlackTokenPasteHookRegistration, - type SlackTokenPasteHookOptions, -} from "./token-paste"; + createSlackValidateCredentialsHookRegistration, + type SlackValidateCredentialsHookOptions, +} from "./validate-credentials"; -export * from "./token-paste"; +export * from "./credential-validation"; +export * from "./validate-credentials"; export interface SlackHookOptions { - readonly tokenPaste?: SlackTokenPasteHookOptions; + readonly validateCredentials?: SlackValidateCredentialsHookOptions; } export function createSlackHookRegistrations( options: SlackHookOptions = {}, ): readonly MessagingHookRegistration[] { return [ - createSlackTokenPasteHookRegistration({ - ...createDefaultSlackTokenPasteOptions(), - ...withoutUndefinedValues(options.tokenPaste), - }), + createSlackValidateCredentialsHookRegistration( + withoutUndefinedValues(options.validateCredentials), + ), ] as const; } function withoutUndefinedValues( - options: SlackTokenPasteHookOptions | undefined, -): SlackTokenPasteHookOptions { + options: SlackValidateCredentialsHookOptions | undefined, +): SlackValidateCredentialsHookOptions { return Object.fromEntries( Object.entries(options ?? {}).filter(([, value]) => value !== undefined), - ) as SlackTokenPasteHookOptions; + ) as SlackValidateCredentialsHookOptions; } diff --git a/src/lib/messaging/channels/slack/hooks/token-paste-runtime.ts b/src/lib/messaging/channels/slack/hooks/token-paste-runtime.ts deleted file mode 100644 index d9aa22ee20..0000000000 --- a/src/lib/messaging/channels/slack/hooks/token-paste-runtime.ts +++ /dev/null @@ -1,14 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { getCredential, prompt, saveCredential } from "../../../../credentials/store"; -import type { SlackTokenPasteHookOptions } from "./token-paste"; - -export function createDefaultSlackTokenPasteOptions(): SlackTokenPasteHookOptions { - return { - getCredential, - saveCredential, - prompt, - log: (message) => console.log(message), - }; -} diff --git a/src/lib/messaging/channels/slack/hooks/token-paste.test.ts b/src/lib/messaging/channels/slack/hooks/token-paste.test.ts deleted file mode 100644 index 7e9f54e6f0..0000000000 --- a/src/lib/messaging/channels/slack/hooks/token-paste.test.ts +++ /dev/null @@ -1,262 +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 { runMessagingHook } from "../../../hooks/hook-runner"; -import { MessagingHookRegistry } from "../../../hooks/registry"; -import { slackManifest } from "../manifest"; -import { - createSlackTokenPasteHook, - SLACK_TOKEN_PASTE_HOOK_HANDLER_ID, - type SlackTokenPasteHookOptions, -} from "./token-paste"; - -function registry(options: SlackTokenPasteHookOptions): MessagingHookRegistry { - return new MessagingHookRegistry([ - { - id: SLACK_TOKEN_PASTE_HOOK_HANDLER_ID, - handler: createSlackTokenPasteHook({ - validateCredentials: () => ({ ok: true }), - ...options, - }), - }, - ]); -} - -function slackTokenHook() { - const hook = slackManifest.hooks[0]; - if (!hook) throw new Error("missing Slack token-paste hook"); - return hook; -} - -describe("Slack token-paste hook", () => { - it("uses the Slack-specific handler declared by the manifest", () => { - expect(slackManifest.hooks[0]?.handler).toBe(SLACK_TOKEN_PASTE_HOOK_HANDLER_ID); - }); - - it("shows the multi-token enrollment output shape", async () => { - await expect( - runMessagingHook( - slackTokenHook(), - registry({ - env: {}, - getCredential: (key) => - key === "SLACK_BOT_TOKEN" - ? "xoxb-test-slack-token" - : key === "SLACK_APP_TOKEN" - ? "xapp-test-slack-token" - : null, - saveCredential: () => {}, - log: () => {}, - }), - { channelId: "slack" }, - ), - ).resolves.toMatchObject({ - handlerId: SLACK_TOKEN_PASTE_HOOK_HANDLER_ID, - phase: "enroll", - outputs: { - botToken: { - kind: "secret", - value: "xoxb-test-slack-token", - }, - appToken: { - kind: "secret", - value: "xapp-test-slack-token", - }, - }, - }); - }); - - it("prompts only for missing token outputs and saves prompted credentials after validation", 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 }> = []; - - await expect( - runMessagingHook( - slackTokenHook(), - registry({ - 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"; - }, - }), - { 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_APP_TOKEN", value: "xapp-prompted" }]); - expect(env.SLACK_APP_TOKEN).toBe("xapp-prompted"); - }); - - it("reprompts in interactive mode when an existing token has invalid format", async () => { - const env: NodeJS.ProcessEnv = { - SLACK_BOT_TOKEN: "not-a-slack-token", - SLACK_APP_TOKEN: "xapp-existing", - }; - const logs: string[] = []; - const prompts: Array<{ readonly question: string; readonly secret: boolean }> = []; - const saved: Array<{ readonly key: string; readonly value: string }> = []; - - await expect( - runMessagingHook( - slackTokenHook(), - registry({ - env, - getCredential: () => null, - saveCredential: (key, value) => saved.push({ key, value }), - log: (message) => logs.push(message), - prompt: async (question, options) => { - prompts.push({ question, secret: options?.secret === true }); - return "xoxb-recovered-token"; - }, - }), - { channelId: "slack", isInteractive: true }, - ), - ).resolves.toMatchObject({ - outputs: { - botToken: { - kind: "secret", - value: "xoxb-recovered-token", - }, - appToken: { - kind: "secret", - value: "xapp-existing", - }, - }, - }); - expect(prompts).toEqual([ - { - question: " Slack Bot Token: ", - secret: true, - }, - ]); - expect(saved).toEqual([{ key: "SLACK_BOT_TOKEN", value: "xoxb-recovered-token" }]); - expect(env.SLACK_BOT_TOKEN).toBe("xoxb-recovered-token"); - expect(logs.join("\n")).toContain("Slack bot tokens start with 'xoxb-'"); - expect(logs.join("\n")).toContain("Invalid existing slack token ignored"); - expect(logs.join("\n")).not.toContain("Skipped slack (invalid token format)"); - }); - - it("skips in non-interactive mode when an existing token has invalid format", async () => { - const logs: string[] = []; - const saved: Array<{ readonly key: string; readonly value: string }> = []; - - await expect( - runMessagingHook( - slackTokenHook(), - registry({ - env: { - SLACK_BOT_TOKEN: "not-a-slack-token", - SLACK_APP_TOKEN: "xapp-existing", - }, - getCredential: () => null, - saveCredential: (key, value) => saved.push({ key, value }), - log: (message) => logs.push(message), - prompt: async () => { - throw new Error("non-interactive enrollment should not prompt"); - }, - }), - { channelId: "slack", isInteractive: false }, - ), - ).rejects.toThrow("Invalid token format for SLACK_BOT_TOKEN"); - expect(saved).toEqual([]); - expect(logs.join("\n")).toContain("Slack bot tokens start with 'xoxb-'"); - expect(logs.join("\n")).toContain("Skipped slack (invalid token format)"); - }); - - it("does not save prompted credentials when Slack API rejects them", async () => { - const env: NodeJS.ProcessEnv = {}; - const saved: Array<{ readonly key: string; readonly value: string }> = []; - const logs: string[] = []; - const prompts = ["xoxb-fake-bot-token", "xapp-fake-app-token"]; - - await expect( - runMessagingHook( - slackTokenHook(), - registry({ - env, - getCredential: () => null, - saveCredential: (key, value) => saved.push({ key, value }), - log: (message) => logs.push(message), - prompt: async () => prompts.shift() ?? "", - validateCredentials: () => ({ - ok: false, - kind: "rejected", - tokenKind: "app", - credential: "app", - error: "invalid_auth", - httpStatus: 200, - curlStatus: 0, - message: "Slack app token was rejected by Slack API: invalid_auth.", - }), - }), - { channelId: "slack", isInteractive: true }, - ), - ).rejects.toThrow("Slack credential validation failed"); - expect(saved).toEqual([]); - expect(env.SLACK_BOT_TOKEN).toBeUndefined(); - expect(env.SLACK_APP_TOKEN).toBeUndefined(); - expect(logs.join("\n")).toContain("Slack app token was rejected by Slack API"); - expect(logs.join("\n")).not.toContain("xoxb-fake-bot-token"); - expect(logs.join("\n")).not.toContain("xapp-fake-app-token"); - }); - - it("ignores existing Slack tokens that pass format but fail Slack API validation", async () => { - const logs: string[] = []; - const saved: Array<{ readonly key: string; readonly value: string }> = []; - - await expect( - runMessagingHook( - slackTokenHook(), - registry({ - env: { - SLACK_BOT_TOKEN: "xoxb-existing-invalid", - SLACK_APP_TOKEN: "xapp-existing-valid", - }, - getCredential: () => null, - saveCredential: (key, value) => saved.push({ key, value }), - log: (message) => logs.push(message), - validateCredentials: () => ({ - ok: false, - kind: "rejected", - tokenKind: "bot", - credential: "bot", - error: "token_revoked", - httpStatus: 200, - curlStatus: 0, - message: "Slack bot token was rejected by Slack API: token_revoked.", - }), - }), - { channelId: "slack", isInteractive: true }, - ), - ).rejects.toThrow("Slack credential validation failed"); - expect(saved).toEqual([]); - expect(logs.join("\n")).toContain("Invalid existing slack token ignored"); - expect(logs.join("\n")).toContain("token_revoked"); - expect(logs.join("\n")).not.toContain("slack — already configured"); - }); -}); diff --git a/src/lib/messaging/channels/slack/hooks/token-paste.ts b/src/lib/messaging/channels/slack/hooks/token-paste.ts deleted file mode 100644 index d74872bb0e..0000000000 --- a/src/lib/messaging/channels/slack/hooks/token-paste.ts +++ /dev/null @@ -1,247 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { - formatSlackValidationFailure, - type SlackTokenKind, - validateSlackCredentials, -} from "../../../../onboard/slack-validation"; -import type { MessagingHookHandler, MessagingHookRegistration } from "../../../hooks/types"; -import type { - ChannelHookOutputSpec, - ChannelSecretInputSpec, - MessagingSerializableValue, -} from "../../../manifest"; -import { slackManifest } from "../manifest"; - -export const SLACK_TOKEN_PASTE_HOOK_HANDLER_ID = "slack.tokenPaste"; - -export interface SlackTokenPasteHookOptions { - 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 validateCredentials?: typeof validateSlackCredentials; - readonly formatValidationFailure?: typeof formatSlackValidationFailure; -} - -interface SlackSecretField { - readonly id: string; - readonly envKey: string; - readonly label: string; - readonly help?: string; - readonly format?: RegExp; - readonly formatHint?: string; - readonly kind: SlackTokenKind; -} - -interface CollectedSlackToken { - readonly field: SlackSecretField; - readonly token: string; - readonly source: "existing" | "prompted"; -} - -export function createSlackTokenPasteHook( - options: SlackTokenPasteHookOptions = {}, -): MessagingHookHandler { - return async (context) => { - const declarations = (context.outputDeclarations ?? []).filter( - (output) => output.kind === "secret", - ); - const collected: CollectedSlackToken[] = []; - for (const output of declarations) { - const field = resolveSlackSecretField(output); - if (!field) throw new Error(`No Slack token field registered for ${output.id}`); - collected.push( - await collectSlackToken(field, options, context.isInteractive !== false), - ); - } - - const byId = new Map(collected.map((entry) => [entry.field.id, entry])); - const bot = byId.get("botToken"); - const app = byId.get("appToken"); - if (!bot || !app) { - throw new Error("Slack requires both SLACK_BOT_TOKEN and SLACK_APP_TOKEN."); - } - - validateCollectedSlackTokens(bot, app, options); - persistCollectedSlackTokens(collected, options); - - return { - outputs: Object.fromEntries( - collected.map((entry) => [ - entry.field.id, - { - kind: "secret", - value: entry.token as MessagingSerializableValue, - }, - ]), - ), - }; - }; -} - -export function createSlackTokenPasteHookRegistration( - options: SlackTokenPasteHookOptions = {}, -): MessagingHookRegistration { - return { - id: SLACK_TOKEN_PASTE_HOOK_HANDLER_ID, - handler: createSlackTokenPasteHook(options), - }; -} - -async function collectSlackToken( - field: SlackSecretField, - options: SlackTokenPasteHookOptions, - isInteractive: boolean, -): Promise { - const env = options.env ?? process.env; - const readCredential = options.getCredential ?? (() => null); - const prompt = options.prompt ?? missingSlackPrompt; - const log = options.log ?? console.log; - - let token = normalizeCredentialValue(env[field.envKey]) || readCredential(field.envKey) || ""; - let source: "existing" | "prompted" = "existing"; - if (token && field.format && !field.format.test(token)) { - log(` ✗ Invalid format. ${field.formatHint || "Check the token and try again."}`); - if (!isInteractive) { - log(formatSkippedInvalidTokenMessage(field)); - throw new Error( - `Invalid token format for ${field.envKey}. ${ - field.formatHint || "Check the token and try again." - }`, - ); - } - log(` ✗ Invalid existing slack ${tokenNoun(field)} ignored.`); - token = ""; - } - - if (!token) { - if (!isInteractive) { - log(formatSkippedNoTokenMessage(field)); - throw new Error(`No token entered for ${field.envKey}.`); - } - if (field.help) { - log(""); - log(` ${field.help}`); - } - token = normalizeCredentialValue(await prompt(` ${field.label}: `, { secret: true })); - source = "prompted"; - } - - if (!token) { - log(formatSkippedNoTokenMessage(field)); - throw new Error(`No token entered for ${field.envKey}.`); - } - - if (field.format && !field.format.test(token)) { - log(` ✗ Invalid format. ${field.formatHint || "Check the token and try again."}`); - log(formatSkippedInvalidTokenMessage(field)); - throw new Error( - `Invalid token format for ${field.envKey}. ${ - field.formatHint || "Check the token and try again." - }`, - ); - } - - return { field, token, source }; -} - -function validateCollectedSlackTokens( - bot: CollectedSlackToken, - app: CollectedSlackToken, - options: SlackTokenPasteHookOptions, -): void { - const validate = options.validateCredentials ?? validateSlackCredentials; - const validation = validate({ botToken: bot.token, appToken: app.token }); - if (validation.ok) { - if (validation.skipped && validation.message) { - (options.log ?? console.log)(` ⚠ ${validation.message}`); - } - return; - } - - const log = options.log ?? console.log; - const failing = validation.credential === "bot" ? bot : app; - if (failing.source === "existing") { - log(` ✗ Invalid existing slack ${tokenNoun(failing.field)} ignored.`); - } - const formatFailure = options.formatValidationFailure ?? formatSlackValidationFailure; - const prefix = validation.kind === "rejected" ? "✗" : "⚠"; - log(` ${prefix} ${formatFailure(validation)}`); - log( - ` Skipped slack (${ - validation.kind === "rejected" - ? "invalid Slack credentials" - : "Slack API validation unavailable" - })`, - ); - throw new Error(`Slack credential validation failed: ${formatFailure(validation)}`); -} - -function persistCollectedSlackTokens( - collected: readonly CollectedSlackToken[], - options: SlackTokenPasteHookOptions, -): void { - const env = options.env ?? process.env; - const writeCredential = options.saveCredential ?? (() => {}); - const log = options.log ?? console.log; - - for (const entry of collected) { - env[entry.field.envKey] = entry.token; - if (entry.source === "prompted") { - writeCredential(entry.field.envKey, entry.token); - log(` ✓ slack ${tokenNoun(entry.field)} saved`); - } else { - log( - entry.field.id === "botToken" - ? " ✓ slack — already configured" - : ` ✓ slack ${tokenNoun(entry.field)} — already configured`, - ); - } - } -} - -function resolveSlackSecretField(output: ChannelHookOutputSpec): SlackSecretField | null { - const input = slackManifest.inputs.find( - (entry) => entry.kind === "secret" && entry.id === output.id, - ) as ChannelSecretInputSpec | undefined; - if (!input?.envKey) return null; - return { - id: input.id, - envKey: input.envKey, - label: input.prompt?.label ?? input.envKey, - help: input.prompt?.help, - format: input.formatPattern ? new RegExp(input.formatPattern) : undefined, - formatHint: input.formatHint, - kind: input.id === "appToken" ? "app" : "bot", - }; -} - -async function missingSlackPrompt(): Promise { - throw new Error("Slack token-paste hook requires an injected prompt implementation."); -} - -function normalizeCredentialValue(value: string | null | undefined): string { - if (typeof value !== "string") return ""; - return value.replace(/\r/g, "").trim(); -} - -function tokenNoun(field: SlackSecretField): string { - return field.id === "appToken" ? "app token" : "token"; -} - -function formatSkippedNoTokenMessage(field: SlackSecretField): string { - if (field.id === "appToken") { - return " Skipped slack app token (Socket Mode requires both tokens)"; - } - return " Skipped slack (no token entered)"; -} - -function formatSkippedInvalidTokenMessage(field: SlackSecretField): string { - if (field.id === "appToken") { - return " Skipped slack app token (invalid token format)"; - } - return " Skipped slack (invalid token format)"; -} diff --git a/src/lib/messaging/channels/slack/hooks/validate-credentials.test.ts b/src/lib/messaging/channels/slack/hooks/validate-credentials.test.ts new file mode 100644 index 0000000000..773b3ce52c --- /dev/null +++ b/src/lib/messaging/channels/slack/hooks/validate-credentials.test.ts @@ -0,0 +1,177 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { runMessagingHook } from "../../../hooks/hook-runner"; +import { MessagingHookRegistry } from "../../../hooks/registry"; +import { slackManifest } from "../manifest"; +import { + createSlackValidateCredentialsHook, + SLACK_VALIDATE_CREDENTIALS_HOOK_HANDLER_ID, + type SlackValidateCredentialsHookOptions, +} from "./validate-credentials"; + +function registry(options: SlackValidateCredentialsHookOptions): MessagingHookRegistry { + return new MessagingHookRegistry([ + { + id: SLACK_VALIDATE_CREDENTIALS_HOOK_HANDLER_ID, + handler: createSlackValidateCredentialsHook(options), + }, + ]); +} + +function slackValidationHook() { + const hook = slackManifest.hooks.find((entry) => entry.id === "slack-credential-validation"); + if (!hook) throw new Error("missing Slack credential validation hook"); + return hook; +} + +describe("Slack credential validation hook", () => { + it("uses the Slack-specific reachability handler declared by the manifest", () => { + expect(slackValidationHook()).toMatchObject({ + phase: "reachability-check", + handler: SLACK_VALIDATE_CREDENTIALS_HOOK_HANDLER_ID, + inputs: ["botToken", "appToken"], + onFailure: "skip-channel", + }); + }); + + it("validates the collected bot and app tokens without exposing them in outputs", async () => { + const validated: Array<{ readonly botToken: string; readonly appToken: string }> = []; + + await expect( + runMessagingHook( + slackValidationHook(), + registry({ + validateCredentials: (tokens) => { + validated.push(tokens); + return { ok: true }; + }, + log: () => {}, + }), + { + channelId: "slack", + inputs: { + botToken: "xoxb-test-slack-token", + appToken: "xapp-test-slack-token", + }, + }, + ), + ).resolves.toMatchObject({ + handlerId: SLACK_VALIDATE_CREDENTIALS_HOOK_HANDLER_ID, + phase: "reachability-check", + outputs: {}, + }); + expect(validated).toEqual([ + { + botToken: "xoxb-test-slack-token", + appToken: "xapp-test-slack-token", + }, + ]); + }); + + it("logs skip-env validation warnings without failing the hook", async () => { + const logs: string[] = []; + + await runMessagingHook( + slackValidationHook(), + registry({ + validateCredentials: () => ({ + ok: true, + skipped: true, + message: "Live Slack API validation skipped because test.", + }), + log: (message) => logs.push(message), + }), + { + channelId: "slack", + inputs: { + botToken: "xoxb-test-slack-token", + appToken: "xapp-test-slack-token", + }, + }, + ); + + expect(logs.join("\n")).toContain("Live Slack API validation skipped because test."); + }); + + it("skips Slack when the Slack API rejects a credential", async () => { + const logs: string[] = []; + + await expect( + runMessagingHook( + slackValidationHook(), + registry({ + validateCredentials: () => ({ + ok: false, + kind: "rejected", + tokenKind: "app", + credential: "app", + error: "invalid_auth", + httpStatus: 200, + curlStatus: 0, + message: "Slack app token was rejected by Slack API: invalid_auth.", + }), + log: (message) => logs.push(message), + }), + { + channelId: "slack", + inputs: { + botToken: "xoxb-fake-bot-token", + appToken: "xapp-fake-app-token", + }, + }, + ), + ).rejects.toThrow("Slack credential validation failed"); + expect(logs.join("\n")).toContain("Slack app token was rejected by Slack API"); + expect(logs.join("\n")).toContain("Skipped slack (invalid Slack credentials)"); + expect(logs.join("\n")).not.toContain("xoxb-fake-bot-token"); + expect(logs.join("\n")).not.toContain("xapp-fake-app-token"); + }); + + it("skips Slack when Slack API validation is unavailable", async () => { + const logs: string[] = []; + + await expect( + runMessagingHook( + slackValidationHook(), + registry({ + validateCredentials: () => ({ + ok: false, + kind: "indeterminate", + tokenKind: "bot", + credential: "bot", + httpStatus: 0, + curlStatus: 7, + message: "Slack bot token could not be validated because Slack API was unreachable.", + }), + log: (message) => logs.push(message), + }), + { + channelId: "slack", + inputs: { + botToken: "xoxb-fake-bot-token", + appToken: "xapp-fake-app-token", + }, + }, + ), + ).rejects.toThrow("Slack credential validation failed"); + expect(logs.join("\n")).toContain("Slack API validation unavailable"); + }); + + it("requires both Slack hook inputs", async () => { + await expect( + runMessagingHook( + slackValidationHook(), + registry({ validateCredentials: () => ({ ok: true }) }), + { + channelId: "slack", + inputs: { + botToken: "xoxb-test-slack-token", + }, + }, + ), + ).rejects.toThrow("Slack credential validation requires botToken and appToken"); + }); +}); diff --git a/src/lib/messaging/channels/slack/hooks/validate-credentials.ts b/src/lib/messaging/channels/slack/hooks/validate-credentials.ts new file mode 100644 index 0000000000..13a6f054b5 --- /dev/null +++ b/src/lib/messaging/channels/slack/hooks/validate-credentials.ts @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + formatSlackValidationFailure, + validateSlackCredentials, +} from "./credential-validation"; +import type { MessagingHookHandler, MessagingHookRegistration } from "../../../hooks/types"; + +export const SLACK_VALIDATE_CREDENTIALS_HOOK_HANDLER_ID = "slack.validateCredentials"; + +export interface SlackValidateCredentialsHookOptions { + readonly validateCredentials?: typeof validateSlackCredentials; + readonly formatValidationFailure?: typeof formatSlackValidationFailure; + readonly log?: (message: string) => void; +} + +export function createSlackValidateCredentialsHook( + options: SlackValidateCredentialsHookOptions = {}, +): MessagingHookHandler { + return async (context) => { + const botToken = normalizeHookToken(context.inputs?.botToken); + const appToken = normalizeHookToken(context.inputs?.appToken); + if (!botToken || !appToken) { + throw new Error("Slack credential validation requires botToken and appToken."); + } + + const validate = options.validateCredentials ?? validateSlackCredentials; + const validation = validate({ botToken, appToken }); + if (validation.ok) { + if (validation.skipped && validation.message) { + (options.log ?? console.log)(` ⚠ ${validation.message}`); + } + return {}; + } + + const log = options.log ?? console.log; + const formatFailure = options.formatValidationFailure ?? formatSlackValidationFailure; + const prefix = validation.kind === "rejected" ? "✗" : "⚠"; + log(` ${prefix} ${formatFailure(validation)}`); + log( + ` Skipped slack (${ + validation.kind === "rejected" + ? "invalid Slack credentials" + : "Slack API validation unavailable" + })`, + ); + throw new Error(`Slack credential validation failed: ${formatFailure(validation)}`); + }; +} + +export function createSlackValidateCredentialsHookRegistration( + options: SlackValidateCredentialsHookOptions = {}, +): MessagingHookRegistration { + return { + id: SLACK_VALIDATE_CREDENTIALS_HOOK_HANDLER_ID, + handler: createSlackValidateCredentialsHook(options), + }; +} + +function normalizeHookToken(value: unknown): string { + return typeof value === "string" ? value.replace(/\r/g, "").trim() : ""; +} diff --git a/src/lib/messaging/channels/slack/manifest.ts b/src/lib/messaging/channels/slack/manifest.ts index 6bfc10afe1..f0564c69aa 100644 --- a/src/lib/messaging/channels/slack/manifest.ts +++ b/src/lib/messaging/channels/slack/manifest.ts @@ -135,7 +135,7 @@ export const slackManifest = { { id: "slack-token-paste", phase: "enroll", - handler: "slack.tokenPaste", + handler: "common.tokenPaste", outputs: [ { id: "botToken", @@ -165,5 +165,12 @@ export const slackManifest = { }, ], }, + { + id: "slack-credential-validation", + phase: "reachability-check", + handler: "slack.validateCredentials", + inputs: ["botToken", "appToken"], + onFailure: "skip-channel", + }, ], } 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 aec166bcf3..82b5bf8c23 100644 --- a/src/lib/messaging/compiler/manifest-compiler.test.ts +++ b/src/lib/messaging/compiler/manifest-compiler.test.ts @@ -39,11 +39,7 @@ function compiler(): ManifestCompiler { log: () => {}, }, slack: { - tokenPaste: { - env: {}, - getCredential: (key) => TEST_CREDENTIALS[key] ?? null, - saveCredential: () => {}, - prompt: async () => "", + validateCredentials: { log: () => {}, validateCredentials: () => ({ ok: true }), }, diff --git a/src/lib/messaging/compiler/workflow-planner.test.ts b/src/lib/messaging/compiler/workflow-planner.test.ts index b85925753e..c7bb68bcd8 100644 --- a/src/lib/messaging/compiler/workflow-planner.test.ts +++ b/src/lib/messaging/compiler/workflow-planner.test.ts @@ -33,11 +33,7 @@ function planner(): MessagingWorkflowPlanner { log: () => {}, }, slack: { - tokenPaste: { - env: {}, - getCredential: (key) => TEST_CREDENTIALS[key] ?? null, - saveCredential: () => {}, - prompt: async () => "unused", + validateCredentials: { log: () => {}, validateCredentials: () => ({ ok: true }), }, @@ -208,25 +204,14 @@ describe("MessagingWorkflowPlanner", () => { return { outputs }; }, }, - { - id: "slack.tokenPaste", - handler: (context) => { - const outputs: Record = {}; - for (const output of context.outputDeclarations ?? []) { - if (output.kind === "secret") { - outputs[output.id] = { - kind: "secret", - value: `test-${context.channelId}-${output.id}`, - }; - } - } - return { outputs }; - }, - }, { id: "common.configPrompt", handler: () => ({ outputs: {} }), }, + { + id: "slack.validateCredentials", + handler: () => ({}), + }, ]); const plan = await new MessagingWorkflowPlanner( createBuiltInChannelManifestRegistry(), diff --git a/src/lib/messaging/hooks/common/token-paste.test.ts b/src/lib/messaging/hooks/common/token-paste.test.ts index a97aeb466b..67ec979214 100644 --- a/src/lib/messaging/hooks/common/token-paste.test.ts +++ b/src/lib/messaging/hooks/common/token-paste.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; -import { discordManifest, telegramManifest } from "../../channels"; +import { discordManifest, slackManifest, telegramManifest } from "../../channels"; import { runMessagingHook } from "../hook-runner"; import { MessagingHookRegistry } from "../registry"; import { @@ -21,6 +21,7 @@ describe("common token-paste hook implementation", () => { ]); expect(telegramManifest.hooks[0]?.handler).toBe(COMMON_TOKEN_PASTE_HOOK_HANDLER_ID); expect(discordManifest.hooks[0]?.handler).toBe(COMMON_TOKEN_PASTE_HOOK_HANDLER_ID); + expect(slackManifest.hooks[0]?.handler).toBe(COMMON_TOKEN_PASTE_HOOK_HANDLER_ID); }); it("requires an injected prompt when no env or credential value is available", async () => { @@ -73,4 +74,45 @@ describe("common token-paste hook implementation", () => { }); }); + it("collects Slack bot and app tokens through the shared token-paste hook", async () => { + 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"); + + await expect( + runMessagingHook(hook, registry, { + channelId: "slack", + }), + ).resolves.toMatchObject({ + handlerId: COMMON_TOKEN_PASTE_HOOK_HANDLER_ID, + phase: "enroll", + outputs: { + botToken: { + kind: "secret", + value: "xoxb-test-slack-token", + }, + appToken: { + kind: "secret", + value: "xapp-test-slack-token", + }, + }, + }); + }); + }); diff --git a/src/lib/messaging/hooks/common/token-paste.ts b/src/lib/messaging/hooks/common/token-paste.ts index d98f2afab7..42f870456b 100644 --- a/src/lib/messaging/hooks/common/token-paste.ts +++ b/src/lib/messaging/hooks/common/token-paste.ts @@ -39,6 +39,12 @@ export function createTokenPasteHook(options: TokenPasteHookOptions = {}): Messa return async (context) => { const outputs: Record = {}; const manifest = createBuiltInChannelManifestRegistry().get(context.channelId); + const resolvedFields: Array<{ + readonly output: ChannelHookOutputSpec; + readonly field: TokenPasteField; + readonly token: string; + readonly source: "existing" | "prompted"; + }> = []; for (const output of context.outputDeclarations ?? []) { if (output.kind !== "secret") continue; @@ -55,12 +61,22 @@ export function createTokenPasteHook(options: TokenPasteHookOptions = {}): Messa options, context.isInteractive !== false, ); + resolvedFields.push({ + output, + field, + token: resolved.token, + source: resolved.source, + }); outputs[output.id] = { kind: "secret", value: resolved.token, }; - logTokenStatus(context.channelId, output, resolved.source, options); - if (isPrimarySecretOutput(manifest, output)) { + } + + for (const resolved of resolvedFields) { + persistTokenValue(resolved.field, resolved.token, resolved.source, options); + logTokenStatus(context.channelId, resolved.output, resolved.source, options); + if (isPrimarySecretOutput(manifest, resolved.output)) { logEnrollmentNotes(manifest, options); } } @@ -87,7 +103,6 @@ async function resolveTokenValue( ): Promise<{ readonly token: string; readonly source: "existing" | "prompted" }> { const env = options.env ?? process.env; const readCredential = options.getCredential ?? (() => null); - const writeCredential = options.saveCredential ?? (() => {}); const prompt = options.prompt ?? missingPhaseOnePrompt; const log = options.log ?? ((message: string) => console.log(message)); @@ -134,11 +149,23 @@ async function resolveTokenValue( ); } - writeCredential(field.envKey, token); - env[field.envKey] = token; return { token, source }; } +function persistTokenValue( + field: TokenPasteField, + token: string, + source: "existing" | "prompted", + options: TokenPasteHookOptions, +): void { + const env = options.env ?? process.env; + env[field.envKey] = token; + if (source === "prompted") { + const writeCredential = options.saveCredential ?? (() => {}); + writeCredential(field.envKey, token); + } +} + async function missingPhaseOnePrompt(): Promise { throw new Error( "Token-paste hook requires an injected prompt implementation in phase 1.", diff --git a/src/lib/messaging/hooks/hook-runner.test.ts b/src/lib/messaging/hooks/hook-runner.test.ts index 39e580672f..afd70080e1 100644 --- a/src/lib/messaging/hooks/hook-runner.test.ts +++ b/src/lib/messaging/hooks/hook-runner.test.ts @@ -35,7 +35,7 @@ describe("MessagingHookRegistry", () => { expect(registry.listIds()).toEqual([ "common.tokenPaste", "common.configPrompt", - "slack.tokenPaste", + "slack.validateCredentials", "telegram.allowlistAliases", "telegram.getMeReachability", "wechat.ilinkLogin", diff --git a/src/lib/onboard/messaging-channel-setup.test.ts b/src/lib/onboard/messaging-channel-setup.test.ts index 10bdabdf4e..dc3e9f1d37 100644 --- a/src/lib/onboard/messaging-channel-setup.test.ts +++ b/src/lib/onboard/messaging-channel-setup.test.ts @@ -11,7 +11,7 @@ import { setupMessagingChannels, setupSelectedMessagingChannels, } from "./messaging-channel-setup"; -import { validateSlackCredentials } from "./slack-validation"; +import { validateSlackCredentials } from "../messaging/channels/slack/hooks/credential-validation"; vi.mock("../credentials/store", () => ({ getCredential: vi.fn(() => null), @@ -28,7 +28,7 @@ vi.mock("../host-qr-handlers", () => ({ }, })); -vi.mock("./slack-validation", () => ({ +vi.mock("../messaging/channels/slack/hooks/credential-validation", () => ({ formatSlackValidationFailure: vi.fn((result: { message: string }) => result.message), validateSlackCredentials: vi.fn(() => ({ ok: true })), })); @@ -480,7 +480,7 @@ describe("setupMessagingChannels", () => { expect(prompt).not.toHaveBeenCalled(); }); - it("does not save prompted Slack credentials when Slack API rejects them", async () => { + it("disables Slack when Slack API rejects prompted credentials", async () => { delete process.env.SLACK_BOT_TOKEN; delete process.env.SLACK_APP_TOKEN; vi.mocked(prompt) @@ -509,16 +509,17 @@ describe("setupMessagingChannels", () => { ); expect(enabled.has("slack")).toBe(false); - expect(saveCredential).not.toHaveBeenCalled(); - expect(process.env.SLACK_BOT_TOKEN).toBeUndefined(); - expect(process.env.SLACK_APP_TOKEN).toBeUndefined(); + expect(saveCredential).toHaveBeenCalledWith("SLACK_BOT_TOKEN", "xoxb-fake-bot-token"); + expect(saveCredential).toHaveBeenCalledWith("SLACK_APP_TOKEN", "xapp-fake-app-token"); + expect(process.env.SLACK_BOT_TOKEN).toBe("xoxb-fake-bot-token"); + expect(process.env.SLACK_APP_TOKEN).toBe("xapp-fake-app-token"); const output = logs.join("\n"); expect(output).toContain("Slack app token was rejected by Slack API"); expect(output).not.toContain("xoxb-fake-bot-token"); expect(output).not.toContain("xapp-fake-app-token"); }); - it("does not save prompted Slack credentials when Slack API validation is indeterminate", async () => { + it("disables Slack when Slack API validation is indeterminate", async () => { delete process.env.SLACK_BOT_TOKEN; delete process.env.SLACK_APP_TOKEN; vi.mocked(prompt) @@ -542,9 +543,10 @@ describe("setupMessagingChannels", () => { ); expect(enabled.has("slack")).toBe(false); - expect(saveCredential).not.toHaveBeenCalled(); - expect(process.env.SLACK_BOT_TOKEN).toBeUndefined(); - expect(process.env.SLACK_APP_TOKEN).toBeUndefined(); + expect(saveCredential).toHaveBeenCalledWith("SLACK_BOT_TOKEN", "xoxb-timeout-bot-token"); + expect(saveCredential).toHaveBeenCalledWith("SLACK_APP_TOKEN", "xapp-timeout-app-token"); + expect(process.env.SLACK_BOT_TOKEN).toBe("xoxb-timeout-bot-token"); + expect(process.env.SLACK_APP_TOKEN).toBe("xapp-timeout-app-token"); }); it("ignores existing Slack tokens that pass format but fail Slack API validation", async () => { @@ -573,11 +575,11 @@ describe("setupMessagingChannels", () => { ); expect(enabled.has("slack")).toBe(false); - expect(prompt).not.toHaveBeenCalled(); + expect(prompt).toHaveBeenCalledWith(" Slack Member IDs (comma-separated allowlist): "); + expect(prompt).toHaveBeenCalledWith(" Slack Channel IDs (comma-separated allowlist): "); expect(saveCredential).not.toHaveBeenCalled(); const output = logs.join("\n"); - expect(output).toContain("Invalid existing slack token ignored"); expect(output).toContain("token_revoked"); - expect(output).not.toContain("slack — already configured"); + expect(output).toContain("slack — already configured"); }); }); diff --git a/test/channels-add-preset.test.ts b/test/channels-add-preset.test.ts index d7a2d1a017..b66369d904 100644 --- a/test/channels-add-preset.test.ts +++ b/test/channels-add-preset.test.ts @@ -1079,7 +1079,7 @@ process.exit = (code) => { ); }); - it("validates Slack credentials before persisting tokens or registering providers", () => { + it("validates Slack credentials before registering providers", () => { const script = `${buildPreamble()} const ctx = module.exports; (async () => { @@ -1125,6 +1125,11 @@ const ctx = module.exports; payload.callOrder.indexOf("saveCredential:SLACK_BOT_TOKEN"), `Slack validation must complete before token persistence; got ${JSON.stringify(payload.callOrder)}`, ); + assert.ok( + payload.callOrder.indexOf("slackProbe:app") < + payload.callOrder.indexOf("upsertMessagingProviders"), + `Slack validation must complete before provider registration; got ${JSON.stringify(payload.callOrder)}`, + ); assert.ok( payload.callOrder.indexOf("saveCredential:SLACK_APP_TOKEN") < payload.callOrder.indexOf("upsertMessagingProviders"), @@ -1184,7 +1189,7 @@ global.__slackBotProbe = { ); }); - it("aborts Slack channel add on rejected Slack API validation before persistence or registration", () => { + it("aborts Slack channel add on rejected Slack API validation before provider registration", () => { const script = `${buildPreamble()} const ctx = module.exports; global.__slackBotProbe = { @@ -1244,7 +1249,7 @@ process.exit = (code) => { ); }); - it("aborts Slack channel add on indeterminate Slack API validation before persistence or registration", () => { + it("aborts Slack channel add on indeterminate Slack API validation before provider registration", () => { const script = `${buildPreamble()} const ctx = module.exports; global.__slackBotProbe = { From 752ace3f82b0d965a74b5a7c33e84d5be79299b0 Mon Sep 17 00:00:00 2001 From: San Dang Date: Thu, 4 Jun 2026 16:52:39 +0700 Subject: [PATCH 39/40] feat(messaging): persist channel manifest plans (#4536) ## Summary Persist manifest messaging plans through channel lifecycle operations so add, stop, start, remove, and rebuild can carry the new architecture state in `SandboxEntry` while legacy registry fields continue to work. ## Related Issue Fixes #4535 Refs #3896 ## Changes - Added `MessagingWorkflowPlanner` helpers that merge a compiled add-channel plan into a stored sandbox plan and mutate stored plans for stop/start/remove/rebuild. - Updated `channels add`, `channels stop`, `channels start`, and `channels remove` to write `SandboxEntry.messaging.plan` without removing legacy registry updates. - Staged stored manifest plans during rebuild through the existing messaging plan env path. - Added planner tests for add merge, stop/start mutation, remove pruning, rebuild staging from stored plans, and no-compile behavior when no stored plan exists. ## 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 - [ ] `npm run 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) --- Signed-off-by: San Dang ## Summary by CodeRabbit ## Release Notes * **New Features** * Improved messaging channel management with manifest-driven configuration for Discord, Telegram, Slack, WeChat, and WhatsApp * Support for multiple authentication modes including token-based and QR code enrollment * Enhanced channel validation and reachability checks during setup * **Bug Fixes** * More reliable credential handling and credential binding resolution * Better error messaging and validation for channel configuration * **Tests** * Expanded test coverage for channel enrollment workflows and manifest validation --------- Signed-off-by: San Dang --- src/lib/actions/sandbox/policy-channel.ts | 53 ++- src/lib/actions/sandbox/rebuild.ts | 48 +++ .../compiler/workflow-planner.test.ts | 259 ++++++++++++- .../messaging/compiler/workflow-planner.ts | 358 ++++++++++++++++++ test/e2e/test-channels-add-remove.sh | 115 ++++++ test/e2e/test-channels-stop-start.sh | 117 ++++++ 6 files changed, 943 insertions(+), 7 deletions(-) diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index 3fce13f743..d0fa865093 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -681,13 +681,12 @@ async function planSandboxChannelAdd( hydrateAddChannelEnvFromSession(sandboxName, channelId); try { - const plan = await planner.buildPlan({ + const plan = await planner.buildChannelAddPlanFromSandboxEntry({ sandboxName, agent: toMessagingAgentId(agent), - workflow: "add-channel", isInteractive: !isNonInteractive(), - configuredChannels: [channelId], - disabledChannels: [], + channelId, + sandboxEntry: registry.getSandbox(sandboxName), supportedChannelIds, credentialAvailability: buildCredentialAvailability([channelId]), }); @@ -700,6 +699,46 @@ async function planSandboxChannelAdd( } } +async function persistManifestChannelDisabledPlan( + sandboxName: string, + channelId: string, + disabled: boolean, +): Promise { + const entry = registry.getSandbox(sandboxName); + if (!entry) return; + const agent = resolveAgentForSandbox(sandboxName); + const planner = new MessagingWorkflowPlanner(messagingManifestRegistry); + const context = { + sandboxName, + agent: toMessagingAgentId(agent), + channelId, + sandboxEntry: entry, + supportedChannelIds: availableManifestChannelsForAgent(agent).map((manifest) => manifest.id), + }; + const plan = disabled + ? await planner.buildChannelStopPlanFromSandboxEntry(context) + : await planner.buildChannelStartPlanFromSandboxEntry(context); + if (plan) MessagingHostStateApplier.applyPlanToRegistry(sandboxName, plan); +} + +async function persistManifestChannelRemovePlan( + sandboxName: string, + channelId: string, +): Promise { + const entry = registry.getSandbox(sandboxName); + if (!entry) return; + const agent = resolveAgentForSandbox(sandboxName); + const planner = new MessagingWorkflowPlanner(messagingManifestRegistry); + const plan = await planner.buildChannelRemovePlanFromSandboxEntry({ + sandboxName, + agent: toMessagingAgentId(agent), + channelId, + sandboxEntry: entry, + supportedChannelIds: availableManifestChannelsForAgent(agent).map((manifest) => manifest.id), + }); + if (plan) MessagingHostStateApplier.applyPlanToRegistry(sandboxName, plan); +} + function buildCredentialAvailability(channelIds: readonly string[]): Record { const availability: Record = {}; for (const channelId of channelIds) { @@ -923,7 +962,7 @@ export async function addSandboxChannel( } await applyChannelAddToGatewayAndRegistry(sandboxName, canonical, {}); persistManifestAddState(sandboxName, manifest); - MessagingHostStateApplier.applyPlanToRegistry(sandboxName, plan, { mode: "merge" }); + MessagingHostStateApplier.applyPlanToRegistry(sandboxName, plan); console.log(""); const help = manifest.enrollmentHelp ?? manifest.inputs[0]?.prompt?.help; if (help) console.log(` ${help}`); @@ -982,7 +1021,7 @@ export async function addSandboxChannel( } persistManifestAddState(sandboxName, manifest); - MessagingHostStateApplier.applyPlanToRegistry(sandboxName, plan, { mode: "merge" }); + MessagingHostStateApplier.applyPlanToRegistry(sandboxName, plan); const rebuilt = await promptAndRebuild(sandboxName, `add '${canonical}'`); if (rebuilt) verifyChannelBridgeAfterRebuild(sandboxName, canonical); @@ -1290,6 +1329,7 @@ export async function removeSandboxChannel( } removeChannelPresetIfPresent(sandboxName, canonical); + await persistManifestChannelRemovePlan(sandboxName, canonical); // Token-based channels: best-effort tidy of any leftover dir. Token // revocation already prevents the bot from authenticating, so a @@ -1345,6 +1385,7 @@ async function sandboxChannelsSetEnabled( console.error(` Sandbox '${sandboxName}' not found in the registry.`); process.exit(1); } + await persistManifestChannelDisabledPlan(sandboxName, normalized, disabled); const state = disabled ? "disabled" : "enabled"; console.log(` ${G}✓${R} Marked ${normalized} ${state} for '${sandboxName}'.`); await promptAndRebuild(sandboxName, `${verb} '${normalized}'`); diff --git a/src/lib/actions/sandbox/rebuild.ts b/src/lib/actions/sandbox/rebuild.ts index b010b436dc..1edfb71a3e 100644 --- a/src/lib/actions/sandbox/rebuild.ts +++ b/src/lib/actions/sandbox/rebuild.ts @@ -41,6 +41,12 @@ import * as agentRuntime from "../../agent/runtime"; import { RD as _RD, B, D, G, R, YW } from "../../cli/terminal-style"; import { getSandboxDeleteOutcome } from "../../domain/sandbox/destroy"; import * as nim from "../../inference/nim"; +import { + createBuiltInChannelManifestRegistry, + MessagingSetupApplier, + MessagingWorkflowPlanner, + toMessagingAgentId, +} from "../../messaging"; import { pruneDisabledMessagingPolicyPresets } from "../../onboard/messaging-policy-presets"; import { captureSandboxListWithGatewayRecovery, @@ -162,6 +168,33 @@ function preflightHermesProviderCredentials( return false; } +async function stageMessagingManifestPlanForRebuild( + sandboxName: string, + sandboxEntry: registry.SandboxEntry, + rebuildAgent: string | null, + log: (msg: string) => void, +): Promise { + const agent = loadAgent(rebuildAgent || "openclaw"); + const planner = new MessagingWorkflowPlanner(createBuiltInChannelManifestRegistry()); + const plan = await planner.buildRebuildPlanFromSandboxEntry({ + sandboxName, + agent: toMessagingAgentId(agent), + sandboxEntry, + supportedChannelIds: agent.messagingPlatforms, + }); + if (!plan || plan.channels.length === 0) { + MessagingSetupApplier.clearPlanEnv(); + log("Messaging manifest rebuild plan: no configured channels"); + return; + } + MessagingSetupApplier.writePlanToEnv(plan); + log( + `Messaging manifest rebuild plan staged: ${plan.channels + .map((channel) => channel.channelId) + .join(",")}`, + ); +} + /** * Rebuild a live sandbox while preserving registered agent state and policies. * @@ -392,6 +425,21 @@ export async function rebuildSandbox( ); } + try { + await stageMessagingManifestPlanForRebuild(sandboxName, sb, rebuildAgent, log); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(""); + console.error( + ` ${_RD}Rebuild preflight failed:${R} messaging manifest plan could not be staged.`, + ); + console.error(` ${message}`); + console.error(""); + console.error(" Sandbox is untouched — no data was lost."); + bail(message); + return; + } + // Step 1: Ensure sandbox is live for backup log("Checking sandbox liveness: openshell sandbox list"); const liveRecovery = await captureSandboxListWithGatewayRecovery(); diff --git a/src/lib/messaging/compiler/workflow-planner.test.ts b/src/lib/messaging/compiler/workflow-planner.test.ts index c7bb68bcd8..9a07b5f307 100644 --- a/src/lib/messaging/compiler/workflow-planner.test.ts +++ b/src/lib/messaging/compiler/workflow-planner.test.ts @@ -195,9 +195,15 @@ describe("MessagingWorkflowPlanner", () => { const outputs: Record = {}; for (const output of context.outputDeclarations ?? []) { if (output.kind === "secret") { + const value = + context.channelId === "slack" && output.id === "botToken" + ? "xoxb-test-slack-bot-token" + : context.channelId === "slack" && output.id === "appToken" + ? "xapp-test-slack-app-token" + : `test-${context.channelId}-${output.id}`; outputs[output.id] = { kind: "secret", - value: `test-${context.channelId}-${output.id}`, + value, }; } } @@ -249,6 +255,10 @@ describe("MessagingWorkflowPlanner", () => { id: "common.configPrompt", handler: () => ({ outputs: {} }), }, + { + id: "slack.validateCredentials", + handler: () => ({}), + }, ]); await withEnv( @@ -418,6 +428,253 @@ describe("MessagingWorkflowPlanner", () => { ]); }); + it("adds one manifest channel into an existing sandbox entry plan", async () => { + const existingPlan = await planner().buildPlan({ + sandboxName: "demo", + agent: "openclaw", + workflow: "onboard", + isInteractive: false, + configuredChannels: ["telegram"], + credentialAvailability: { + TELEGRAM_BOT_TOKEN: true, + }, + }); + 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") { + const value = + context.channelId === "slack" && output.id === "botToken" + ? "xoxb-test-slack-bot-token" + : context.channelId === "slack" && output.id === "appToken" + ? "xapp-test-slack-app-token" + : `test-${context.channelId}-${output.id}`; + outputs[output.id] = { + kind: "secret", + value, + }; + } + } + return { outputs }; + }, + }, + { + id: "common.configPrompt", + handler: () => ({ outputs: {} }), + }, + { + id: "slack.validateCredentials", + handler: () => ({}), + }, + ]); + + const plan = await new MessagingWorkflowPlanner( + createBuiltInChannelManifestRegistry(), + hooks, + ).buildChannelAddPlanFromSandboxEntry({ + sandboxName: "demo", + agent: "openclaw", + sandboxEntry: { + name: "demo", + messaging: { + schemaVersion: 1, + plan: existingPlan, + }, + }, + channelId: "slack", + isInteractive: true, + supportedChannelIds: ["telegram", "slack"], + }); + + expect(plan.workflow).toBe("add-channel"); + expect(plan.channels.map((channel) => channel.channelId)).toEqual([ + "telegram", + "slack", + ]); + expect(plan.channels.find((channel) => channel.channelId === "telegram")).toMatchObject({ + active: true, + configured: true, + }); + expect(plan.channels.find((channel) => channel.channelId === "slack")).toMatchObject({ + active: true, + configured: true, + disabled: false, + }); + expect(plan.credentialBindings.map((binding) => binding.channelId)).toEqual([ + "telegram", + "slack", + "slack", + ]); + }); + + it("mutates disabled channel state in an existing sandbox entry plan", async () => { + const existingPlan = await planner().buildPlan({ + sandboxName: "demo", + agent: "openclaw", + workflow: "onboard", + isInteractive: false, + configuredChannels: ["telegram", "slack"], + credentialAvailability: { + TELEGRAM_BOT_TOKEN: true, + SLACK_BOT_TOKEN: true, + SLACK_APP_TOKEN: true, + }, + }); + + const stopped = await planner().buildChannelStopPlanFromSandboxEntry({ + sandboxName: "demo", + agent: "openclaw", + sandboxEntry: { + name: "demo", + messaging: { + schemaVersion: 1, + plan: existingPlan, + }, + }, + channelId: "telegram", + }); + + expect(stopped?.workflow).toBe("stop-channel"); + expect(stopped?.disabledChannels).toEqual(["telegram"]); + expect(stopped?.channels.find((channel) => channel.channelId === "telegram")).toMatchObject({ + active: false, + disabled: true, + }); + + const started = await planner().buildChannelStartPlanFromSandboxEntry({ + sandboxName: "demo", + agent: "openclaw", + sandboxEntry: { + name: "demo", + messaging: { + schemaVersion: 1, + plan: stopped!, + }, + }, + channelId: "telegram", + }); + + expect(started?.workflow).toBe("start-channel"); + expect(started?.disabledChannels).toEqual([]); + expect(started?.channels.find((channel) => channel.channelId === "telegram")).toMatchObject({ + active: true, + disabled: false, + }); + }); + + it("removes a channel and its dependent plan entries from an existing sandbox entry plan", async () => { + const existingPlan = await planner().buildPlan({ + sandboxName: "demo", + agent: "openclaw", + workflow: "onboard", + isInteractive: false, + configuredChannels: ["telegram", "slack"], + credentialAvailability: { + TELEGRAM_BOT_TOKEN: true, + SLACK_BOT_TOKEN: true, + SLACK_APP_TOKEN: true, + }, + }); + + const removed = await planner().buildChannelRemovePlanFromSandboxEntry({ + sandboxName: "demo", + agent: "openclaw", + sandboxEntry: { + name: "demo", + messaging: { + schemaVersion: 1, + plan: existingPlan, + }, + }, + channelId: "telegram", + }); + + expect(removed?.workflow).toBe("remove-channel"); + expect(removed?.channels.map((channel) => channel.channelId)).toEqual(["slack"]); + expect(removed?.disabledChannels).toEqual([]); + expect( + removed?.credentialBindings.some((binding) => binding.channelId === "telegram"), + ).toBe(false); + expect( + removed?.networkPolicy.entries.some((entry) => entry.channelId === "telegram"), + ).toBe(false); + expect(removed?.agentRender.some((entry) => entry.channelId === "telegram")).toBe(false); + }); + + it("rebuilds from stored plan input values when config env is unavailable", async () => { + const existingPlan = await withEnv( + { + TELEGRAM_REQUIRE_MENTION: "1", + }, + () => + planner().buildPlan({ + sandboxName: "demo", + agent: "openclaw", + workflow: "onboard", + isInteractive: false, + configuredChannels: ["telegram"], + credentialAvailability: { + TELEGRAM_BOT_TOKEN: true, + }, + }), + ); + + await withEnv( + { + TELEGRAM_REQUIRE_MENTION: undefined, + }, + async () => { + const rebuilt = await planner().buildRebuildPlanFromSandboxEntry({ + sandboxName: "demo", + agent: "openclaw", + sandboxEntry: { + name: "demo", + messagingChannels: ["telegram"], + messaging: { + schemaVersion: 1, + plan: existingPlan, + }, + }, + }); + + expect(rebuilt?.workflow).toBe("rebuild"); + expect( + rebuilt?.channels + .find((channel) => channel.channelId === "telegram") + ?.inputs.find((input) => input.inputId === "requireMention"), + ).toMatchObject({ + value: "1", + }); + expect(rebuilt?.channels.find((channel) => channel.channelId === "telegram")).toMatchObject({ + active: true, + disabled: false, + }); + }, + ); + }); + + it("does not compile a rebuild plan when the sandbox entry has no stored plan", async () => { + const rebuilt = await planner().buildRebuildPlanFromSandboxEntry({ + sandboxName: "demo", + agent: "openclaw", + sandboxEntry: { + name: "demo", + messagingChannels: ["telegram"], + providerCredentialHashes: { + TELEGRAM_BOT_TOKEN: "sha256:test", + }, + }, + }); + + expect(rebuilt).toBeNull(); + }); + it("reports unsupported channels deterministically before compiling", async () => { await expect( planner().buildPlan({ diff --git a/src/lib/messaging/compiler/workflow-planner.ts b/src/lib/messaging/compiler/workflow-planner.ts index 8371b2d1ad..853ff39456 100644 --- a/src/lib/messaging/compiler/workflow-planner.ts +++ b/src/lib/messaging/compiler/workflow-planner.ts @@ -7,6 +7,7 @@ import type { MessagingAgentId, MessagingChannelId, MessagingCompilerWorkflow, + SandboxMessagingChannelPlan, SandboxMessagingPlan, } from "../manifest"; import { ManifestCompiler } from "./manifest-compiler"; @@ -56,6 +57,65 @@ export class MessagingWorkflowPlanner { return this.compiler.compile(compilerContext); } + async buildChannelAddPlanFromSandboxEntry( + context: MessagingWorkflowPlannerChannelAddContext, + ): Promise { + const existingPlan = readSandboxEntryPlan(context); + const compiledPlan = await this.buildPlan({ + sandboxName: context.sandboxName, + agent: context.agent, + workflow: "add-channel", + isInteractive: context.isInteractive, + configuredChannels: [context.channelId], + disabledChannels: [], + supportedChannelIds: context.supportedChannelIds, + credentialAvailability: mergeAvailability( + credentialAvailabilityFromPlan(existingPlan), + this.credentialAvailabilityFromSandboxEntry( + context.sandboxEntry, + [context.channelId], + ), + context.credentialAvailability, + ), + }); + return existingPlan + ? mergeSandboxMessagingPlans(existingPlan, compiledPlan) + : compiledPlan; + } + + async buildChannelStopPlanFromSandboxEntry( + context: MessagingWorkflowPlannerChannelMutationContext, + ): Promise { + const plan = await this.planForSandboxEntryMutation(context, "stop-channel"); + return plan ? setPlanChannelDisabled(plan, context.channelId, true, "stop-channel") : null; + } + + async buildChannelStartPlanFromSandboxEntry( + context: MessagingWorkflowPlannerChannelMutationContext, + ): Promise { + const plan = await this.planForSandboxEntryMutation(context, "start-channel"); + return plan ? setPlanChannelDisabled(plan, context.channelId, false, "start-channel") : null; + } + + async buildChannelRemovePlanFromSandboxEntry( + context: MessagingWorkflowPlannerChannelMutationContext, + ): Promise { + const plan = await this.planForSandboxEntryMutation(context, "remove-channel"); + return plan ? removePlanChannel(plan, context.channelId, "remove-channel") : null; + } + + async buildRebuildPlanFromSandboxEntry( + context: MessagingWorkflowPlannerSandboxRebuildContext, + ): Promise { + const existingPlan = readSandboxEntryPlan(context); + if (!existingPlan) return null; + return setPlanDisabledChannels( + existingPlan, + disabledChannelsFromSandboxEntry(context.sandboxEntry, existingPlan), + "rebuild", + ); + } + private assertSupportedChannels( channelIds: readonly MessagingChannelId[], context: Pick< @@ -92,8 +152,74 @@ export class MessagingWorkflowPlanner { .filter((manifest) => !supportedFilter || supportedFilter.has(manifest.id)) .map((manifest) => manifest.id); } + + private async planForSandboxEntryMutation( + context: MessagingWorkflowPlannerChannelMutationContext, + workflow: MessagingCompilerWorkflow, + ): Promise { + const existingPlan = readSandboxEntryPlan(context); + if (existingPlan) return { ...clonePlan(existingPlan), workflow }; + return null; + } + + private credentialAvailabilityFromSandboxEntry( + sandboxEntry: MessagingWorkflowPlannerSandboxEntry | null | undefined, + channelIds: readonly MessagingChannelId[], + ): MessagingCompilerCredentialAvailability | undefined { + const hashes = sandboxEntry?.providerCredentialHashes; + if (!hashes || Object.keys(hashes).length === 0) return undefined; + + const availability: Record = {}; + for (const channelId of channelIds) { + const manifest = this.registry.get(channelId); + if (!manifest) continue; + for (const credential of manifest.credentials) { + if (!hashes[credential.providerEnvKey]) continue; + availability[credential.sourceInput] = true; + availability[`${manifest.id}.${credential.sourceInput}`] = true; + availability[credential.id] = true; + availability[`${manifest.id}.${credential.id}`] = true; + availability[credential.providerEnvKey] = true; + } + } + return Object.keys(availability).length > 0 ? availability : undefined; + } +} + +export interface MessagingWorkflowPlannerSandboxEntry { + readonly name: string; + readonly agent?: string | null; + readonly messagingChannels?: readonly MessagingChannelId[] | null; + readonly disabledChannels?: readonly MessagingChannelId[] | null; + readonly providerCredentialHashes?: Readonly> | null; + readonly messaging?: { + readonly schemaVersion: 1; + readonly plan: SandboxMessagingPlan; + } | null; +} + +export interface MessagingWorkflowPlannerSandboxContext { + readonly sandboxName: string; + readonly agent: MessagingAgentId; + readonly sandboxEntry?: MessagingWorkflowPlannerSandboxEntry | null; + readonly supportedChannelIds?: readonly MessagingChannelId[]; + readonly credentialAvailability?: MessagingCompilerCredentialAvailability; } +export interface MessagingWorkflowPlannerChannelAddContext + extends MessagingWorkflowPlannerSandboxContext { + readonly channelId: MessagingChannelId; + readonly isInteractive: boolean; +} + +export interface MessagingWorkflowPlannerChannelMutationContext + extends MessagingWorkflowPlannerSandboxContext { + readonly channelId: MessagingChannelId; +} + +export type MessagingWorkflowPlannerSandboxRebuildContext = + MessagingWorkflowPlannerSandboxContext; + function uniqueChannels( channelIds: readonly MessagingChannelId[] | undefined, ): MessagingChannelId[] { @@ -107,3 +233,235 @@ function onlyConfiguredChannels( const configured = new Set(configuredChannels); return uniqueChannels(channelIds).filter((channelId) => configured.has(channelId)); } + +function readSandboxEntryPlan( + context: Pick< + MessagingWorkflowPlannerSandboxContext, + "agent" | "sandboxEntry" | "sandboxName" + >, +): SandboxMessagingPlan | null { + const plan = context.sandboxEntry?.messaging?.plan; + if ( + !plan || + plan.schemaVersion !== 1 || + plan.sandboxName !== context.sandboxName || + plan.agent !== context.agent + ) { + return null; + } + return clonePlan(plan); +} + +function disabledChannelsFromSandboxEntry( + sandboxEntry: MessagingWorkflowPlannerSandboxEntry | null | undefined, + fallbackPlan: SandboxMessagingPlan | null, +): MessagingChannelId[] { + return uniqueChannels( + Array.isArray(sandboxEntry?.disabledChannels) + ? sandboxEntry.disabledChannels + : fallbackPlan?.disabledChannels ?? [], + ); +} + +function clonePlan(plan: SandboxMessagingPlan): SandboxMessagingPlan { + return JSON.parse(JSON.stringify(plan)) as SandboxMessagingPlan; +} + +function mergeSandboxMessagingPlans( + existing: SandboxMessagingPlan, + incoming: SandboxMessagingPlan, +): SandboxMessagingPlan { + if ( + existing.schemaVersion !== incoming.schemaVersion || + existing.sandboxName !== incoming.sandboxName || + existing.agent !== incoming.agent + ) { + return clonePlan(incoming); + } + + const incomingChannelIds = new Set(incoming.channels.map((channel) => channel.channelId)); + const mergedChannels = [ + ...existing.channels.filter((channel) => !incomingChannelIds.has(channel.channelId)), + ...incoming.channels, + ]; + const activeIncomingChannels = new Set( + incoming.channels + .filter((channel) => channel.active && !channel.disabled) + .map((channel) => channel.channelId), + ); + const disabledChannels = uniqueSortedStrings([ + ...existing.disabledChannels.filter((channelId) => !activeIncomingChannels.has(channelId)), + ...incoming.disabledChannels, + ]); + const networkEntries = mergePlanEntriesByChannel( + existing.networkPolicy.entries, + incoming.networkPolicy.entries, + ); + + return clonePlan({ + ...incoming, + channels: mergedChannels, + disabledChannels, + credentialBindings: mergePlanEntriesByChannel( + existing.credentialBindings, + incoming.credentialBindings, + ), + networkPolicy: { + presets: uniqueSortedStrings(networkEntries.map((entry) => entry.presetName)), + entries: networkEntries, + }, + agentRender: mergePlanEntriesByChannel(existing.agentRender, incoming.agentRender), + buildSteps: mergePlanEntriesByChannel(existing.buildSteps, incoming.buildSteps), + stateUpdates: mergePlanEntriesByChannel(existing.stateUpdates, incoming.stateUpdates), + healthChecks: mergePlanEntriesByChannel(existing.healthChecks, incoming.healthChecks), + }); +} + +function setPlanChannelDisabled( + plan: SandboxMessagingPlan, + channelId: MessagingChannelId, + disabled: boolean, + workflow: MessagingCompilerWorkflow, +): SandboxMessagingPlan { + const nextChannels = plan.channels.map((channel) => { + if (channel.channelId !== channelId) return channel; + const nextChannel = { ...channel, disabled }; + return { + ...nextChannel, + active: !disabled && isChannelPlanStartable(nextChannel), + }; + }); + const configuredIds = new Set(nextChannels.map((channel) => channel.channelId)); + const disabledChannels = disabled + ? uniqueSortedStrings([...plan.disabledChannels, channelId]).filter((id) => + configuredIds.has(id), + ) + : plan.disabledChannels.filter((id) => id !== channelId); + + return clonePlan({ + ...plan, + workflow, + channels: nextChannels, + disabledChannels, + }); +} + +function setPlanDisabledChannels( + plan: SandboxMessagingPlan, + disabledChannelIds: readonly MessagingChannelId[], + workflow: MessagingCompilerWorkflow, +): SandboxMessagingPlan { + const configuredIds = new Set(plan.channels.map((channel) => channel.channelId)); + const disabledChannels = uniqueSortedStrings(disabledChannelIds).filter((id) => + configuredIds.has(id), + ); + const disabledSet = new Set(disabledChannels); + const channels = plan.channels.map((channel) => { + const disabled = disabledSet.has(channel.channelId); + const nextChannel = { ...channel, disabled }; + return { + ...nextChannel, + active: !disabled && isChannelPlanStartable(nextChannel), + }; + }); + + return clonePlan({ + ...plan, + workflow, + channels, + disabledChannels, + }); +} + +function removePlanChannel( + plan: SandboxMessagingPlan, + channelId: MessagingChannelId, + workflow: MessagingCompilerWorkflow, +): SandboxMessagingPlan { + const channels = plan.channels.filter((channel) => channel.channelId !== channelId); + const remainingChannelIds = new Set(channels.map((channel) => channel.channelId)); + const networkEntries = plan.networkPolicy.entries.filter( + (entry) => entry.channelId !== channelId, + ); + const keepEntry = (entry: T) => + entry.channelId !== channelId && remainingChannelIds.has(entry.channelId); + + return clonePlan({ + ...plan, + workflow, + channels, + disabledChannels: plan.disabledChannels.filter( + (id) => id !== channelId && remainingChannelIds.has(id), + ), + credentialBindings: plan.credentialBindings.filter(keepEntry), + networkPolicy: { + presets: uniqueSortedStrings(networkEntries.map((entry) => entry.presetName)), + entries: networkEntries, + }, + agentRender: plan.agentRender.filter(keepEntry), + buildSteps: plan.buildSteps.filter(keepEntry), + stateUpdates: plan.stateUpdates.filter(keepEntry), + healthChecks: plan.healthChecks.filter(keepEntry), + }); +} + +function isChannelPlanStartable(channel: SandboxMessagingChannelPlan): boolean { + if (!channel.configured) return false; + return channel.inputs.every((input) => { + if (!input.required) return true; + if (input.kind === "secret") return input.credentialAvailable === true; + if (input.value === undefined) return false; + return typeof input.value === "string" ? input.value.trim().length > 0 : true; + }); +} + +function mergePlanEntriesByChannel( + existing: readonly T[], + incoming: readonly T[], +): T[] { + const incomingChannelIds = new Set(incoming.map((entry) => entry.channelId)); + return [ + ...existing.filter((entry) => !incomingChannelIds.has(entry.channelId)), + ...incoming, + ]; +} + +function credentialAvailabilityFromPlan( + plan: SandboxMessagingPlan | null, +): MessagingCompilerCredentialAvailability | undefined { + if (!plan) return undefined; + const availability: Record = {}; + for (const channel of plan.channels) { + for (const input of channel.inputs) { + if (input.kind !== "secret" || input.credentialAvailable !== true) continue; + availability[input.inputId] = true; + availability[`${channel.channelId}.${input.inputId}`] = true; + if (input.sourceEnv) availability[input.sourceEnv] = true; + } + } + for (const credential of plan.credentialBindings) { + if (!credential.credentialAvailable) continue; + availability[credential.credentialId] = true; + availability[`${credential.channelId}.${credential.credentialId}`] = true; + availability[credential.sourceInput] = true; + availability[`${credential.channelId}.${credential.sourceInput}`] = true; + availability[credential.providerEnvKey] = true; + } + return Object.keys(availability).length > 0 ? availability : undefined; +} + +function mergeAvailability( + ...sources: Array +): MessagingCompilerCredentialAvailability | undefined { + const merged: Record = {}; + for (const source of sources) { + for (const [key, value] of Object.entries(source ?? {})) { + if (value === true) merged[key] = true; + } + } + return Object.keys(merged).length > 0 ? merged : undefined; +} + +function uniqueSortedStrings(values: readonly string[]): string[] { + return [...new Set(values)].filter(Boolean).sort(); +} diff --git a/test/e2e/test-channels-add-remove.sh b/test/e2e/test-channels-add-remove.sh index 59837349fa..fb17b987e0 100755 --- a/test/e2e/test-channels-add-remove.sh +++ b/test/e2e/test-channels-add-remove.sh @@ -84,7 +84,10 @@ fi SANDBOX_NAME="${NEMOCLAW_SANDBOX_NAME:-e2e-channels-add-remove}" INSTALL_LOG="/tmp/nemoclaw-e2e-install.log" +REGISTRY="$HOME/.nemoclaw/sandboxes.json" TELEGRAM_TOKEN="${TELEGRAM_BOT_TOKEN:-test-fake-telegram-token-add-remove-e2e}" +TELEGRAM_ALLOWED_IDS_VALUE="${TELEGRAM_ALLOWED_IDS:-123456789}" +TELEGRAM_REQUIRE_MENTION_VALUE="${TELEGRAM_REQUIRE_MENTION:-0}" is_fake_telegram_token() { case "${1:-}" in @@ -158,6 +161,105 @@ policy_list_has_preset() { | grep -E "^\s*●\s+${preset}\b" >/dev/null } +assert_host_telegram_config() { + local context="$1" + local output + if output="$(node -e ' +const fs = require("fs"); +const [registryPath, sandboxName, allowedIds, requireMention] = process.argv.slice(1); +const fail = (message) => { + console.error(message); + process.exit(1); +}; +if (!fs.existsSync(registryPath)) fail("registry file not found: " + registryPath); +const registry = JSON.parse(fs.readFileSync(registryPath, "utf8")); +const entry = registry.sandboxes?.[sandboxName]; +if (!entry) fail("sandbox " + sandboxName + " missing from registry"); +const config = entry.messagingChannelConfig; +if (!config || typeof config !== "object" || Array.isArray(config)) { + fail("messagingChannelConfig missing or not an object"); +} +if (config.TELEGRAM_ALLOWED_IDS !== allowedIds) { + fail("TELEGRAM_ALLOWED_IDS expected " + allowedIds + ", got " + JSON.stringify(config.TELEGRAM_ALLOWED_IDS)); +} +if (config.TELEGRAM_REQUIRE_MENTION !== requireMention) { + fail("TELEGRAM_REQUIRE_MENTION expected " + requireMention + ", got " + JSON.stringify(config.TELEGRAM_REQUIRE_MENTION)); +} +' "$REGISTRY" "$SANDBOX_NAME" "$TELEGRAM_ALLOWED_IDS_VALUE" "$TELEGRAM_REQUIRE_MENTION_VALUE" 2>&1)"; then + pass "host registry messagingChannelConfig persists telegram config ${context}" + else + fail "host registry messagingChannelConfig missing telegram config ${context}: ${output}" + fi +} + +assert_host_telegram_plan() { + local expected="$1" + local context="$2" + local output + if output="$(node -e ' +const fs = require("fs"); +const [registryPath, sandboxName, expected] = process.argv.slice(1); +const fail = (message) => { + console.error(message); + process.exit(1); +}; +if (!fs.existsSync(registryPath)) fail("registry file not found: " + registryPath); +const registry = JSON.parse(fs.readFileSync(registryPath, "utf8")); +const entry = registry.sandboxes?.[sandboxName]; +if (!entry) fail("sandbox " + sandboxName + " missing from registry"); +const state = entry.messaging; +if (!state || state.schemaVersion !== 1) fail("messaging state missing or schemaVersion != 1"); +const plan = state.plan; +if (!plan || plan.schemaVersion !== 1) fail("messaging.plan missing or schemaVersion != 1"); +if (plan.sandboxName !== sandboxName) { + fail("messaging.plan.sandboxName expected " + sandboxName + ", got " + JSON.stringify(plan.sandboxName)); +} +if (plan.agent !== "openclaw") fail("messaging.plan.agent expected openclaw, got " + JSON.stringify(plan.agent)); +const channels = Array.isArray(plan.channels) ? plan.channels : []; +const channel = channels.find((item) => item?.channelId === "telegram"); +const disabledChannels = Array.isArray(plan.disabledChannels) ? plan.disabledChannels : []; +const credentialBindings = Array.isArray(plan.credentialBindings) ? plan.credentialBindings : []; +const networkEntries = Array.isArray(plan.networkPolicy?.entries) ? plan.networkPolicy.entries : []; +const networkPresets = Array.isArray(plan.networkPolicy?.presets) ? plan.networkPolicy.presets : []; +const agentRender = Array.isArray(plan.agentRender) ? plan.agentRender : []; +if (expected === "active") { + if (!channel) fail("telegram channel missing from messaging.plan.channels"); + if (channel.active !== true) fail("telegram plan active expected true, got " + JSON.stringify(channel.active)); + if (channel.disabled === true) fail("telegram plan disabled unexpectedly true"); + if (!networkPresets.includes("telegram")) fail("telegram missing from messaging.plan.networkPolicy.presets"); + if (!networkEntries.some((entry) => entry?.channelId === "telegram")) { + fail("telegram missing from messaging.plan.networkPolicy.entries"); + } + if (!credentialBindings.some((entry) => entry?.channelId === "telegram" && entry?.providerEnvKey === "TELEGRAM_BOT_TOKEN")) { + fail("telegram TELEGRAM_BOT_TOKEN credential binding missing from messaging.plan"); + } + if (!agentRender.some((entry) => entry?.channelId === "telegram" && entry?.agent === "openclaw")) { + fail("telegram openclaw agent render entry missing from messaging.plan"); + } + if (disabledChannels.includes("telegram")) fail("telegram unexpectedly listed in messaging.plan.disabledChannels"); +} else if (expected === "removed") { + if (channel) fail("telegram still present in messaging.plan.channels"); + if (disabledChannels.includes("telegram")) fail("telegram still present in messaging.plan.disabledChannels"); + if (networkPresets.includes("telegram")) fail("telegram still present in messaging.plan.networkPolicy.presets"); + if (networkEntries.some((entry) => entry?.channelId === "telegram")) { + fail("telegram still present in messaging.plan.networkPolicy.entries"); + } + if (credentialBindings.some((entry) => entry?.channelId === "telegram")) { + fail("telegram credential binding still present in messaging.plan"); + } + if (agentRender.some((entry) => entry?.channelId === "telegram")) { + fail("telegram agent render entry still present in messaging.plan"); + } +} else { + fail("unknown expected plan state: " + expected); +} +' "$REGISTRY" "$SANDBOX_NAME" "$expected" 2>&1)"; then + pass "host registry messaging.plan has telegram ${expected} ${context}" + else + fail "host registry messaging.plan expected telegram ${expected} ${context}: ${output}" + fi +} + # Run rebuild with live tail of the rebuild log so the operator can see # progress. Mirrors the install.sh tail pattern in Phase 1. run_rebuild_with_live_log() { @@ -245,6 +347,8 @@ pass "C1a: Pre-cleanup complete" # messaging tokens and skip the messaging step entirely. This reproduces the # exact entry condition of the #3437 bug (onboard empty -> later channels add). unset TELEGRAM_BOT_TOKEN +unset TELEGRAM_ALLOWED_IDS +unset TELEGRAM_REQUIRE_MENTION export NEMOCLAW_SANDBOX_NAME="$SANDBOX_NAME" export NEMOCLAW_RECREATE_SANDBOX=1 @@ -338,6 +442,8 @@ section "Phase 3: channels add telegram + rebuild" # Now provide the token — this mirrors the real user flow: after onboard, # the operator decides to add a channel and exports the token first. export TELEGRAM_BOT_TOKEN="$TELEGRAM_TOKEN" +export TELEGRAM_ALLOWED_IDS="$TELEGRAM_ALLOWED_IDS_VALUE" +export TELEGRAM_REQUIRE_MENTION="$TELEGRAM_REQUIRE_MENTION_VALUE" maybe_skip_telegram_reachability_for_fake_token if nemoclaw "$SANDBOX_NAME" channels add telegram >/tmp/nc-add.log 2>&1; then @@ -352,6 +458,8 @@ else fail "C3a: channels add telegram did not register" tail -20 /tmp/nc-add.log 2>/dev/null || true fi +assert_host_telegram_config "after channels add" +assert_host_telegram_plan "active" "after channels add" info "Rebuilding sandbox to apply the add..." if run_rebuild_with_live_log /tmp/nc-rebuild-add.log; then @@ -394,6 +502,9 @@ else fail "C4c: telegram-bridge provider missing in gateway after add+rebuild" fi +assert_host_telegram_config "after add+rebuild" +assert_host_telegram_plan "active" "after add+rebuild" + # C4d: network reachability. With the preset applied, the bridge-style # probe (see telegram_egress_open) should reach Telegram and elicit a # response; without it, the proxy denies the CONNECT. User-facing symptom @@ -426,6 +537,8 @@ else fail "C5a: channels remove telegram did not unregister" tail -20 /tmp/nc-remove.log 2>/dev/null || true fi +assert_host_telegram_config "after channels remove" +assert_host_telegram_plan "removed" "after channels remove" info "Rebuilding sandbox to apply the remove..." if run_rebuild_with_live_log /tmp/nc-rebuild-remove.log; then @@ -471,4 +584,6 @@ else pass "C6c: 'telegram' preset removed from policy list after remove+rebuild" fi +assert_host_telegram_config "after remove+rebuild" + print_summary diff --git a/test/e2e/test-channels-stop-start.sh b/test/e2e/test-channels-stop-start.sh index ad082c8116..38b3f20d13 100755 --- a/test/e2e/test-channels-stop-start.sh +++ b/test/e2e/test-channels-stop-start.sh @@ -319,6 +319,113 @@ assert_disabled_channels() { done } +assert_host_messaging_config() { + local context="$1" + local output msg + if output="$(node -e ' +const fs = require("fs"); +const [registryPath, sandboxName, ...pairs] = process.argv.slice(1); +const fail = (message) => { + console.error(message); + process.exit(1); +}; +if (!fs.existsSync(registryPath)) fail("registry file not found: " + registryPath); +const registry = JSON.parse(fs.readFileSync(registryPath, "utf8")); +const entry = registry.sandboxes?.[sandboxName]; +if (!entry) fail("sandbox " + sandboxName + " missing from registry"); +const config = entry.messagingChannelConfig; +if (!config || typeof config !== "object" || Array.isArray(config)) { + fail("messagingChannelConfig missing or not an object"); +} +for (let i = 0; i < pairs.length; i += 2) { + const key = pairs[i]; + const expected = pairs[i + 1]; + if (config[key] !== expected) { + fail(key + " expected " + expected + ", got " + JSON.stringify(config[key])); + } +} +' "$REGISTRY" "$ACTIVE_SANDBOX" \ + TELEGRAM_ALLOWED_IDS "$TELEGRAM_ALLOWED_IDS" \ + TELEGRAM_REQUIRE_MENTION "$TELEGRAM_REQUIRE_MENTION" \ + DISCORD_SERVER_ID "$DISCORD_SERVER_ID" \ + DISCORD_USER_ID "$DISCORD_USER_ID" \ + DISCORD_REQUIRE_MENTION "$DISCORD_REQUIRE_MENTION" \ + SLACK_ALLOWED_USERS "$SLACK_ALLOWED_USERS" \ + WECHAT_ALLOWED_IDS "$WECHAT_ALLOWED_IDS" 2>&1)"; then + msg="${ACTIVE_AGENT}: host registry messagingChannelConfig persists channel config ${context}" + pass_msg "$msg" + else + msg="${ACTIVE_AGENT}: host registry messagingChannelConfig missing channel config ${context}: ${output}" + fail_msg "$msg" + fi +} + +assert_host_messaging_plan_state() { + local expected="$1" + local context="$2" + local channel output msg + for channel in "${CHANNELS[@]}"; do + if output="$(node -e ' +const fs = require("fs"); +const [registryPath, sandboxName, agent, channelId, expected] = process.argv.slice(1); +const fail = (message) => { + console.error(message); + process.exit(1); +}; +if (!fs.existsSync(registryPath)) fail("registry file not found: " + registryPath); +const registry = JSON.parse(fs.readFileSync(registryPath, "utf8")); +const entry = registry.sandboxes?.[sandboxName]; +if (!entry) fail("sandbox " + sandboxName + " missing from registry"); +const state = entry.messaging; +if (!state || state.schemaVersion !== 1) fail("messaging state missing or schemaVersion != 1"); +const plan = state.plan; +if (!plan || plan.schemaVersion !== 1) fail("messaging.plan missing or schemaVersion != 1"); +if (plan.sandboxName !== sandboxName) { + fail("messaging.plan.sandboxName expected " + sandboxName + ", got " + JSON.stringify(plan.sandboxName)); +} +if (plan.agent !== agent) fail("messaging.plan.agent expected " + agent + ", got " + JSON.stringify(plan.agent)); +const channels = Array.isArray(plan.channels) ? plan.channels : []; +const channel = channels.find((item) => item?.channelId === channelId); +if (!channel) fail(channelId + " missing from messaging.plan.channels"); +if (channel.configured !== true) { + fail(channelId + " messaging.plan configured expected true, got " + JSON.stringify(channel.configured)); +} +const disabledChannels = Array.isArray(plan.disabledChannels) ? plan.disabledChannels : []; +if (expected === "active") { + if (channel.active !== true) fail(channelId + " messaging.plan active expected true, got " + JSON.stringify(channel.active)); + if (channel.disabled === true) fail(channelId + " messaging.plan disabled unexpectedly true"); + if (disabledChannels.includes(channelId)) fail(channelId + " unexpectedly listed in messaging.plan.disabledChannels"); +} else if (expected === "disabled") { + if (channel.disabled !== true) fail(channelId + " messaging.plan disabled expected true, got " + JSON.stringify(channel.disabled)); + if (channel.active === true) fail(channelId + " messaging.plan active unexpectedly true"); + if (!disabledChannels.includes(channelId)) fail(channelId + " missing from messaging.plan.disabledChannels"); +} else { + fail("unknown expected plan state: " + expected); +} +const networkEntries = Array.isArray(plan.networkPolicy?.entries) ? plan.networkPolicy.entries : []; +const networkPresets = Array.isArray(plan.networkPolicy?.presets) ? plan.networkPolicy.presets : []; +if (!networkPresets.includes(channelId)) fail(channelId + " missing from messaging.plan.networkPolicy.presets"); +if (!networkEntries.some((entry) => entry?.channelId === channelId)) { + fail(channelId + " missing from messaging.plan.networkPolicy.entries"); +} +const credentialBindings = Array.isArray(plan.credentialBindings) ? plan.credentialBindings : []; +if (channelId !== "whatsapp" && !credentialBindings.some((entry) => entry?.channelId === channelId)) { + fail(channelId + " credential binding missing from messaging.plan"); +} +const agentRender = Array.isArray(plan.agentRender) ? plan.agentRender : []; +if (!agentRender.some((entry) => entry?.channelId === channelId && entry?.agent === agent)) { + fail(channelId + " " + agent + " render entry missing from messaging.plan"); +} +' "$REGISTRY" "$ACTIVE_SANDBOX" "$ACTIVE_AGENT" "$channel" "$expected" 2>&1)"; then + msg="${ACTIVE_AGENT}/${channel}: host registry messaging.plan has channel ${expected} ${context}" + pass_msg "$msg" + else + msg="${ACTIVE_AGENT}/${channel}: host registry messaging.plan expected ${expected} ${context}: ${output}" + fail_msg "$msg" + fi + done +} + assert_provider_records_exist() { local context="$1" local channel provider msg @@ -624,12 +731,16 @@ run_agent_scenario() { assert_all_config_channels "present" "at baseline" assert_registry_channels "present" "at baseline" assert_disabled_channels "absent" "at baseline" + assert_host_messaging_config "at baseline" + assert_host_messaging_plan_state "active" "at baseline" for channel in "${CHANNELS[@]}"; do assert_policy_preset_active "$channel" "active" "at baseline" done section "${agent}: channels stop all + rebuild" stop_all_channels + assert_host_messaging_config "after channels stop" + assert_host_messaging_plan_state "disabled" "after channels stop" run_rebuild "stop-all" section "${agent}: verify stopped state" @@ -637,9 +748,13 @@ run_agent_scenario() { assert_registry_channels "present" "after stop" assert_disabled_channels "present" "after stop" assert_provider_records_exist "after stop" + assert_host_messaging_config "after stop+rebuild" + assert_host_messaging_plan_state "disabled" "after stop+rebuild" section "${agent}: channels start all + rebuild" start_all_channels + assert_host_messaging_config "after channels start" + assert_host_messaging_plan_state "active" "after channels start" run_rebuild "start-all" section "${agent}: verify restarted state" @@ -647,6 +762,8 @@ run_agent_scenario() { assert_registry_channels "present" "after start" assert_disabled_channels "absent" "after start" assert_provider_records_exist "after start" + assert_host_messaging_config "after start+rebuild" + assert_host_messaging_plan_state "active" "after start+rebuild" } section "Phase 0: Prerequisites" From 0538b979ac9be6d8bf1135cba00fc9ea2caf0e83 Mon Sep 17 00:00:00 2001 From: San Dang Date: Thu, 4 Jun 2026 17:00:18 +0700 Subject: [PATCH 40/40] test(e2e): accept wechat build-step plan entries Signed-off-by: San Dang --- test/e2e/test-channels-stop-start.sh | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/e2e/test-channels-stop-start.sh b/test/e2e/test-channels-stop-start.sh index 38b3f20d13..eb6aa6ac08 100755 --- a/test/e2e/test-channels-stop-start.sh +++ b/test/e2e/test-channels-stop-start.sh @@ -413,8 +413,11 @@ if (channelId !== "whatsapp" && !credentialBindings.some((entry) => entry?.chann fail(channelId + " credential binding missing from messaging.plan"); } const agentRender = Array.isArray(plan.agentRender) ? plan.agentRender : []; -if (!agentRender.some((entry) => entry?.channelId === channelId && entry?.agent === agent)) { - fail(channelId + " " + agent + " render entry missing from messaging.plan"); +const buildSteps = Array.isArray(plan.buildSteps) ? plan.buildSteps : []; +const hasAgentRender = agentRender.some((entry) => entry?.channelId === channelId && entry?.agent === agent); +const hasBuildStep = buildSteps.some((entry) => entry?.channelId === channelId); +if (!hasAgentRender && !hasBuildStep) { + fail(channelId + " " + agent + " render/build-step entry missing from messaging.plan"); } ' "$REGISTRY" "$ACTIVE_SANDBOX" "$ACTIVE_AGENT" "$channel" "$expected" 2>&1)"; then msg="${ACTIVE_AGENT}/${channel}: host registry messaging.plan has channel ${expected} ${context}"