Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion agents/hermes/Dockerfile.base
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions agents/hermes/manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ messaging_platforms:
- discord
- slack
- wechat
- wecom
- whatsapp
- teams
# Future: signal, matrix, mattermost, email, etc.
Expand Down
28 changes: 28 additions & 0 deletions agents/hermes/policy-additions.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
8 changes: 8 additions & 0 deletions agents/hermes/start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions agents/openclaw/manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ state_dirs:
- memory
- telegram
- wechat
- wecom
- whatsapp
- credentials

Expand Down Expand Up @@ -79,6 +80,7 @@ messaging_platforms:
- discord
- slack
- wechat
- wecom
- whatsapp
- teams

Expand Down
2 changes: 1 addition & 1 deletion ci/test-file-size-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
35 changes: 35 additions & 0 deletions nemoclaw-blueprint/policies/presets/wecom.yaml
Original file line number Diff line number Diff line change
@@ -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 }
1 change: 1 addition & 0 deletions nemoclaw-blueprint/policies/tiers.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
11 changes: 10 additions & 1 deletion scripts/nemoclaw-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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}"

Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/lib/agent/defs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ describe("agent definitions", () => {
"discord",
"slack",
"wechat",
"wecom",
"whatsapp",
"teams",
]);
Expand Down Expand Up @@ -93,6 +94,7 @@ describe("agent definitions", () => {
"discord",
"slack",
"wechat",
"wecom",
"whatsapp",
"teams",
]);
Expand Down
46 changes: 45 additions & 1 deletion src/lib/channel-runtime-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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 —
Expand Down
23 changes: 14 additions & 9 deletions src/lib/channel-runtime-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,21 +112,26 @@ export function extractEnabledChannelsFromOpenclawConfig(json: unknown): string[
if (!json || typeof json !== "object") return [];
const channels = (json as Record<string, unknown>).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<string>();
for (const [key, value] of Object.entries(channels as Record<string, unknown>)) {
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<string, unknown>).accounts;
const channelConfig = value as Record<string, unknown>;
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<string, unknown>)) {
if (
account &&
typeof account === "object" &&
(account as Record<string, unknown>).enabled === true
) {
visible.add(canonical);
visible.add(metadata.channelId);
break;
}
}
Expand Down Expand Up @@ -216,13 +221,13 @@ export function parseGatewayLogScanOutput(stdout: string): Set<string> {

const DEFAULT_GATEWAY_LOG_PATH = "/tmp/gateway.log";

function runtimeConfigKeyToChannelName(
function runtimeConfigKeyToMetadata(
outputs: readonly OpenClawRuntimeChannelMetadata[],
): ReadonlyMap<string, string> {
const aliases = new Map<string, string>();
): ReadonlyMap<string, OpenClawRuntimeChannelMetadata> {
const aliases = new Map<string, OpenClawRuntimeChannelMetadata>();
for (const output of outputs) {
for (const key of output.configKeys) {
aliases.set(key, output.channelId);
aliases.set(key, output);
}
}
return aliases;
Expand Down
6 changes: 6 additions & 0 deletions src/lib/messaging-channel-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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 ",
Expand All @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions src/lib/messaging/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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

Expand Down
Loading