diff --git a/agents/hermes/Dockerfile.base b/agents/hermes/Dockerfile.base index 5a2b024de7a..df1c8707b24 100644 --- a/agents/hermes/Dockerfile.base +++ b/agents/hermes/Dockerfile.base @@ -158,7 +158,7 @@ RUN printf '%s\n' \ # Install Hermes Agent from the selected GitHub release. # The image prebakes only the extras mapped to NemoClaw-supported onboarding # integrations: anthropic (native Anthropic Messages routing), messaging -# (Telegram, Discord, Slack, WeChat, WhatsApp), web (API health/UI runtime), +# (Telegram, Discord, Slack, WeChat, WeCom, WhatsApp), web (API health/UI runtime), # and pty (optional browser TUI bridge). These extras are resolved from the # selected Hermes release's uv.lock via `uv sync --frozen`, so dependency # changes remain tied to HERMES_VERSION/HERMES_TARBALL_SHA256 review. diff --git a/agents/hermes/manifest.yaml b/agents/hermes/manifest.yaml index ae9fc8960d9..7bc944a789e 100644 --- a/agents/hermes/manifest.yaml +++ b/agents/hermes/manifest.yaml @@ -114,6 +114,7 @@ messaging_platforms: - discord - slack - wechat + - wecom - whatsapp - teams # Future: signal, matrix, mattermost, email, etc. diff --git a/agents/hermes/policy-additions.yaml b/agents/hermes/policy-additions.yaml index fa52431a967..ed6510d2c9a 100644 --- a/agents/hermes/policy-additions.yaml +++ b/agents/hermes/policy-additions.yaml @@ -363,3 +363,31 @@ network_policies: - { path: /usr/local/bin/hermes } - { path: /usr/bin/python3* } - { path: /opt/hermes/.venv/bin/python } + + # WeCom AI Bot via the Enterprise WeChat WebSocket gateway. WECOM_BOT_ID and + # WECOM_SECRET are L7-resolved from manifest render outputs. + wecom_aibot: + name: wecom_aibot + endpoints: + - host: openws.work.weixin.qq.com + port: 443 + protocol: websocket + enforcement: enforce + websocket_credential_rewrite: true + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: WEBSOCKET_TEXT, path: "/**" } + - host: qyapi.weixin.qq.com + port: 443 + protocol: rest + enforcement: enforce + request_body_credential_rewrite: true + rules: + - allow: { method: GET, path: "/cgi-bin/media/get" } + - allow: { method: POST, path: "/cgi-bin/message/send" } + - allow: { method: POST, path: "/cgi-bin/appchat/send" } + - allow: { method: POST, path: "/cgi-bin/media/upload" } + binaries: + - { path: /usr/local/bin/hermes } + - { path: /usr/bin/python3* } + - { path: /opt/hermes/.venv/bin/python } diff --git a/agents/hermes/start.sh b/agents/hermes/start.sh index 1a9b5c12759..3fa1faf9b96 100755 --- a/agents/hermes/start.sh +++ b/agents/hermes/start.sh @@ -729,9 +729,13 @@ _PROXY_URL="http://${PROXY_HOST}:${PROXY_PORT}" _NO_PROXY_VAL="localhost,127.0.0.1,::1,${PROXY_HOST}" export HTTP_PROXY="$_PROXY_URL" export HTTPS_PROXY="$_PROXY_URL" +export WS_PROXY="$_PROXY_URL" +export WSS_PROXY="$_PROXY_URL" export NO_PROXY="$_NO_PROXY_VAL" export http_proxy="$_PROXY_URL" export https_proxy="$_PROXY_URL" +export ws_proxy="$_PROXY_URL" +export wss_proxy="$_PROXY_URL" export no_proxy="$_NO_PROXY_VAL" # OpenShell injects SSL_CERT_FILE/CURL_CA_BUNDLE for its L7 proxy CA. Persist @@ -764,9 +768,13 @@ write_runtime_shell_env() { # Proxy configuration (overrides narrow OpenShell defaults on connect) export HTTP_PROXY="$_PROXY_URL" export HTTPS_PROXY="$_PROXY_URL" +export WS_PROXY="$_PROXY_URL" +export WSS_PROXY="$_PROXY_URL" export NO_PROXY="$_NO_PROXY_VAL" export http_proxy="$_PROXY_URL" export https_proxy="$_PROXY_URL" +export ws_proxy="$_PROXY_URL" +export wss_proxy="$_PROXY_URL" export no_proxy="$_NO_PROXY_VAL" export HERMES_HOME="${HERMES_DIR}" PROXYEOF diff --git a/agents/openclaw/manifest.yaml b/agents/openclaw/manifest.yaml index 3ff1099d6c1..e195f18b610 100644 --- a/agents/openclaw/manifest.yaml +++ b/agents/openclaw/manifest.yaml @@ -52,6 +52,7 @@ state_dirs: - memory - telegram - wechat + - wecom - whatsapp - credentials @@ -79,6 +80,7 @@ messaging_platforms: - discord - slack - wechat + - wecom - whatsapp - teams diff --git a/ci/test-file-size-budget.json b/ci/test-file-size-budget.json index 5056f6c38f9..cfe7a9301b1 100644 --- a/ci/test-file-size-budget.json +++ b/ci/test-file-size-budget.json @@ -5,7 +5,7 @@ "nemoclaw/src/commands/migration-state.test.ts": 1566, "src/lib/inference/nim.test.ts": 2068, "src/lib/onboard/preflight.test.ts": 1905, - "test/channels-add-preset.test.ts": 1871, + "test/channels-add-preset.test.ts": 1869, "test/generate-openclaw-config.test.ts": 1984, "test/install-preflight.test.ts": 4006, "test/nemoclaw-start.test.ts": 5043, diff --git a/nemoclaw-blueprint/policies/presets/wecom.yaml b/nemoclaw-blueprint/policies/presets/wecom.yaml new file mode 100644 index 00000000000..b9a87b05a81 --- /dev/null +++ b/nemoclaw-blueprint/policies/presets/wecom.yaml @@ -0,0 +1,35 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +preset: + name: wecom + description: "WeCom AI Bot WebSocket and API access (OpenClaw + Hermes)" + +network_policies: + wecom_aibot: + name: wecom_aibot + endpoints: + - host: openws.work.weixin.qq.com + port: 443 + protocol: websocket + enforcement: enforce + websocket_credential_rewrite: true + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: WEBSOCKET_TEXT, path: "/**" } + - host: qyapi.weixin.qq.com + port: 443 + protocol: rest + enforcement: enforce + request_body_credential_rewrite: true + rules: + - allow: { method: GET, path: "/cgi-bin/media/get" } + - allow: { method: POST, path: "/cgi-bin/message/send" } + - allow: { method: POST, path: "/cgi-bin/appchat/send" } + - allow: { method: POST, path: "/cgi-bin/media/upload" } + binaries: + - { path: /usr/local/bin/node } + - { path: /usr/bin/node } + - { path: /usr/local/bin/hermes } + - { path: /usr/bin/python3* } + - { path: /opt/hermes/.venv/bin/python } diff --git a/nemoclaw-blueprint/policies/tiers.yaml b/nemoclaw-blueprint/policies/tiers.yaml index 85b98e334c0..a019c8cabaf 100644 --- a/nemoclaw-blueprint/policies/tiers.yaml +++ b/nemoclaw-blueprint/policies/tiers.yaml @@ -43,6 +43,7 @@ tiers: - { name: discord, access: read-write } - { name: telegram, access: read-write } - { name: wechat, access: read-write } + - { name: wecom, access: read-write } - { name: whatsapp, access: read-write } - { name: teams, access: read-write } - { name: jira, access: read-write } diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index a566b3556aa..152e4ce672d 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -2436,9 +2436,13 @@ _PROXY_URL="http://${PROXY_HOST}:${PROXY_PORT}" _NO_PROXY_VAL="localhost,127.0.0.1,::1,${PROXY_HOST}" export HTTP_PROXY="$_PROXY_URL" export HTTPS_PROXY="$_PROXY_URL" +export WS_PROXY="$_PROXY_URL" +export WSS_PROXY="$_PROXY_URL" export NO_PROXY="$_NO_PROXY_VAL" export http_proxy="$_PROXY_URL" export https_proxy="$_PROXY_URL" +export ws_proxy="$_PROXY_URL" +export wss_proxy="$_PROXY_URL" export no_proxy="$_NO_PROXY_VAL" # Git TLS CA bundle fix (NemoClaw#2270). @@ -2585,7 +2589,8 @@ export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--require $_SECCOMP_GUARD_SC # # Both uppercase and lowercase variants are required: Node.js undici prefers # lowercase (no_proxy) over uppercase (NO_PROXY) when both are set. -# curl/wget use uppercase. gRPC C-core uses lowercase. +# curl/wget use uppercase. gRPC C-core uses lowercase. aiohttp maps +# wss:// and ws:// lookups through WSS_PROXY and WS_PROXY respectively. _RUNTIME_SHELL_ENV_FILE="/tmp/nemoclaw-proxy-env.sh" _RUNTIME_SHELL_ENV_SHIM="[ -f ${_RUNTIME_SHELL_ENV_FILE} ] && . ${_RUNTIME_SHELL_ENV_FILE}" @@ -2596,9 +2601,13 @@ write_runtime_shell_env() { # Proxy configuration (overrides narrow OpenShell defaults on connect) export HTTP_PROXY="$_PROXY_URL" export HTTPS_PROXY="$_PROXY_URL" +export WS_PROXY="$_PROXY_URL" +export WSS_PROXY="$_PROXY_URL" export NO_PROXY="$_NO_PROXY_VAL" export http_proxy="$_PROXY_URL" export https_proxy="$_PROXY_URL" +export ws_proxy="$_PROXY_URL" +export wss_proxy="$_PROXY_URL" export no_proxy="$_NO_PROXY_VAL" export JITI_FS_CACHE="false" PROXYEOF diff --git a/src/lib/agent/defs.test.ts b/src/lib/agent/defs.test.ts index 770d610abc6..819b951f534 100644 --- a/src/lib/agent/defs.test.ts +++ b/src/lib/agent/defs.test.ts @@ -53,6 +53,7 @@ describe("agent definitions", () => { "discord", "slack", "wechat", + "wecom", "whatsapp", "teams", ]); @@ -93,6 +94,7 @@ describe("agent definitions", () => { "discord", "slack", "wechat", + "wecom", "whatsapp", "teams", ]); diff --git a/src/lib/channel-runtime-status.test.ts b/src/lib/channel-runtime-status.test.ts index 3bf795656d1..e502073bcc2 100644 --- a/src/lib/channel-runtime-status.test.ts +++ b/src/lib/channel-runtime-status.test.ts @@ -124,6 +124,20 @@ describe("extractEnabledChannelsFromOpenclawConfig", () => { expect(extractEnabledChannelsFromOpenclawConfig(config)).toEqual(["telegram"]); }); + it("recognizes rendered accountless WeCom config", () => { + const config = { + channels: { + wecom: { + enabled: true, + connectionMode: "websocket", + botId: "openshell:resolve:env:WECOM_BOT_ID", + secret: "openshell:resolve:env:WECOM_SECRET", + }, + }, + }; + expect(extractEnabledChannelsFromOpenclawConfig(config)).toEqual(["wecom"]); + }); + it("treats missing accounts block as no enabled accounts", () => { const config = { channels: { @@ -151,7 +165,15 @@ describe("buildGatewayLogScanScript", () => { expect(script).toContain("(launched|respawning)"); expect(script).toContain('buf=""'); expect(script).toContain("grep -iwoE '"); - for (const token of ["telegram", "discord", "slack", "whatsapp", "wechat", "openclaw-weixin"]) { + for (const token of [ + "telegram", + "discord", + "slack", + "whatsapp", + "wechat", + "wecom", + "openclaw-weixin", + ]) { expect(script).toContain(token); } expect(script).not.toContain("tail -n"); @@ -311,6 +333,28 @@ describe("probeChannelRuntimeStatus", () => { expect(result.configuredButNotRunning).toEqual([]); }); + it("treats rendered WeCom config as visible when the gateway log mentions it", () => { + const config = JSON.stringify({ + channels: { + wecom: { + enabled: true, + connectionMode: "websocket", + botId: "openshell:resolve:env:WECOM_BOT_ID", + secret: "openshell:resolve:env:WECOM_SECRET", + }, + }, + }); + const result = probeChannelRuntimeStatus({ + configFilePath: "/sandbox/.openclaw/openclaw.json", + executeSandboxCommand: makeMockExec(config, ["WeCom"]), + }); + expect(result.ok).toBe(true); + expect(result.logProbeOk).toBe(true); + expect(result.configuredChannels).toEqual(["wecom"]); + expect(result.visibleChannels).toEqual(["wecom"]); + expect(result.configuredButNotRunning).toEqual([]); + }); + it("flags a configured channel as not-running when the gateway log never mentions it (#4156 reporter case)", () => { // Reporter symptom: openclaw.json had the telegram block but the // dashboard rendered "No channels found." This is the failure mode — diff --git a/src/lib/channel-runtime-status.ts b/src/lib/channel-runtime-status.ts index f6642333fa7..dcf2ba1fc0e 100644 --- a/src/lib/channel-runtime-status.ts +++ b/src/lib/channel-runtime-status.ts @@ -112,13 +112,18 @@ export function extractEnabledChannelsFromOpenclawConfig(json: unknown): string[ if (!json || typeof json !== "object") return []; const channels = (json as Record).channels; if (!channels || typeof channels !== "object") return []; - const channelKeyToName = runtimeConfigKeyToChannelName(DEFAULT_RUNTIME_VISIBILITY_METADATA); + const metadataByConfigKey = runtimeConfigKeyToMetadata(DEFAULT_RUNTIME_VISIBILITY_METADATA); const visible = new Set(); for (const [key, value] of Object.entries(channels as Record)) { - const canonical = channelKeyToName.get(key); - if (!canonical) continue; + const metadata = metadataByConfigKey.get(key); + if (!metadata) continue; if (!value || typeof value !== "object") continue; - const accounts = (value as Record).accounts; + const channelConfig = value as Record; + if (metadata.configShape === "enabled-flag") { + if (channelConfig.enabled === true) visible.add(metadata.channelId); + continue; + } + const accounts = channelConfig.accounts; if (!accounts || typeof accounts !== "object") continue; for (const account of Object.values(accounts as Record)) { if ( @@ -126,7 +131,7 @@ export function extractEnabledChannelsFromOpenclawConfig(json: unknown): string[ typeof account === "object" && (account as Record).enabled === true ) { - visible.add(canonical); + visible.add(metadata.channelId); break; } } @@ -216,13 +221,13 @@ export function parseGatewayLogScanOutput(stdout: string): Set { const DEFAULT_GATEWAY_LOG_PATH = "/tmp/gateway.log"; -function runtimeConfigKeyToChannelName( +function runtimeConfigKeyToMetadata( outputs: readonly OpenClawRuntimeChannelMetadata[], -): ReadonlyMap { - const aliases = new Map(); +): ReadonlyMap { + const aliases = new Map(); for (const output of outputs) { for (const key of output.configKeys) { - aliases.set(key, output.channelId); + aliases.set(key, output); } } return aliases; diff --git a/src/lib/messaging-channel-config.test.ts b/src/lib/messaging-channel-config.test.ts index a9678dac994..181f5e211ad 100644 --- a/src/lib/messaging-channel-config.test.ts +++ b/src/lib/messaging-channel-config.test.ts @@ -19,6 +19,7 @@ describe("messaging channel config", () => { "DISCORD_USER_ID", "DISCORD_REQUIRE_MENTION", "WECHAT_ALLOWED_IDS", + "WECOM_ALLOWED_USERS", "SLACK_ALLOWED_USERS", "SLACK_ALLOWED_CHANNELS", "WHATSAPP_ALLOWED_IDS", @@ -28,6 +29,7 @@ describe("messaging channel config", () => { "WECHAT_ACCOUNT_ID", "WECHAT_BASE_URL", "WECHAT_USER_ID", + "WECOM_DM_POLICY", "MSTEAMS_APP_ID", "MSTEAMS_TENANT_ID", "MSTEAMS_PORT", @@ -45,6 +47,8 @@ describe("messaging channel config", () => { DISCORD_REQUIRE_MENTION: "0", SLACK_ALLOWED_USERS: " U01ABC2DEF3, U04GHI5JKL6 ", SLACK_ALLOWED_CHANNELS: " C012AB3CD, C987ZY6XW ", + WECOM_ALLOWED_USERS: " zhangsan,lisi ", + WECOM_DM_POLICY: "pairing", TEAMS_ALLOWED_USERS: " aad-one, aad-two ", TEAMS_REQUIRE_MENTION: "1", MSTEAMS_APP_ID: " teams-app ", @@ -59,6 +63,8 @@ describe("messaging channel config", () => { DISCORD_REQUIRE_MENTION: "0", SLACK_ALLOWED_USERS: "U01ABC2DEF3, U04GHI5JKL6", SLACK_ALLOWED_CHANNELS: "C012AB3CD, C987ZY6XW", + WECOM_ALLOWED_USERS: "zhangsan,lisi", + WECOM_DM_POLICY: "pairing", TEAMS_ALLOWED_USERS: "aad-one, aad-two", TEAMS_REQUIRE_MENTION: "1", MSTEAMS_APP_ID: "teams-app", diff --git a/src/lib/messaging/AGENTS.md b/src/lib/messaging/AGENTS.md index f8c303e5e53..ca8604a3e78 100644 --- a/src/lib/messaging/AGENTS.md +++ b/src/lib/messaging/AGENTS.md @@ -5,7 +5,7 @@ ## Purpose -This package owns NemoClaw's manifest-first messaging architecture. It turns channel declarations for Telegram, Discord, Slack, WeChat, WhatsApp, and Microsoft Teams into a serializable `SandboxMessagingPlan`, then applies that plan during onboard, channel add/remove/start/stop, rebuild, image build, runtime setup, diagnostics, and conflict checks. +This package owns NemoClaw's manifest-first messaging architecture. It turns channel declarations for Telegram, Discord, Slack, WeChat, WeCom, WhatsApp, and Microsoft Teams into a serializable `SandboxMessagingPlan`, then applies that plan during onboard, channel add/remove/start/stop, rebuild, image build, runtime setup, diagnostics, and conflict checks. The design goal is to keep messaging channel behavior out of core onboard/rebuild logic. Add channel-specific behavior to manifests, template resolvers, hooks, runtime assets, and policy metadata first; only change shared engines when the manifest vocabulary cannot express the required behavior. @@ -76,7 +76,7 @@ Use the narrowest test that covers the changed surface: - Build-time render/install behavior: `npx vitest run test/messaging-build-applier.test.ts` - Onboard/channel CLI integration: `npx vitest run test/onboard-messaging.test.ts test/channels-add-preset.test.ts src/lib/onboard/messaging-channel-setup.test.ts` -Mock external messaging APIs. Do not call real Telegram, Discord, Slack, WeChat, WhatsApp, Microsoft Teams, NVIDIA, or OpenShell services from unit tests. +Mock external messaging APIs. Do not call real Telegram, Discord, Slack, WeChat, WeCom, WhatsApp, Microsoft Teams, NVIDIA, or OpenShell services from unit tests. ## Documentation diff --git a/src/lib/messaging/applier/setup-applier.test.ts b/src/lib/messaging/applier/setup-applier.test.ts index 248e80688ca..2b2d5b2ec13 100644 --- a/src/lib/messaging/applier/setup-applier.test.ts +++ b/src/lib/messaging/applier/setup-applier.test.ts @@ -26,6 +26,8 @@ const TEST_CREDENTIALS: Readonly> = { TELEGRAM_BOT_TOKEN: "123456:test-telegram-token", DISCORD_BOT_TOKEN: "test-discord-token", WECHAT_BOT_TOKEN: "test-wechat-token", + WECOM_BOT_ID: "test-wecom-bot-id", + WECOM_SECRET: "test-wecom-secret", SLACK_BOT_TOKEN: "xoxb-test-slack-token", SLACK_APP_TOKEN: "xapp-test-slack-token", }; @@ -299,6 +301,68 @@ describe("MessagingSetupApplier", () => { expect(JSON.stringify(result)).not.toContain("slack-token"); }); + it("upserts both WeCom credential providers from plan bindings", async () => { + const plan = await buildOnboardPlan( + { WECOM_BOT_ID: "raw-wecom-bot-id-secret", WECOM_SECRET: "raw-wecom-secret-value" }, + ["wecom"], + ); + const calls: Array<{ + args: readonly string[]; + env?: Readonly>; + }> = []; + const runOpenshell: MessagingOpenShellRunner = (args, options) => { + calls.push({ args, env: options?.env }); + return { status: args[0] === "provider" && args[1] === "get" ? 1 : 0 }; + }; + + const result = MessagingSetupApplier.applyCredentialsAtOpenShell(plan, { + env: { + WECOM_BOT_ID: "raw-wecom-bot-id-secret", + WECOM_SECRET: "raw-wecom-secret-value", + }, + runOpenshell, + }); + + expect(calls.map((call) => call.args)).toEqual([ + ["provider", "get", "demo-wecom-bot-id"], + [ + "provider", + "create", + "--name", + "demo-wecom-bot-id", + "--type", + "generic", + "--credential", + "WECOM_BOT_ID", + ], + ["provider", "get", "demo-wecom-secret"], + [ + "provider", + "create", + "--name", + "demo-wecom-secret", + "--type", + "generic", + "--credential", + "WECOM_SECRET", + ], + ]); + expect(calls[1]?.env).toEqual({ WECOM_BOT_ID: "raw-wecom-bot-id-secret" }); + expect(calls[3]?.env).toEqual({ WECOM_SECRET: "raw-wecom-secret-value" }); + expect(result.upserted.map((entry) => `${entry.action}:${entry.providerName}`)).toEqual([ + "create:demo-wecom-bot-id", + "create:demo-wecom-secret", + ]); + expect(result.sandboxCreateProviderArgs).toEqual([ + "--provider", + "demo-wecom-bot-id", + "--provider", + "demo-wecom-secret", + ]); + expect(JSON.stringify(result)).not.toContain("raw-wecom-bot-id-secret"); + expect(JSON.stringify(result)).not.toContain("raw-wecom-secret-value"); + }); + it("redacts OpenShell provider failure output", async () => { const plan = await buildOnboardPlan({ TELEGRAM_BOT_TOKEN: "tokensecretvalue" }, ["telegram"]); const runOpenshell: MessagingOpenShellRunner = (args) => { @@ -810,10 +874,12 @@ describe("MessagingSetupApplier", () => { DISCORD_BOT_TOKEN: "test-discord-token", WECHAT_BOT_TOKEN: "test-wechat-token", WECHAT_ACCOUNT_ID: "wechat-account", + WECOM_BOT_ID: "test-wecom-bot-id", + WECOM_SECRET: "test-wecom-secret", SLACK_BOT_TOKEN: "xoxb-slack-token", SLACK_APP_TOKEN: "xapp-slack-token", }, - ["discord", "wechat", "slack"], + ["discord", "wechat", "wecom", "slack"], "hermes", ); const policyCalls: string[][] = []; @@ -827,15 +893,15 @@ describe("MessagingSetupApplier", () => { }, }); - expect(policyCalls).toEqual([["demo", "discord", "wechat", "slack"]]); + expect(policyCalls).toEqual([["demo", "discord", "wechat", "wecom", "slack"]]); expect(applyContext).toEqual({ agent: "hermes", entries: plan.networkPolicy.entries, - policyKeys: ["discord", "wechat_bridge", "slack"], + policyKeys: ["discord", "wechat_bridge", "wecom_aibot", "slack"], }); expect(result).toEqual({ - appliedPresets: ["discord", "wechat", "slack"], - appliedPolicyKeys: ["discord", "wechat_bridge", "slack"], + appliedPresets: ["discord", "wechat", "wecom", "slack"], + appliedPolicyKeys: ["discord", "wechat_bridge", "wecom_aibot", "slack"], }); }); }); diff --git a/src/lib/messaging/channels/built-ins.ts b/src/lib/messaging/channels/built-ins.ts index aeb84391148..9a4f3fd77ce 100644 --- a/src/lib/messaging/channels/built-ins.ts +++ b/src/lib/messaging/channels/built-ins.ts @@ -8,6 +8,7 @@ import { slackManifest } from "./slack/manifest"; import { telegramManifest } from "./telegram/manifest"; import { teamsManifest } from "./teams/manifest"; import { wechatManifest } from "./wechat/manifest"; +import { wecomManifest } from "./wecom/manifest"; import { whatsappManifest } from "./whatsapp/manifest"; export { discordManifest } from "./discord/manifest"; @@ -15,12 +16,14 @@ export { slackManifest } from "./slack/manifest"; export { telegramManifest } from "./telegram/manifest"; export { teamsManifest } from "./teams/manifest"; export { wechatManifest } from "./wechat/manifest"; +export { wecomManifest } from "./wecom/manifest"; export { whatsappManifest } from "./whatsapp/manifest"; export const BUILT_IN_CHANNEL_MANIFESTS = [ telegramManifest, discordManifest, wechatManifest, + wecomManifest, slackManifest, whatsappManifest, teamsManifest, diff --git a/src/lib/messaging/channels/manifests.test.ts b/src/lib/messaging/channels/manifests.test.ts index 8d08c248b18..518c4b91b41 100644 --- a/src/lib/messaging/channels/manifests.test.ts +++ b/src/lib/messaging/channels/manifests.test.ts @@ -24,6 +24,7 @@ import { teamsManifest, telegramManifest, wechatManifest, + wecomManifest, whatsappManifest, } from "./index"; import { @@ -177,11 +178,13 @@ function expectOpenClawRuntimeVisibility( configKeys: readonly string[], logPatterns: readonly string[], channelName = configKeys[0], + configShape?: "accounts" | "enabled-flag", ): void { expect(manifest.runtime?.openclaw?.channelName).toBe(channelName); expect(manifest.runtime?.openclaw?.visibility).toEqual({ configKeys, logPatterns, + ...(configShape ? { configShape } : {}), }); } @@ -201,6 +204,7 @@ describe("built-in channel manifests", () => { "telegram", "discord", "wechat", + "wecom", "slack", "whatsapp", "teams", @@ -209,6 +213,7 @@ describe("built-in channel manifests", () => { "telegram", "discord", "wechat", + "wecom", "slack", "whatsapp", "teams", @@ -230,6 +235,7 @@ describe("built-in channel manifests", () => { "src/lib/messaging/channels/discord/hooks/index.ts", "src/lib/messaging/channels/discord/hooks/openclaw-bridge-health.ts", "src/lib/messaging/channels/wechat/manifest.ts", + "src/lib/messaging/channels/wecom/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", @@ -269,6 +275,7 @@ describe("built-in channel manifests", () => { telegram: telegramManifest, discord: discordManifest, wechat: wechatManifest, + wecom: wecomManifest, slack: slackManifest, whatsapp: whatsappManifest, teams: teamsManifest, @@ -302,6 +309,14 @@ describe("built-in channel manifests", () => { label: KNOWN_CHANNELS.wechat.label, help: KNOWN_CHANNELS.wechat.help, }); + expect(findInput(wecomManifest, "botId").prompt).toEqual({ + label: KNOWN_CHANNELS.wecom.label, + help: KNOWN_CHANNELS.wecom.help, + }); + expect(findInput(wecomManifest, "secret").prompt).toEqual({ + label: KNOWN_CHANNELS.wecom.appTokenLabel, + help: KNOWN_CHANNELS.wecom.appTokenHelp, + }); expect(findInput(teamsManifest, "clientSecret").prompt).toEqual({ label: KNOWN_CHANNELS.teams.label, help: KNOWN_CHANNELS.teams.help, @@ -617,6 +632,88 @@ describe("built-in channel manifests", () => { ); }); + it("declares WeCom bot credentials, access policies, and OpenClaw/Hermes render intent", () => { + const botId = findInput(wecomManifest, "botId"); + const secret = findInput(wecomManifest, "secret"); + const allowedUsers = findInput(wecomManifest, "allowedUsers"); + const dmPolicy = findInput(wecomManifest, "dmPolicy"); + + expect(getChannelTokenKeys(KNOWN_CHANNELS.wecom)).toEqual(["WECOM_BOT_ID", "WECOM_SECRET"]); + expect(botId.envKey).toBe("WECOM_BOT_ID"); + expect(secret.envKey).toBe("WECOM_SECRET"); + expect(allowedUsers).toMatchObject({ + kind: "config", + envKey: "WECOM_ALLOWED_USERS", + statePath: "allowedIds.wecom", + }); + expect(dmPolicy).toMatchObject({ + kind: "config", + envKey: "WECOM_DM_POLICY", + statePath: "wecomConfig.dmPolicy", + defaultValue: "open", + validValues: ["open", "allowlist", "disabled", "pairing"], + }); + expect(wecomManifest.credentials).toEqual([ + { + id: "wecomBotId", + sourceInput: "botId", + providerName: "{sandboxName}-wecom-bot-id", + providerEnvKey: "WECOM_BOT_ID", + placeholder: "openshell:resolve:env:WECOM_BOT_ID", + primary: true, + }, + { + id: "wecomSecret", + sourceInput: "secret", + providerName: "{sandboxName}-wecom-secret", + providerEnvKey: "WECOM_SECRET", + placeholder: "openshell:resolve:env:WECOM_SECRET", + }, + ]); + expect(wecomManifest.policyPresets).toEqual([{ name: "wecom", policyKeys: ["wecom_aibot"] }]); + expectEnvRenderLines(wecomManifest, "wecom-hermes-env", [ + "WECOM_BOT_ID={{credential.wecomBotId.placeholder}}", + "WECOM_SECRET={{credential.wecomSecret.placeholder}}", + "WECOM_ALLOWED_USERS={{allowedIds.wecom.csv}}", + "WECOM_DM_POLICY={{wecomConfig.dmPolicy}}", + ]); + expect(renderJson(wecomManifest)).toContain("channels.wecom"); + expect(renderJson(wecomManifest)).toContain("platforms.wecom"); + expect(renderJson(wecomManifest)).toContain("credential.wecomBotId.placeholder"); + expect(renderJson(wecomManifest)).toContain("credential.wecomSecret.placeholder"); + expect(renderJson(wecomManifest)).toContain("wecomConfig.dmPolicy"); + expect(renderJson(wecomManifest)).toContain("allowedIds.wecom.values"); + expect(renderJson(wecomManifest)).not.toContain("wecomConfig.groupAllowFrom"); + expect(renderJson(wecomManifest)).not.toContain("wecomConfig.websocketUrl"); + expect(wecomManifest.agentPackages).toContainEqual({ + id: "openclawPluginPackage", + agent: "openclaw", + manager: "openclaw-plugin", + spec: "npm:@wecom/wecom-openclaw-plugin@2026.5.25", + pin: true, + required: true, + }); + expect(wecomManifest.state).toEqual({ + persist: { + allowedIds: ["wecom"], + wecomConfig: ["dmPolicy"], + }, + rebuildHydration: [ + { statePath: "allowedIds.wecom", env: "WECOM_ALLOWED_USERS" }, + { statePath: "wecomConfig.dmPolicy", env: "WECOM_DM_POLICY" }, + ], + }); + expectTokenPasteEnrollHook(wecomManifest, ["botId", "secret"]); + expectConfigPromptEnrollHook(wecomManifest, ["allowedUsers", "dmPolicy"]); + expectOpenClawRuntimeVisibility( + wecomManifest, + ["wecom"], + ["wecom", "WeCom"], + "wecom", + "enabled-flag", + ); + }); + it("declares WhatsApp as in-sandbox QR with optional allowlist config", () => { const openclawRender = findRender(whatsappManifest, "whatsapp-openclaw-channel"); const hermesRender = findRender(whatsappManifest, "whatsapp-hermes-env"); diff --git a/src/lib/messaging/channels/metadata.test.ts b/src/lib/messaging/channels/metadata.test.ts index ff25fe4c6eb..294c5a3909c 100644 --- a/src/lib/messaging/channels/metadata.test.ts +++ b/src/lib/messaging/channels/metadata.test.ts @@ -28,6 +28,7 @@ describe("built-in messaging channel metadata", () => { "telegram", "discord", "wechat", + "wecom", "slack", "whatsapp", "teams", @@ -36,6 +37,7 @@ describe("built-in messaging channel metadata", () => { "telegram", "discord", "wechat", + "wecom", "slack", "whatsapp", "teams", @@ -47,16 +49,19 @@ describe("built-in messaging channel metadata", () => { telegram: ["TELEGRAM_BOT_TOKEN"], discord: ["DISCORD_BOT_TOKEN"], wechat: ["WECHAT_BOT_TOKEN"], + wecom: ["WECOM_BOT_ID", "WECOM_SECRET"], slack: ["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"], whatsapp: [], teams: ["MSTEAMS_APP_PASSWORD"], }); expect(getMessagingChannelForCredentialEnvKey("SLACK_APP_TOKEN")).toBe("slack"); + expect(getMessagingChannelForCredentialEnvKey("WECOM_SECRET")).toBe("wecom"); expect(getMessagingChannelForCredentialEnvKey("WHATSAPP_ALLOWED_IDS")).toBeNull(); expect(getMessagingProviderSuffixesByChannel()).toMatchObject({ telegram: ["-telegram-bridge"], discord: ["-discord-bridge"], wechat: ["-wechat-bridge"], + wecom: ["-wecom-bot-id", "-wecom-secret"], slack: ["-slack-bridge", "-slack-app"], teams: ["-teams-bridge"], }); @@ -79,6 +84,8 @@ describe("built-in messaging channel metadata", () => { "WECHAT_BASE_URL", "WECHAT_USER_ID", "WECHAT_ALLOWED_IDS", + "WECOM_ALLOWED_USERS", + "WECOM_DM_POLICY", "SLACK_ALLOWED_USERS", "SLACK_ALLOWED_CHANNELS", "WHATSAPP_ALLOWED_IDS", @@ -103,6 +110,7 @@ describe("built-in messaging channel metadata", () => { telegram: ["telegram_bot", "telegram"], discord: ["discord"], wechat: ["wechat_bridge"], + wecom: ["wecom_aibot"], slack: ["slack"], whatsapp: ["whatsapp"], teams: ["teams"], @@ -111,6 +119,7 @@ describe("built-in messaging channel metadata", () => { telegram: ["telegram"], discord: ["discord"], wechat: ["wechat_bridge"], + wecom: ["wecom_aibot"], slack: ["slack"], whatsapp: ["whatsapp"], teams: ["teams"], @@ -123,6 +132,7 @@ describe("built-in messaging channel metadata", () => { "telegram", "discord", "openclaw-weixin", + "wecom", "slack", "whatsapp", "msteams", @@ -135,6 +145,7 @@ describe("built-in messaging channel metadata", () => { telegram: ["telegram"], discord: ["discord"], wechat: ["openclaw-weixin"], + wecom: ["wecom"], slack: ["slack"], whatsapp: ["whatsapp"], teams: ["msteams"], @@ -149,6 +160,7 @@ describe("built-in messaging channel metadata", () => { ).toMatchObject({ discord: "npm:@openclaw/discord@{{openclaw.version}}", wechat: "npm:@tencent-weixin/openclaw-weixin@2.4.3", + wecom: "npm:@wecom/wecom-openclaw-plugin@2026.5.25", slack: "npm:@openclaw/slack@{{openclaw.version}}", whatsapp: "npm:@openclaw/whatsapp@{{openclaw.version}}", teams: "npm:@openclaw/msteams@{{openclaw.version}}", diff --git a/src/lib/messaging/channels/metadata.ts b/src/lib/messaging/channels/metadata.ts index 5142c681d03..2c3b7544060 100644 --- a/src/lib/messaging/channels/metadata.ts +++ b/src/lib/messaging/channels/metadata.ts @@ -48,6 +48,7 @@ export interface OpenClawRuntimeChannelMetadata { readonly channelId: string; readonly configKeys: readonly string[]; readonly logPatterns: readonly string[]; + readonly configShape: "accounts" | "enabled-flag"; } export interface MessagingPackageInstallMetadata { @@ -292,6 +293,7 @@ export function listOpenClawRuntimeChannelMetadata( channelId: manifest.id, configKeys: [...visibility.configKeys], logPatterns: [...visibility.logPatterns], + configShape: visibility.configShape ?? "accounts", }, ]; }); diff --git a/src/lib/messaging/channels/template-resolver.ts b/src/lib/messaging/channels/template-resolver.ts index 11c1190b3b6..8a41922555b 100644 --- a/src/lib/messaging/channels/template-resolver.ts +++ b/src/lib/messaging/channels/template-resolver.ts @@ -7,12 +7,14 @@ import { resolveTelegramTemplateReference } from "./telegram/template-resolver"; import { resolveTeamsTemplateReference } from "./teams/template-resolver"; import type { BuiltInRenderTemplateResolver } from "./template-resolver-utils"; import { resolveWechatTemplateReference } from "./wechat/template-resolver"; +import { resolveWecomTemplateReference } from "./wecom/template-resolver"; import { resolveWhatsappTemplateReference } from "./whatsapp/template-resolver"; const BUILT_IN_TEMPLATE_REFERENCE_RESOLVERS: readonly BuiltInRenderTemplateResolver[] = [ resolveTelegramTemplateReference, resolveDiscordTemplateReference, resolveWechatTemplateReference, + resolveWecomTemplateReference, resolveSlackTemplateReference, resolveWhatsappTemplateReference, resolveTeamsTemplateReference, diff --git a/src/lib/messaging/channels/wecom/manifest.ts b/src/lib/messaging/channels/wecom/manifest.ts new file mode 100644 index 00000000000..215e45d028a --- /dev/null +++ b/src/lib/messaging/channels/wecom/manifest.ts @@ -0,0 +1,215 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { ChannelManifest } from "../../manifest"; + +const WECOM_OPENCLAW_PLUGIN_VERSION = "2026.5.25"; + +export const wecomManifest = { + schemaVersion: 1, + id: "wecom", + displayName: "WeCom", + description: "WeCom (Enterprise WeChat) AI Bot messaging", + enrollmentNotes: [ + "Experimental. NemoClaw configures the WeCom AI Bot WebSocket mode, not the separate callback/webhook integration.", + ], + supportedAgents: ["openclaw", "hermes"], + auth: { + mode: "token-paste", + }, + inputs: [ + { + id: "botId", + kind: "secret", + required: true, + envKey: "WECOM_BOT_ID", + prompt: { + label: "WeCom Bot ID", + help: "Create a WeCom AI Bot in the WeCom Admin Console, then copy its Bot ID.", + }, + }, + { + id: "secret", + kind: "secret", + required: true, + envKey: "WECOM_SECRET", + prompt: { + label: "WeCom Bot Secret", + help: "Copy the Secret from the WeCom AI Bot credentials page.", + }, + }, + { + id: "allowedUsers", + kind: "config", + required: false, + envKey: "WECOM_ALLOWED_USERS", + statePath: "allowedIds.wecom", + prompt: { + label: "WeCom User IDs (DM allowlist)", + help: "Optional: restrict who can DM the bot. Enter one or more comma-separated WeCom user IDs.", + emptyValueMessage: "DM access stays controlled by DM policy", + }, + }, + { + id: "dmPolicy", + kind: "config", + required: false, + envKey: "WECOM_DM_POLICY", + statePath: "wecomConfig.dmPolicy", + validValues: ["open", "allowlist", "disabled", "pairing"], + defaultValue: "open", + prompt: { + label: "WeCom DM policy", + help: "Controls direct-message access: open, allowlist, disabled, or pairing.", + }, + }, + ], + credentials: [ + { + id: "wecomBotId", + sourceInput: "botId", + providerName: "{sandboxName}-wecom-bot-id", + providerEnvKey: "WECOM_BOT_ID", + placeholder: "openshell:resolve:env:WECOM_BOT_ID", + primary: true, + }, + { + id: "wecomSecret", + sourceInput: "secret", + providerName: "{sandboxName}-wecom-secret", + providerEnvKey: "WECOM_SECRET", + placeholder: "openshell:resolve:env:WECOM_SECRET", + }, + ], + policyPresets: [{ name: "wecom", policyKeys: ["wecom_aibot"] }], + render: [ + { + id: "wecom-openclaw-channel", + kind: "json-fragment", + agent: "openclaw", + target: "openclaw.json", + fragment: { + path: "channels.wecom", + value: { + enabled: true, + connectionMode: "websocket", + botId: "{{credential.wecomBotId.placeholder}}", + secret: "{{credential.wecomSecret.placeholder}}", + dmPolicy: "{{wecomConfig.dmPolicy}}", + allowFrom: "{{allowedIds.wecom.values}}", + }, + }, + }, + { + id: "wecom-openclaw-plugin", + kind: "json-fragment", + agent: "openclaw", + target: "openclaw.json", + fragment: { + path: "plugins.entries.wecom", + value: { + enabled: true, + }, + }, + }, + { + id: "wecom-hermes-env", + kind: "env-lines", + agent: "hermes", + target: "~/.hermes/.env", + lines: [ + "WECOM_BOT_ID={{credential.wecomBotId.placeholder}}", + "WECOM_SECRET={{credential.wecomSecret.placeholder}}", + "WECOM_ALLOWED_USERS={{allowedIds.wecom.csv}}", + "WECOM_DM_POLICY={{wecomConfig.dmPolicy}}", + ], + }, + { + id: "wecom-hermes-platform", + kind: "json-fragment", + agent: "hermes", + target: "~/.hermes/config.yaml", + fragment: { + path: "platforms.wecom", + value: { + enabled: true, + extra: { + dm_policy: "{{wecomConfig.dmPolicy}}", + allow_from: "{{allowedIds.wecom.values}}", + }, + }, + }, + }, + ], + runtime: { + openclaw: { + channelName: "wecom", + visibility: { + configKeys: ["wecom"], + logPatterns: ["wecom", "WeCom"], + configShape: "enabled-flag", + }, + }, + }, + agentPackages: [ + { + id: "openclawPluginPackage", + agent: "openclaw", + manager: "openclaw-plugin", + spec: `npm:@wecom/wecom-openclaw-plugin@${WECOM_OPENCLAW_PLUGIN_VERSION}`, + pin: true, + required: true, + }, + ], + state: { + persist: { + allowedIds: ["wecom"], + wecomConfig: ["dmPolicy"], + }, + rebuildHydration: [ + { + statePath: "allowedIds.wecom", + env: "WECOM_ALLOWED_USERS", + }, + { + statePath: "wecomConfig.dmPolicy", + env: "WECOM_DM_POLICY", + }, + ], + }, + hooks: [ + { + id: "wecom-token-paste", + phase: "enroll", + handler: "common.tokenPaste", + outputs: [ + { + id: "botId", + kind: "secret", + required: true, + }, + { + id: "secret", + kind: "secret", + required: true, + }, + ], + onFailure: "skip-channel", + }, + { + id: "wecom-config-prompt", + phase: "enroll", + handler: "common.configPrompt", + outputs: [ + { + id: "allowedUsers", + kind: "config", + }, + { + id: "dmPolicy", + kind: "config", + }, + ], + }, + ], +} as const satisfies ChannelManifest; diff --git a/src/lib/messaging/channels/wecom/template-resolver.ts b/src/lib/messaging/channels/wecom/template-resolver.ts new file mode 100644 index 00000000000..9ac540fa049 --- /dev/null +++ b/src/lib/messaging/channels/wecom/template-resolver.ts @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { RenderTemplateContext } from "../../compiler/engines/template"; +import { + allowedIds, + type BuiltInRenderTemplateResolver, + nonEmptyArray, + nonEmptyCsv, + nonEmptyString, + resolvedRenderTemplateReference, + stateValue, +} from "../template-resolver-utils"; + +const DEFAULT_DM_POLICY = "open"; +const DM_POLICIES = new Set(["open", "allowlist", "disabled", "pairing"]); + +export const resolveWecomTemplateReference: BuiltInRenderTemplateResolver = ( + reference, + context, +) => { + if (reference === "wecomConfig.dmPolicy") { + return resolvedRenderTemplateReference(wecomPolicy(context)); + } + + const allowedIdsReference = reference.match(/^allowedIds[.]wecom[.](values|csv)$/); + if (!allowedIdsReference?.[1]) return undefined; + const ids = allowedIds(context, "wecom"); + switch (allowedIdsReference[1]) { + case "values": + return resolvedRenderTemplateReference(nonEmptyArray(ids)); + case "csv": + return resolvedRenderTemplateReference(nonEmptyCsv(ids)); + default: + return undefined; + } +}; + +function wecomPolicy(context: RenderTemplateContext): string { + const value = nonEmptyString(stateValue(context, "wecomConfig.dmPolicy")); + return value && DM_POLICIES.has(value) ? value : DEFAULT_DM_POLICY; +} diff --git a/src/lib/messaging/compiler/manifest-compiler.test.ts b/src/lib/messaging/compiler/manifest-compiler.test.ts index df735aed04a..df3d185c2c7 100644 --- a/src/lib/messaging/compiler/manifest-compiler.test.ts +++ b/src/lib/messaging/compiler/manifest-compiler.test.ts @@ -15,11 +15,21 @@ import { } from "../manifest"; import { ManifestCompiler } from "./manifest-compiler"; -const ALL_CHANNELS = ["telegram", "discord", "wechat", "slack", "whatsapp", "teams"] as const; +const ALL_CHANNELS = [ + "telegram", + "discord", + "wechat", + "wecom", + "slack", + "whatsapp", + "teams", +] 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", + WECOM_BOT_ID: "test-wecom-bot-id", + WECOM_SECRET: "test-wecom-secret", SLACK_BOT_TOKEN: "xoxb-test-slack-token", SLACK_APP_TOKEN: "xapp-test-slack-token", MSTEAMS_APP_PASSWORD: "test-teams-client-secret", @@ -135,11 +145,21 @@ describe("ManifestCompiler", () => { agent: "openclaw", workflow: "onboard", isInteractive: true, - configuredChannels: ["slack", "telegram", "wechat", "discord", "whatsapp", "teams"], + configuredChannels: [ + "slack", + "telegram", + "wechat", + "wecom", + "discord", + "whatsapp", + "teams", + ], credentialAvailability: { TELEGRAM_BOT_TOKEN: true, DISCORD_BOT_TOKEN: true, WECHAT_BOT_TOKEN: true, + WECOM_BOT_ID: true, + WECOM_SECRET: true, SLACK_BOT_TOKEN: true, SLACK_APP_TOKEN: true, MSTEAMS_APP_PASSWORD: true, @@ -153,6 +173,8 @@ describe("ManifestCompiler", () => { "demo-telegram-bridge", "demo-discord-bridge", "demo-wechat-bridge", + "demo-wecom-bot-id", + "demo-wecom-secret", "demo-slack-bridge", "demo-slack-app", "demo-teams-bridge", @@ -161,6 +183,8 @@ describe("ManifestCompiler", () => { "openshell:resolve:env:TELEGRAM_BOT_TOKEN", "openshell:resolve:env:DISCORD_BOT_TOKEN", "openshell:resolve:env:WECHAT_BOT_TOKEN", + "openshell:resolve:env:WECOM_BOT_ID", + "openshell:resolve:env:WECOM_SECRET", "xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN", "xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN", "openshell:resolve:env:MSTEAMS_APP_PASSWORD", @@ -184,6 +208,12 @@ describe("ManifestCompiler", () => { policyKeys: ["wechat_bridge"], source: "manifest", }, + { + channelId: "wecom", + presetName: "wecom", + policyKeys: ["wecom_aibot"], + source: "manifest", + }, { channelId: "slack", presetName: "slack", @@ -210,6 +240,8 @@ describe("ManifestCompiler", () => { "discord:discord-openclaw-channel", "discord:discord-openclaw-plugin", "wechat:wechat-openclaw-plugin", + "wecom:wecom-openclaw-channel", + "wecom:wecom-openclaw-plugin", "slack:slack-openclaw-channel", "slack:slack-openclaw-plugin", "whatsapp:whatsapp-openclaw-channel", @@ -258,6 +290,12 @@ describe("ManifestCompiler", () => { outputId: "openclawConfigPatch", required: true, }, + { + channelId: "wecom", + kind: "package-install", + outputId: "openclawPluginPackage", + required: true, + }, { channelId: "slack", kind: "package-install", @@ -296,6 +334,15 @@ describe("ManifestCompiler", () => { pin: true, }, }), + expect.objectContaining({ + channelId: "wecom", + kind: "package-install", + value: { + manager: "openclaw-plugin", + spec: "npm:@wecom/wecom-openclaw-plugin@2026.5.25", + pin: true, + }, + }), expect.objectContaining({ channelId: "teams", kind: "package-install", @@ -353,10 +400,11 @@ describe("ManifestCompiler", () => { ).toEqual([]); }); - it("compiles Hermes render and manifest-owned WeChat policy intent", async () => { + it("compiles Hermes render and manifest-owned WeChat and WeCom policy intent", async () => { const plan = await withEnv( { WECHAT_ACCOUNT_ID: "test-wechat-account", + WECOM_ALLOWED_USERS: "wecom-user-one,wecom-user-two", ...TEST_TEAMS_ENV, }, () => @@ -370,6 +418,8 @@ describe("ManifestCompiler", () => { TELEGRAM_BOT_TOKEN: true, DISCORD_BOT_TOKEN: true, WECHAT_BOT_TOKEN: true, + WECOM_BOT_ID: true, + WECOM_SECRET: true, SLACK_BOT_TOKEN: true, SLACK_APP_TOKEN: true, MSTEAMS_APP_PASSWORD: true, @@ -383,6 +433,12 @@ describe("ManifestCompiler", () => { policyKeys: ["wechat_bridge"], source: "manifest", }); + expect(plan.networkPolicy.entries.find((entry) => entry.channelId === "wecom")).toEqual({ + channelId: "wecom", + presetName: "wecom", + policyKeys: ["wecom_aibot"], + source: "manifest", + }); expect(plan.networkPolicy.entries.find((entry) => entry.channelId === "teams")).toEqual({ channelId: "teams", presetName: "teams", @@ -398,6 +454,8 @@ describe("ManifestCompiler", () => { "discord:~/.hermes/config.yaml", "wechat:~/.hermes/.env", "wechat:~/.hermes/config.yaml", + "wecom:~/.hermes/.env", + "wecom:~/.hermes/config.yaml", "slack:~/.hermes/.env", "slack:~/.hermes/config.yaml", "whatsapp:~/.hermes/.env", @@ -408,6 +466,17 @@ describe("ManifestCompiler", () => { expect(JSON.stringify(plan.agentRender)).toContain( "WEIXIN_TOKEN=openshell:resolve:env:WECHAT_BOT_TOKEN", ); + expect(JSON.stringify(plan.agentRender)).toContain( + "WECOM_BOT_ID=openshell:resolve:env:WECOM_BOT_ID", + ); + expect(JSON.stringify(plan.agentRender)).toContain( + "WECOM_SECRET=openshell:resolve:env:WECOM_SECRET", + ); + expect(JSON.stringify(plan.agentRender)).toContain( + "WECOM_ALLOWED_USERS=wecom-user-one,wecom-user-two", + ); + expect(JSON.stringify(plan.agentRender)).toContain("WECOM_DM_POLICY=open"); + expect(JSON.stringify(plan.agentRender)).not.toContain("wss://openws.work.weixin.qq.com"); expect(JSON.stringify(plan.agentRender)).toContain( "TEAMS_CLIENT_SECRET=openshell:resolve:env:MSTEAMS_APP_PASSWORD", ); @@ -664,6 +733,29 @@ describe("ManifestCompiler", () => { } }); + it("rejects unsafe WeCom Hermes DM policy render values", async () => { + await expect( + withEnv( + { + WECOM_ALLOWED_USERS: "user-one", + WECOM_DM_POLICY: "open\nEVIL=1", + }, + () => + compiler().compile({ + sandboxName: "demo", + agent: "hermes", + workflow: "rebuild", + isInteractive: false, + configuredChannels: ["wecom"], + credentialAvailability: { + WECOM_BOT_ID: true, + WECOM_SECRET: true, + }, + }), + ), + ).rejects.toThrow(/line breaks/); + }); + it("rejects non-HTTPS or non-iLink WeChat baseUrl values", async () => { for (const baseUrl of ["http://ilinkai.wechat.com", "https://example.com"] as const) { await expect( diff --git a/src/lib/messaging/diagnostics.test.ts b/src/lib/messaging/diagnostics.test.ts index c075044aa74..9dd3569b452 100644 --- a/src/lib/messaging/diagnostics.test.ts +++ b/src/lib/messaging/diagnostics.test.ts @@ -13,6 +13,7 @@ describe("messaging channel diagnostics", () => { "telegram", "discord", "wechat", + "wecom", "slack", "whatsapp", "teams", @@ -24,6 +25,9 @@ describe("messaging channel diagnostics", () => { expect(specs.find((spec) => spec.channelId === "wechat")).toMatchObject({ policyPresets: ["wechat"], }); + expect(specs.find((spec) => spec.channelId === "wecom")).toMatchObject({ + policyPresets: ["wecom"], + }); expect(specs.find((spec) => spec.channelId === "whatsapp")).toMatchObject({ policyPresets: ["whatsapp"], preferredDefault: true, diff --git a/src/lib/messaging/manifest/types.ts b/src/lib/messaging/manifest/types.ts index 4b6c87f5ff7..ca76652d7c4 100644 --- a/src/lib/messaging/manifest/types.ts +++ b/src/lib/messaging/manifest/types.ts @@ -176,6 +176,7 @@ export interface ChannelRuntimeSpec { export interface ChannelRuntimeVisibilitySpec { readonly configKeys: readonly string[]; readonly logPatterns: readonly string[]; + readonly configShape?: "accounts" | "enabled-flag"; } export type ChannelRuntimeNodePreloadScope = "boot" | "connect"; diff --git a/src/lib/onboard/extra-placeholder-keys.test.ts b/src/lib/onboard/extra-placeholder-keys.test.ts index 91bb0aa120a..8cd1deeccf8 100644 --- a/src/lib/onboard/extra-placeholder-keys.test.ts +++ b/src/lib/onboard/extra-placeholder-keys.test.ts @@ -19,6 +19,8 @@ const CANONICAL_ENVKEYS_FIXTURE = new Set([ "SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "WECHAT_BOT_TOKEN", + "WECOM_BOT_ID", + "WECOM_SECRET", "BRAVE_API_KEY", ]); @@ -147,6 +149,8 @@ describe("canonicalPlaceholderKeys", () => { "SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "WECHAT_BOT_TOKEN", + "WECOM_BOT_ID", + "WECOM_SECRET", "BRAVE_API_KEY", ]) { expect(canonical.has(expected)).toBe(true); diff --git a/src/lib/onboard/initial-policy.test.ts b/src/lib/onboard/initial-policy.test.ts index bf9bba626f9..4bedde5bc6a 100644 --- a/src/lib/onboard/initial-policy.test.ts +++ b/src/lib/onboard/initial-policy.test.ts @@ -207,6 +207,7 @@ network_policies: " slack: {}", " teams: {}", " wechat_bridge: {}", + " wecom_aibot: {}", "", ].join("\n"), ); @@ -238,6 +239,39 @@ network_policies: expect(policyNames?.has("slack")).toBe(false); expect(policyNames?.has("teams")).toBe(false); expect(policyNames?.has("wechat_bridge")).toBe(false); + expect(policyNames?.has("wecom_aibot")).toBe(false); + expect(prepared.cleanup?.()).toBe(true); + expect(fs.existsSync(prepared.policyPath)).toBe(false); + }); + + it("keeps active Hermes WeCom policy in the create-time policy", () => { + const hermesPolicyPath = path.relative( + process.cwd(), + path.join(import.meta.dirname, "..", "..", "..", "agents", "hermes", "policy-additions.yaml"), + ); + + const prepared = prepareInitialSandboxCreatePolicy(hermesPolicyPath, ["wecom"], { + agentName: "hermes", + }); + const policy = fs.readFileSync(prepared.policyPath, "utf-8"); + const parsed = YAML.parse(policy); + const policyNames = getNetworkPolicyNames(policy); + + expect(policyNames?.has("wecom_aibot")).toBe(true); + expect(policyNames?.has("telegram")).toBe(false); + expect(policyNames?.has("discord")).toBe(false); + expect(policyNames?.has("slack")).toBe(false); + expect(policyNames?.has("wechat_bridge")).toBe(false); + expect(parsed.network_policies.wecom_aibot.endpoints).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + host: "openws.work.weixin.qq.com", + port: 443, + protocol: "websocket", + enforcement: "enforce", + }), + ]), + ); expect(prepared.cleanup?.()).toBe(true); expect(fs.existsSync(prepared.policyPath)).toBe(false); }); diff --git a/src/lib/onboard/messaging-channel-setup.test.ts b/src/lib/onboard/messaging-channel-setup.test.ts index 29de917a366..5a3769be095 100644 --- a/src/lib/onboard/messaging-channel-setup.test.ts +++ b/src/lib/onboard/messaging-channel-setup.test.ts @@ -457,6 +457,21 @@ describe("setupMessagingChannels", () => { expect(prompt).not.toHaveBeenCalled(); }); + it("seeds WeCom with both static credentials in non-interactive mode", async () => { + process.env.WECOM_BOT_ID = "wecom-bot-id"; + process.env.WECOM_SECRET = "wecom-secret"; + const notes: string[] = []; + + const result = await setupMessagingChannels(null, null, { + note: (message) => notes.push(message), + isNonInteractive: () => true, + }); + + expect(result).toEqual(["wecom"]); + expect(notes).toEqual([" [non-interactive] Messaging channel inputs detected: wecom"]); + 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"; diff --git a/src/lib/onboard/messaging-prep.test.ts b/src/lib/onboard/messaging-prep.test.ts index ba31de95473..c6c578d1285 100644 --- a/src/lib/onboard/messaging-prep.test.ts +++ b/src/lib/onboard/messaging-prep.test.ts @@ -10,6 +10,16 @@ import { } from "../../../dist/lib/onboard/messaging-prep"; import { listChannels } from "../../../dist/lib/sandbox/channels"; +const CHANNEL_BY_ENV_KEY: Readonly> = { + DISCORD_BOT_TOKEN: "discord", + SLACK_BOT_TOKEN: "slack", + SLACK_APP_TOKEN: "slack", + TELEGRAM_BOT_TOKEN: "telegram", + WECHAT_BOT_TOKEN: "wechat", + WECOM_BOT_ID: "wecom", + WECOM_SECRET: "wecom", +}; + function normalizeCredentialValue(value: unknown): string { return typeof value === "string" ? value.trim() : ""; } @@ -28,14 +38,7 @@ function createInput( getCredential: () => null, normalizeCredentialValue, registerExtraPlaceholderProviders: vi.fn(() => []), - getMessagingChannelForEnvKey: (envKey) => { - if (envKey === "DISCORD_BOT_TOKEN") return "discord"; - if (envKey === "SLACK_BOT_TOKEN") return "slack"; - if (envKey === "SLACK_APP_TOKEN") return "slack"; - if (envKey === "TELEGRAM_BOT_TOKEN") return "telegram"; - if (envKey === "WECHAT_BOT_TOKEN") return "wechat"; - return null; - }, + getMessagingChannelForEnvKey: (envKey) => CHANNEL_BY_ENV_KEY[envKey] ?? null, providerExistsInGateway: () => false, ...overrides, }; @@ -149,6 +152,8 @@ describe("prepareCreateSandboxMessaging", () => { "SLACK_BOT_TOKEN", "TELEGRAM_BOT_TOKEN", "WECHAT_BOT_TOKEN", + "WECOM_BOT_ID", + "WECOM_SECRET", ]); expect(result.reusableMessagingProviders).toEqual([]); expect(result.reusableMessagingChannels).toEqual([]); diff --git a/src/lib/onboard/messaging-reuse.test.ts b/src/lib/onboard/messaging-reuse.test.ts index a74a1a794fa..2b6fc21f652 100644 --- a/src/lib/onboard/messaging-reuse.test.ts +++ b/src/lib/onboard/messaging-reuse.test.ts @@ -12,6 +12,7 @@ const messagingChannels = [ { name: "discord", envKey: "DISCORD_BOT_TOKEN" }, { name: "slack", envKey: "SLACK_BOT_TOKEN" }, { name: "wechat", envKey: "WECHAT_BOT_TOKEN" }, + { name: "wecom", envKey: "WECOM_BOT_ID" }, ]; describe("onboard messaging reuse", () => { @@ -25,6 +26,10 @@ describe("onboard messaging reuse", () => { expect(getMessagingProviderNamesForChannel("assistant", "wechat")).toEqual([ "assistant-wechat-bridge", ]); + expect(getMessagingProviderNamesForChannel("assistant", "wecom")).toEqual([ + "assistant-wecom-bot-id", + "assistant-wecom-secret", + ]); }); it("requires both Slack providers before reusing a stored Slack channel", () => { @@ -64,6 +69,22 @@ describe("onboard messaging reuse", () => { expect(reusedChannels).toEqual(["slack"]); }); + it("requires both WeCom providers before reusing a stored WeCom channel", () => { + const reusedChannels = getNonInteractiveStoredMessagingChannels( + false, + null, + "assistant", + messagingChannels, + () => false, + () => ["wecom"], + () => [], + (provider) => provider === "assistant-wecom-bot-id", + true, + ); + + expect(reusedChannels).toBeNull(); + }); + it("reuses a stored WeChat channel when its bridge provider exists", () => { const reusedChannels = getNonInteractiveStoredMessagingChannels( false, diff --git a/src/lib/sandbox/channels.test.ts b/src/lib/sandbox/channels.test.ts index 5097262af2a..bb08f487679 100644 --- a/src/lib/sandbox/channels.test.ts +++ b/src/lib/sandbox/channels.test.ts @@ -16,11 +16,12 @@ import { } from "./channels"; describe("sandbox-channels KNOWN_CHANNELS", () => { - it("covers telegram, discord, wechat, slack, whatsapp, and teams", () => { + it("covers telegram, discord, wechat, wecom, slack, whatsapp, and teams", () => { expect(knownChannelNames()).toEqual([ "telegram", "discord", "wechat", + "wecom", "slack", "whatsapp", "teams", @@ -32,6 +33,7 @@ describe("sandbox-channels KNOWN_CHANNELS", () => { expect(getChannelDef("discord")?.envKey).toBe("DISCORD_BOT_TOKEN"); expect(getChannelDef("slack")?.envKey).toBe("SLACK_BOT_TOKEN"); expect(getChannelDef("wechat")?.envKey).toBe("WECHAT_BOT_TOKEN"); + expect(getChannelDef("wecom")?.envKey).toBe("WECOM_BOT_ID"); expect(getChannelDef("teams")?.envKey).toBe("MSTEAMS_APP_PASSWORD"); }); @@ -48,15 +50,24 @@ describe("sandbox-channels KNOWN_CHANNELS", () => { expect(getChannelDef("telegram")?.loginMethod).toBeUndefined(); expect(getChannelDef("discord")?.loginMethod).toBeUndefined(); expect(getChannelDef("slack")?.loginMethod).toBeUndefined(); + expect(getChannelDef("wecom")?.loginMethod).toBeUndefined(); expect(getChannelDef("teams")?.loginMethod).toBeUndefined(); }); - it("declares wechat as DM-only with the WECHAT_ALLOWED_IDS env key", () => { + it("declares WeChat as DM-only with an allowlist env key", () => { const wechat = getChannelDef("wechat"); expect(wechat?.allowIdsMode).toBe("dm"); expect(wechat?.userIdEnvKey).toBe("WECHAT_ALLOWED_IDS"); }); + it("exposes WeCom user IDs as a DM allowlist prompt", () => { + const wecom = getChannelDef("wecom"); + expect(wecom?.allowIdsMode).toBe("dm"); + expect(wecom?.userIdEnvKey).toBe("WECOM_ALLOWED_USERS"); + expect(wecom?.userIdLabel).toBe("WeCom User IDs (DM allowlist)"); + expect(wecom?.userIdHelp).toContain("comma-separated WeCom user IDs"); + }); + it("omits envKey for in-sandbox QR-paired channels (whatsapp)", () => { expect(getChannelDef("whatsapp")?.envKey).toBeUndefined(); expect(getChannelDef("whatsapp")?.userIdEnvKey).toBe("WHATSAPP_ALLOWED_IDS"); @@ -64,6 +75,7 @@ describe("sandbox-channels KNOWN_CHANNELS", () => { expect(channelUsesInSandboxQrPairing(KNOWN_CHANNELS.whatsapp)).toBe(true); expect(channelUsesInSandboxQrPairing(KNOWN_CHANNELS.wechat)).toBe(false); expect(channelUsesInSandboxQrPairing(KNOWN_CHANNELS.slack)).toBe(false); + expect(channelUsesInSandboxQrPairing(KNOWN_CHANNELS.wecom)).toBe(false); expect(channelUsesInSandboxQrPairing(KNOWN_CHANNELS.teams)).toBe(false); }); @@ -76,10 +88,11 @@ describe("sandbox-channels KNOWN_CHANNELS", () => { expect(getChannelTokenKeys(KNOWN_CHANNELS.whatsapp)).toEqual([]); }); - it("only slack declares a secondary app-token env var", () => { + it("declares secondary credential env vars for multi-secret channels", () => { expect(getChannelDef("telegram")?.appTokenEnvKey).toBeUndefined(); expect(getChannelDef("discord")?.appTokenEnvKey).toBeUndefined(); expect(getChannelDef("slack")?.appTokenEnvKey).toBe("SLACK_APP_TOKEN"); + expect(getChannelDef("wecom")?.appTokenEnvKey).toBe("WECOM_SECRET"); expect(getChannelDef("whatsapp")?.appTokenEnvKey).toBeUndefined(); expect(getChannelDef("teams")?.appTokenEnvKey).toBeUndefined(); }); @@ -128,6 +141,7 @@ describe("sandbox-channels KNOWN_CHANNELS", () => { expect(getChannelDef(" Telegram ")).toBe(KNOWN_CHANNELS.telegram); expect(getChannelDef("DISCORD")).toBe(KNOWN_CHANNELS.discord); expect(getChannelDef(" WhatsApp ")).toBe(KNOWN_CHANNELS.whatsapp); + expect(getChannelDef("WECOM")).toBe(KNOWN_CHANNELS.wecom); expect(getChannelDef(" Teams ")).toBe(KNOWN_CHANNELS.teams); }); @@ -144,11 +158,12 @@ describe("sandbox-channels getChannelTokenKeys", () => { expect(getChannelTokenKeys(KNOWN_CHANNELS.teams)).toEqual(["MSTEAMS_APP_PASSWORD"]); }); - it("returns primary then app token for slack", () => { + it("returns all provider token keys for multi-secret channels", () => { expect(getChannelTokenKeys(KNOWN_CHANNELS.slack)).toEqual([ "SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", ]); + expect(getChannelTokenKeys(KNOWN_CHANNELS.wecom)).toEqual(["WECOM_BOT_ID", "WECOM_SECRET"]); }); it("returns an empty list for QR-paired channels", () => { @@ -168,6 +183,7 @@ describe("sandbox-channels token-shape helpers", () => { expect(channelUsesInSandboxQrPairing(KNOWN_CHANNELS.whatsapp)).toBe(true); expect(channelUsesInSandboxQrPairing(KNOWN_CHANNELS.wechat)).toBe(false); expect(channelUsesInSandboxQrPairing(KNOWN_CHANNELS.telegram)).toBe(false); + expect(channelUsesInSandboxQrPairing(KNOWN_CHANNELS.wecom)).toBe(false); expect(channelUsesInSandboxQrPairing(KNOWN_CHANNELS.slack)).toBe(false); expect(channelUsesInSandboxQrPairing(KNOWN_CHANNELS.teams)).toBe(false); }); @@ -191,6 +207,7 @@ describe("sandbox-channels listChannels", () => { "telegram", "discord", "wechat", + "wecom", "slack", "whatsapp", "teams", @@ -198,6 +215,9 @@ describe("sandbox-channels listChannels", () => { const telegram = list.find((c) => c.name === "telegram"); expect(telegram?.envKey).toBe("TELEGRAM_BOT_TOKEN"); expect(telegram?.allowIdsMode).toBe("dm"); + const wecom = list.find((c) => c.name === "wecom"); + expect(wecom?.envKey).toBe("WECOM_BOT_ID"); + expect(wecom?.appTokenEnvKey).toBe("WECOM_SECRET"); const whatsapp = list.find((c) => c.name === "whatsapp"); expect(whatsapp?.envKey).toBeUndefined(); const teams = list.find((c) => c.name === "teams"); diff --git a/test/channels-add-preset.test.ts b/test/channels-add-preset.test.ts index fb7f248adf0..56869110e72 100644 --- a/test/channels-add-preset.test.ts +++ b/test/channels-add-preset.test.ts @@ -1,10 +1,6 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -// -// Regression test for #3437 — `nemoclaw channels add ` -// must apply the channel's matching network policy preset BEFORE triggering -// the rebuild, so the rebuild's backup manifest captures the preset and -// the bridge has egress to its upstream API after the new sandbox boots. +// Regression for #3437: channel add applies the matching network policy before rebuild. import assert from "node:assert/strict"; import { type SpawnSyncReturns, spawnSync } from "node:child_process"; @@ -53,13 +49,7 @@ function parseResultPayload = Record> return payload; } -// Build a preamble that: -// - stubs every module touched by addSandboxChannel so no real openshell, -// gateway, or filesystem credential write happens -// - records every policies.applyPreset call in `appliedCalls` -// - records the relative order of applyPreset vs promptAndRebuild via -// a console.log marker, so the test can assert the ordering invariant -// (apply MUST precede rebuild) +// Stubs addSandboxChannel boundaries and records policy/rebuild ordering. function buildPreamble({ presetNamesAvailable = ["telegram", "slack", "discord", "npm", "github"], applyPresetResult = true, @@ -307,7 +297,15 @@ const ctx = module.exports; isInteractive: false, configuredChannels: ["slack"], disabledChannels: [], - supportedChannelIds: ["telegram", "discord", "wechat", "slack", "whatsapp", "teams"], + supportedChannelIds: [ + "telegram", + "discord", + "wechat", + "wecom", + "slack", + "whatsapp", + "teams", + ], }, ]); }); diff --git a/test/credentials.test.ts b/test/credentials.test.ts index 1ab26845173..1fe1de7cc22 100644 --- a/test/credentials.test.ts +++ b/test/credentials.test.ts @@ -81,6 +81,8 @@ describe("messaging legacy bridge credentials", () => { // the known list, sanitization and rotation will silently skip it and // the token may leak through diagnostic dumps. expect(KNOWN_CREDENTIAL_ENV_KEYS).toContain("WECHAT_BOT_TOKEN"); + expect(KNOWN_CREDENTIAL_ENV_KEYS).toContain("WECOM_BOT_ID"); + expect(KNOWN_CREDENTIAL_ENV_KEYS).toContain("WECOM_SECRET"); expect(KNOWN_CREDENTIAL_ENV_KEYS).toContain("TELEGRAM_BOT_TOKEN"); expect(KNOWN_CREDENTIAL_ENV_KEYS).toContain("DISCORD_BOT_TOKEN"); expect(KNOWN_CREDENTIAL_ENV_KEYS).toContain("SLACK_BOT_TOKEN"); diff --git a/test/hermes-start.test.ts b/test/hermes-start.test.ts index bf784a3e641..b17e92973ff 100644 --- a/test/hermes-start.test.ts +++ b/test/hermes-start.test.ts @@ -1,10 +1,10 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { spawnSync } from "node:child_process"; import { describe, expect, it } from "vitest"; import { shellQuote } from "../src/lib/core/shell-quote"; @@ -692,6 +692,8 @@ describe("agents/hermes/start.sh runtime shell env", () => { expect(run.result.status).toBe(0); expect(run.envFileMode).toBe("444"); + expect(run.envFileContent).toContain('export WS_PROXY="http://10.200.0.1:3128"'); + expect(run.envFileContent).toContain('export WSS_PROXY="http://10.200.0.1:3128"'); expect(run.envFileContent).toContain(`export HERMES_HOME="${run.hermesHome}"`); expect(run.envFileContent).toContain('export HERMES_TUI_DIR="/opt/hermes/ui-tui"'); expect(run.envFileContent).not.toContain('HERMES_TUI_DIR="${HERMES_TUI_DIR:-'); diff --git a/test/messaging-build-applier.test.ts b/test/messaging-build-applier.test.ts index e7ded8c3bfc..3da00740b7d 100644 --- a/test/messaging-build-applier.test.ts +++ b/test/messaging-build-applier.test.ts @@ -9,7 +9,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { withLegacyMessagingPlanEnv } from "./messaging-plan-test-helper"; +import { encodeJson, withLegacyMessagingPlanEnv } from "./messaging-plan-test-helper"; const SCRIPT_PATH = path.join( import.meta.dirname, @@ -123,6 +123,7 @@ describe("messaging-build-applier.mts: agent-install", () => { "slack", "whatsapp", "wechat", + "wecom", "teams", ]), NEMOCLAW_WECHAT_CONFIG_B64: wechatConfigB64(), @@ -132,6 +133,7 @@ describe("messaging-build-applier.mts: agent-install", () => { expect(payload.installSpecs).toEqual([ "npm:@openclaw/discord@2026.5.22", "npm:@tencent-weixin/openclaw-weixin@2.4.3", + "npm:@wecom/wecom-openclaw-plugin@2026.5.25", "npm:@openclaw/slack@2026.5.22", "npm:@openclaw/whatsapp@2026.5.22", "npm:@openclaw/msteams@2026.5.22", @@ -143,6 +145,8 @@ describe("messaging-build-applier.mts: agent-install", () => { SLACK_BOT_TOKEN: "xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN", TELEGRAM_BOT_TOKEN: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", WECHAT_BOT_TOKEN: "openshell:resolve:env:WECHAT_BOT_TOKEN", + WECOM_BOT_ID: "openshell:resolve:env:WECOM_BOT_ID", + WECOM_SECRET: "openshell:resolve:env:WECOM_SECRET", }); }); @@ -182,6 +186,18 @@ describe("messaging-build-applier.mts: agent-install", () => { }); }); + it("installs the fixed WeCom OpenClaw plugin without OPENCLAW_VERSION", () => { + const payload = parseDryRun({ + NEMOCLAW_MESSAGING_CHANNELS_B64: channelsB64(["wecom"]), + }); + + expect(payload.installSpecs).toEqual(["npm:@wecom/wecom-openclaw-plugin@2026.5.25"]); + expect(payload.doctorEnv).toEqual({ + WECOM_BOT_ID: "openshell:resolve:env:WECOM_BOT_ID", + WECOM_SECRET: "openshell:resolve:env:WECOM_SECRET", + }); + }); + it("forces WhatsApp to the OpenClaw runtime version on 2026.5.18 sandboxes", () => { const payload = parseDryRun({ OPENCLAW_VERSION: "2026.5.18", @@ -441,6 +457,7 @@ describe("messaging-build-applier.mts: agent-install", () => { "slack", "whatsapp", "wechat", + "wecom", "teams", ]), NEMOCLAW_WECHAT_CONFIG_B64: wechatConfigB64(), @@ -470,6 +487,7 @@ describe("messaging-build-applier.mts: agent-install", () => { expect(fs.readFileSync(tracePath, "utf-8").trim().split("\n")).toEqual([ "plugins|install|npm:@openclaw/discord@2026.5.22|--pin|||", "plugins|install|npm:@tencent-weixin/openclaw-weixin@2.4.3|--pin|||", + "plugins|install|npm:@wecom/wecom-openclaw-plugin@2026.5.25|--pin|||", "plugins|install|npm:@openclaw/slack@2026.5.22|--pin|||", "plugins|install|npm:@openclaw/whatsapp@2026.5.22|--pin|||", "plugins|install|npm:@openclaw/msteams@2026.5.22|--pin|||", @@ -713,7 +731,7 @@ describe("messaging-build-applier.mts: agent-install", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-doctor-rewrite-")); const tracePath = path.join(tmp, "openclaw.trace"); const fakeOpenclaw = path.join(tmp, "openclaw"); - const channels = channelsB64(["telegram", "discord", "slack", "wechat"]); + const channels = channelsB64(["telegram", "discord", "slack", "wechat", "wecom"]); const wechatConfig = Buffer.from( JSON.stringify({ accountId: "primary", baseUrl: "https://ilinkai.wechat.com", userId: "u1" }), ).toString("base64"); @@ -734,6 +752,9 @@ describe("messaging-build-applier.mts: agent-install", () => { "if (config.plugins?.entries?.discord?.enabled !== true) process.exit(42);", "if (config.plugins?.entries?.slack?.enabled !== true) process.exit(43);", 'if (config.channels?.["openclaw-weixin"]?.accounts?.primary?.enabled !== true) process.exit(44);', + 'if (config.channels?.wecom?.botId !== "openshell:resolve:env:WECOM_BOT_ID") process.exit(45);', + 'if (config.channels?.wecom?.secret !== "openshell:resolve:env:WECOM_SECRET") process.exit(46);', + "if (config.plugins?.entries?.wecom?.enabled !== true) process.exit(47);", 'fs.writeFileSync(configPath, JSON.stringify({ channels: { telegram: { accounts: { default: { botToken: "openshell:resolve:env:v42_TELEGRAM_BOT_TOKEN" } } } }, plugins: { entries: {} } }, null, 2) + String.fromCharCode(10));', "process.exit(0);", "", @@ -748,6 +769,9 @@ describe("messaging-build-applier.mts: agent-install", () => { HOME: tmp, ...BASE_GENERATOR_ENV, NEMOCLAW_MESSAGING_CHANNELS_B64: channels, + NEMOCLAW_MESSAGING_ALLOWED_IDS_B64: encodeJson({ + wecom: ["wecom-user-one", "wecom-user-two"], + }), NEMOCLAW_WECHAT_CONFIG_B64: wechatConfig, NEMOCLAW_OPENCLAW_MANAGED_PROXY: "0", }, @@ -797,6 +821,17 @@ describe("messaging-build-applier.mts: agent-install", () => { expect(config.plugins?.entries?.discord).toEqual({ enabled: true }); expect(config.channels?.slack?.enabled).toBe(true); expect(config.plugins?.entries?.slack).toEqual({ enabled: true }); + expect(config.channels?.wecom).toMatchObject({ + enabled: true, + connectionMode: "websocket", + botId: "openshell:resolve:env:WECOM_BOT_ID", + secret: "openshell:resolve:env:WECOM_SECRET", + dmPolicy: "open", + allowFrom: ["wecom-user-one", "wecom-user-two"], + }); + expect(config.channels?.wecom?.websocketUrl).toBeUndefined(); + expect(config.channels?.wecom?.groupPolicy).toBeUndefined(); + expect(config.plugins?.entries?.wecom).toEqual({ enabled: true }); expect(config.channels?.["openclaw-weixin"]?.accounts?.primary).toEqual({ enabled: true }); expect(config.channels?.wechat).toBeUndefined(); } finally { @@ -1100,7 +1135,10 @@ describe("messaging-build-applier.mts: agent-install", () => { { PATH: process.env.PATH || "/usr/bin:/bin", HOME: tmp, - NEMOCLAW_MESSAGING_CHANNELS_B64: channelsB64(["telegram"]), + NEMOCLAW_MESSAGING_CHANNELS_B64: channelsB64(["telegram", "wecom"]), + NEMOCLAW_MESSAGING_ALLOWED_IDS_B64: encodeJson({ + wecom: ["wecom-user-one", "wecom-user-two"], + }), }, "hermes", ); @@ -1127,9 +1165,20 @@ describe("messaging-build-applier.mts: agent-install", () => { const configYaml = fs.readFileSync(path.join(hermesDir, "config.yaml"), "utf-8"); expect(configYaml).toContain("telegram:"); expect(configYaml).toContain("enabled: true"); + expect(configYaml).toContain("wecom:"); + expect(configYaml).toContain("dm_policy: open"); + expect(configYaml).toContain("allow_from:"); + expect(configYaml).toContain("- wecom-user-one"); + expect(configYaml).toContain("- wecom-user-two"); + expect(configYaml).not.toContain("websocket_url:"); + expect(configYaml).not.toContain("group_policy:"); const envFile = fs.readFileSync(path.join(hermesDir, ".env"), "utf-8"); expect(envFile).toContain("API_SERVER_PORT=18642\n"); expect(envFile).toContain("TELEGRAM_BOT_TOKEN=openshell:resolve:env:TELEGRAM_BOT_TOKEN\n"); + expect(envFile).toContain("WECOM_BOT_ID=openshell:resolve:env:WECOM_BOT_ID\n"); + expect(envFile).toContain("WECOM_SECRET=openshell:resolve:env:WECOM_SECRET\n"); + expect(envFile).toContain("WECOM_ALLOWED_USERS=wecom-user-one,wecom-user-two\n"); + expect(envFile).toContain("WECOM_DM_POLICY=open\n"); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } diff --git a/test/messaging-plan-test-helper.ts b/test/messaging-plan-test-helper.ts index d40683927ee..2767775da09 100644 --- a/test/messaging-plan-test-helper.ts +++ b/test/messaging-plan-test-helper.ts @@ -111,6 +111,7 @@ function legacyMessagingConfigEnv(env: Record): Record>( @@ -137,6 +138,9 @@ function legacyMessagingConfigEnv(env: Record): Record>(env, "NEMOCLAW_WECOM_CONFIG_B64", {}); + assignString(next, "WECOM_DM_POLICY", wecomConfig.dmPolicy); + const slackConfig = decodeJsonEnv>(env, "NEMOCLAW_SLACK_CONFIG_B64", {}); assignCsv(next, "SLACK_ALLOWED_CHANNELS", slackConfig.allowedChannels); @@ -234,17 +238,23 @@ function credentialAvailability(): Record { "telegram.botToken", "discord.botToken", "wechat.botToken", + "wecom.botId", + "wecom.secret", "slack.botToken", "slack.appToken", "telegramBotToken", "discordBotToken", "wechatBotToken", + "wecomBotId", + "wecomSecret", "slackBotToken", "slackAppToken", "teamsClientSecret", "TELEGRAM_BOT_TOKEN", "DISCORD_BOT_TOKEN", "WECHAT_BOT_TOKEN", + "WECOM_BOT_ID", + "WECOM_SECRET", "SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "MSTEAMS_APP_PASSWORD", diff --git a/test/policies.test.ts b/test/policies.test.ts index 80741e77d5e..87171272dff 100644 --- a/test/policies.test.ts +++ b/test/policies.test.ts @@ -1,13 +1,13 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { spawnSync } from "node:child_process"; import fs from "node:fs"; +import { createRequire } from "node:module"; import os from "node:os"; import path from "node:path"; -import { createRequire } from "node:module"; import type { Interface as ReadlineInterface } from "node:readline"; -import { afterEach, beforeEach, describe, it, expect, vi } from "vitest"; -import { spawnSync } from "node:child_process"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import * as policies from "../dist/lib/policy"; import { execTimeout } from "./helpers/timeouts"; @@ -40,7 +40,6 @@ type PolicyCall = { type AppliedOptions = { applied?: string[]; }; - function requirePresetContent(content: string | null): string { expect(content).toBeTruthy(); if (!content) { @@ -147,6 +146,7 @@ describe("policies", () => { "telegram", "weather", "wechat", + "wecom", "whatsapp", ]; expect(names).toEqual(expected); diff --git a/test/policy-tiers.test.ts b/test/policy-tiers.test.ts index 33f0b2c5042..988e15c40d2 100644 --- a/test/policy-tiers.test.ts +++ b/test/policy-tiers.test.ts @@ -143,12 +143,13 @@ describe("tiers", () => { expect(accessByName.get("weather")).toBe("read"); }); - it("does not include messaging presets (slack, discord, telegram, wechat, whatsapp)", () => { + it("does not include messaging presets (slack, discord, telegram, wechat, wecom, whatsapp)", () => { const names = mustGetTier("balanced").presets.map((preset: TierPreset) => preset.name); expect(names).not.toContain("slack"); expect(names).not.toContain("discord"); expect(names).not.toContain("telegram"); expect(names).not.toContain("wechat"); + expect(names).not.toContain("wecom"); expect(names).not.toContain("whatsapp"); }); }); @@ -174,6 +175,7 @@ describe("tiers", () => { "discord", "telegram", "wechat", + "wecom", "whatsapp", "jira", "outlook", @@ -184,12 +186,13 @@ describe("tiers", () => { expect(accessByName.get("public-reference")).toBe("read"); }); - it("includes messaging presets (slack, discord, telegram, wechat, whatsapp)", () => { + it("includes messaging presets (slack, discord, telegram, wechat, wecom, whatsapp)", () => { const names = mustGetTier("open").presets.map((preset: TierPreset) => preset.name); expect(names).toContain("slack"); expect(names).toContain("discord"); expect(names).toContain("telegram"); expect(names).toContain("wechat"); + expect(names).toContain("wecom"); expect(names).toContain("whatsapp"); }); diff --git a/test/sandbox-provider-cleanup.test.ts b/test/sandbox-provider-cleanup.test.ts index 8e83ac82325..9dfd78b7c72 100644 --- a/test/sandbox-provider-cleanup.test.ts +++ b/test/sandbox-provider-cleanup.test.ts @@ -36,6 +36,8 @@ describe("SANDBOX_PROVIDER_SUFFIXES", () => { "telegram-bridge", "discord-bridge", "wechat-bridge", + "wecom-bot-id", + "wecom-secret", "slack-bridge", "slack-app", "teams-bridge", diff --git a/test/service-env.test.ts b/test/service-env.test.ts index 9467ff6b7a8..ded63f9cf4f 100644 --- a/test/service-env.test.ts +++ b/test/service-env.test.ts @@ -1,22 +1,22 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, it, expect } from "vitest"; import { - execSync, - execFileSync, type ExecFileSyncOptionsWithStringEncoding, + execFileSync, + execSync, } from "node:child_process"; import { existsSync, + lstatSync, mkdtempSync, - writeFileSync, - unlinkSync, readFileSync, - lstatSync, + unlinkSync, + writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { describe, expect, it } from "vitest"; import { resolveOpenshell } from "../dist/lib/adapters/openshell/resolve"; const NEMOCLAW_START_SCRIPT = join(import.meta.dirname, "../scripts/nemoclaw-start.sh"); @@ -475,9 +475,13 @@ describe("service environment", () => { proxyBlock.trimEnd(), 'echo "HTTP_PROXY=${HTTP_PROXY}"', 'echo "HTTPS_PROXY=${HTTPS_PROXY}"', + 'echo "WS_PROXY=${WS_PROXY}"', + 'echo "WSS_PROXY=${WSS_PROXY}"', 'echo "NO_PROXY=${NO_PROXY}"', 'echo "http_proxy=${http_proxy}"', 'echo "https_proxy=${https_proxy}"', + 'echo "ws_proxy=${ws_proxy}"', + 'echo "wss_proxy=${wss_proxy}"', 'echo "no_proxy=${no_proxy}"', ].join("\n"); const tmpFile = join(tmpdir(), `nemoclaw-proxy-test-${process.pid}.sh`); @@ -512,6 +516,12 @@ describe("service environment", () => { expect(vars.HTTPS_PROXY).toBe("http://10.200.0.1:3128"); }); + it("sets websocket proxy variants for aiohttp websocket clients", () => { + const vars = extractProxyVars(); + expect(vars.WS_PROXY).toBe("http://10.200.0.1:3128"); + expect(vars.WSS_PROXY).toBe("http://10.200.0.1:3128"); + }); + it("NEMOCLAW_PROXY_HOST overrides default gateway IP", () => { const vars = extractProxyVars({ NEMOCLAW_PROXY_HOST: "192.168.64.1" }); expect(vars.HTTP_PROXY).toBe("http://192.168.64.1:3128"); @@ -542,6 +552,8 @@ describe("service environment", () => { const vars = extractProxyVars(); expect(vars.http_proxy).toBe("http://10.200.0.1:3128"); expect(vars.https_proxy).toBe("http://10.200.0.1:3128"); + expect(vars.ws_proxy).toBe("http://10.200.0.1:3128"); + expect(vars.wss_proxy).toBe("http://10.200.0.1:3128"); const noProxy = vars.no_proxy.split(","); expect(noProxy).not.toContain("inference.local"); expect(noProxy).toContain("10.200.0.1"); @@ -574,6 +586,8 @@ describe("service environment", () => { const envFile = readFileSync(join(fakeDataDir, "proxy-env.sh"), "utf-8"); expect(envFile).toContain('export HTTP_PROXY="http://10.200.0.1:3128"'); expect(envFile).toContain('export HTTPS_PROXY="http://10.200.0.1:3128"'); + expect(envFile).toContain('export WS_PROXY="http://10.200.0.1:3128"'); + expect(envFile).toContain('export WSS_PROXY="http://10.200.0.1:3128"'); expect(envFile).toContain("export NO_PROXY="); expect(envFile).not.toContain("inference.local"); expect(envFile).toContain("10.200.0.1"); diff --git a/test/wecom-policies.test.ts b/test/wecom-policies.test.ts new file mode 100644 index 00000000000..065548701c7 --- /dev/null +++ b/test/wecom-policies.test.ts @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import YAML from "yaml"; + +const REPO_ROOT = path.join(import.meta.dirname, ".."); + +type PolicyRule = { + allow?: { + method?: string; + path?: string; + }; +}; + +type PolicyEndpoint = { + host?: string; + rules?: PolicyRule[]; +}; + +type PolicyDocument = { + network_policies?: Record< + string, + { + endpoints?: PolicyEndpoint[]; + } + >; +}; + +function parseRepoYaml(relativePath: string): PolicyDocument { + return YAML.parse(fs.readFileSync(path.join(REPO_ROOT, relativePath), "utf-8")) as PolicyDocument; +} + +describe("WeCom policy presets", () => { + it("do not allow the Agent gettoken exchange", () => { + const policySources = [ + "nemoclaw-blueprint/policies/presets/wecom.yaml", + "agents/hermes/policy-additions.yaml", + ]; + + for (const relativePath of policySources) { + const parsed = parseRepoYaml(relativePath); + const qyapiRules = Object.values(parsed.network_policies ?? {}) + .flatMap((policy) => policy.endpoints ?? []) + .filter((endpoint) => endpoint.host === "qyapi.weixin.qq.com") + .flatMap((endpoint) => endpoint.rules ?? []) + .map((rule) => rule.allow) + .filter((rule): rule is { method: string; path: string } => + Boolean(rule?.method && rule?.path), + ); + + expect(qyapiRules).not.toContainEqual({ method: "GET", path: "/cgi-bin/gettoken" }); + } + }); +});