diff --git a/Dockerfile b/Dockerfile index 6cb0f5b6bb0..91c649d78dc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -225,10 +225,12 @@ COPY scripts/nemoclaw-start.sh /usr/local/bin/nemoclaw-start COPY nemoclaw-blueprint/scripts/*.js /usr/local/lib/nemoclaw/preloads/ COPY scripts/codex-acp-wrapper.sh /usr/local/bin/nemoclaw-codex-acp COPY scripts/generate-openclaw-config.py /usr/local/lib/nemoclaw/generate-openclaw-config.py +COPY scripts/seed-wechat-accounts.py /usr/local/lib/nemoclaw/seed-wechat-accounts.py COPY nemoclaw-blueprint/openclaw-plugins/ /usr/local/share/nemoclaw/openclaw-plugins/ RUN chmod 755 /usr/local/bin/nemoclaw-start /usr/local/bin/nemoclaw-codex-acp \ /usr/local/lib/nemoclaw/sandbox-init.sh \ /usr/local/lib/nemoclaw/generate-openclaw-config.py \ + /usr/local/lib/nemoclaw/seed-wechat-accounts.py \ && if [ -d /usr/local/lib/nemoclaw/preloads ]; then find /usr/local/lib/nemoclaw/preloads -type f -name '*.js' -exec chmod 644 {} +; fi \ && chmod 755 /usr/local/share/nemoclaw \ /usr/local/share/nemoclaw/openclaw-plugins \ @@ -279,6 +281,12 @@ ARG NEMOCLAW_DISCORD_GUILDS_B64=e30= # When requireMention is true, Telegram groups get groups: {"*": {"requireMention": true}} # with groupPolicy: open. See #1737, #3022. Default: empty map. ARG NEMOCLAW_TELEGRAM_CONFIG_B64=e30= +# Base64-encoded JSON WeChat config (e.g. +# {"accountId":"…","baseUrl":"https://…","userId":"…"}). +# Captured by the host-side iLink QR login during onboard. Non-secret per-account +# metadata only — the bot token flows through the OpenShell provider, never +# baked into the image. Default: empty map. +ARG NEMOCLAW_WECHAT_CONFIG_B64=e30= # Set to "1" to force-disable device-pairing auth. Also auto-disabled when # CHAT_UI_URL is a non-loopback address (Brev Launchable, remote deployments) # since terminal-based pairing is impossible in those contexts. @@ -325,6 +333,7 @@ ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \ NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=${NEMOCLAW_MESSAGING_ALLOWED_IDS_B64} \ NEMOCLAW_DISCORD_GUILDS_B64=${NEMOCLAW_DISCORD_GUILDS_B64} \ NEMOCLAW_TELEGRAM_CONFIG_B64=${NEMOCLAW_TELEGRAM_CONFIG_B64} \ + NEMOCLAW_WECHAT_CONFIG_B64=${NEMOCLAW_WECHAT_CONFIG_B64} \ NEMOCLAW_DISABLE_DEVICE_AUTH=${NEMOCLAW_DISABLE_DEVICE_AUTH} \ NEMOCLAW_PROXY_HOST=${NEMOCLAW_PROXY_HOST} \ NEMOCLAW_PROXY_PORT=${NEMOCLAW_PROXY_PORT} \ @@ -360,11 +369,32 @@ USER sandbox # list of env vars and derivation rules. RUN python3 /usr/local/lib/nemoclaw/generate-openclaw-config.py -# Install NemoClaw plugin into OpenClaw. Prune non-runtime metadata from -# staged bundled plugin dependencies before this layer is committed; deleting -# it in a later layer would not reduce the OCI image imported by k3s. +# TEMPORARY: install the WeChat plugin here (was moved to Dockerfile.base in +# e23486b but the wholesale rewrite by generate-openclaw-config.py above +# blew away plugins.installs.openclaw-weixin from base's openclaw.json, +# leaving the plugin unloadable at runtime and taking Telegram down with it). +# Running the install AFTER generate-openclaw-config.py merges the registry +# entry into the freshly-written config. Seed the per-account state right +# after so the bridge picks up the captured iLink session. +# hadolint ignore=DL3059,DL4006 RUN (openclaw doctor --fix > /dev/null 2>&1 || true) \ - && (openclaw plugins install /opt/nemoclaw > /dev/null 2>&1 || true) \ + && openclaw plugins install \ + '@tencent-weixin/openclaw-weixin@2.4.2' --pin \ + && openclaw config set plugins.entries.openclaw-weixin.enabled true \ + && python3 /usr/local/lib/nemoclaw/seed-wechat-accounts.py + +# Lock down npm: no further registry traffic in this image. Everything past +# this point must resolve from local sources only. +ENV NPM_CONFIG_OFFLINE=true \ + NPM_CONFIG_AUDIT=false \ + NPM_CONFIG_FUND=false + +# Install NemoClaw plugin into OpenClaw (local /opt/nemoclaw, no network). +# Prune non-runtime metadata from staged bundled plugin dependencies before +# this layer is committed; deleting it in a later layer would not reduce the +# OCI image imported by k3s. +# hadolint ignore=DL3059,DL4006 +RUN (openclaw plugins install /opt/nemoclaw > /dev/null 2>&1 || true) \ && if [ -d /sandbox/.openclaw/plugin-runtime-deps ]; then \ find /sandbox/.openclaw/plugin-runtime-deps -type f \( \ -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' -o \ @@ -474,6 +504,7 @@ RUN set -eu; \ "$config_dir/flows" \ "$config_dir/sandbox" \ "$config_dir/telegram" \ + "$config_dir/wechat" \ "$config_dir/media" \ "$config_dir/plugin-runtime-deps"; \ touch "$config_dir/update-check.json" "$config_dir/exec-approvals.json"; \ diff --git a/Dockerfile.base b/Dockerfile.base index e7a3cc5aaf7..9d960c29045 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -40,7 +40,6 @@ # Dockerfile and Dockerfile.base # 5. New .openclaw subdirectory — add mkdir below # 6. PyYAML or other pip dep bump — change the version below -# # For ad-hoc rebuilds (e.g., security patch), use workflow_dispatch on # the base-image workflow. # diff --git a/agents/openclaw/manifest.yaml b/agents/openclaw/manifest.yaml index e922e996b00..cb55565f0c3 100644 --- a/agents/openclaw/manifest.yaml +++ b/agents/openclaw/manifest.yaml @@ -51,6 +51,7 @@ state_dirs: - cron - memory - telegram + - wechat - credentials # ── Authentication ────────────────────────────────────────────── @@ -63,6 +64,7 @@ messaging_platforms: - telegram - discord - slack + - wechat # ── Inference ─────────────────────────────────────────────────── inference: diff --git a/docs/manage-sandboxes/messaging-channels.md b/docs/manage-sandboxes/messaging-channels.md index a892ca2896c..2344ffbeba8 100644 --- a/docs/manage-sandboxes/messaging-channels.md +++ b/docs/manage-sandboxes/messaging-channels.md @@ -3,11 +3,13 @@ title: page: "Set Up Messaging Channels with NemoClaw and OpenShell" nav: "Set Up Messaging Channels" description: - main: "Connect Telegram, Discord, or Slack to your sandboxed OpenClaw agent using OpenShell-managed channel messaging." - agent: "Explains how Telegram, Discord, and Slack reach the sandboxed OpenClaw agent through OpenShell-managed processes and NemoClaw channel commands. Use when setting up messaging channels, chat interfaces, or integrations without relying on nemoclaw tunnel start for bridges." -keywords: ["nemoclaw messaging channels", "nemoclaw telegram", "nemoclaw discord", "nemoclaw slack", "openshell channel messaging"] + main: "Connect Telegram, Discord, Slack, or WeChat to your sandboxed OpenClaw agent using OpenShell-managed channel messaging." + agent: >- + Explains how Telegram, Discord, Slack, and WeChat reach the sandboxed OpenClaw agent through OpenShell-managed processes and NemoClaw channel commands. + Use when setting up messaging channels, chat interfaces, or integrations without relying on `nemoclaw tunnel start` for bridges. +keywords: ["nemoclaw messaging channels", "nemoclaw telegram", "nemoclaw discord", "nemoclaw slack", "nemoclaw wechat", "openshell channel messaging"] topics: ["generative_ai", "ai_agents"] -tags: ["openclaw", "openshell", "telegram", "discord", "slack", "messaging", "deployment", "nemoclaw"] +tags: ["openclaw", "openshell", "telegram", "discord", "slack", "wechat", "messaging", "deployment", "nemoclaw"] content: type: how_to difficulty: intermediate @@ -24,13 +26,14 @@ status: published # Messaging Channels -Telegram, Discord, and Slack reach your agent through OpenShell-managed processes and gateway constructs. +Telegram, Discord, Slack, and WeChat reach your agent through OpenShell-managed processes and gateway constructs. NemoClaw registers channel tokens with OpenShell providers, bakes the selected channel configuration into the sandbox image, and keeps runtime delivery under OpenShell control. You can enable channels during `nemoclaw onboard` or add them later with host-side `nemoclaw channels` commands. -Do not run `openclaw channels add` or `openclaw channels remove` inside the sandbox because `/sandbox/.openclaw/openclaw.json` is generated at image build time and changes inside the running container do not persist across rebuilds. +WeChat works through the same channel commands, with one exception that the iLink QR handshake requires an interactive terminal — see [Add Channels After Onboarding](#add-channels-after-onboarding) for the details. +Do not run `openclaw channels add` or `openclaw channels remove` inside the sandbox because the image build generates `/sandbox/.openclaw/openclaw.json` at build time and changes inside the running container do not persist across rebuilds. -`nemoclaw tunnel start` does not start Telegram, Discord, Slack, or other chat bridges. +`nemoclaw tunnel start` does not start Telegram, Discord, Slack, WeChat, or other chat bridges. It only starts optional host services such as the cloudflared tunnel when that binary is present. (`nemoclaw start` is kept as a deprecated alias.) For details, refer to [Commands](../reference/commands.md). @@ -47,6 +50,7 @@ For details, refer to [Commands](../reference/commands.md). | Telegram | `TELEGRAM_BOT_TOKEN` | `TELEGRAM_ALLOWED_IDS` for DM allowlisting, `TELEGRAM_REQUIRE_MENTION` for group-chat replies | | Discord | `DISCORD_BOT_TOKEN` | `DISCORD_SERVER_ID`, `DISCORD_USER_ID`, `DISCORD_REQUIRE_MENTION` | | Slack | `SLACK_BOT_TOKEN`, `SLACK_APP_TOKEN` | None | +| WeChat (personal) | Host-side QR scan during `nemoclaw onboard` captures the token — no token to paste | `WECHAT_ALLOWED_IDS` for DM allowlisting (NemoClaw adds the WeChat user who scanned the QR automatically) | Telegram uses a bot token from [BotFather](https://t.me/BotFather). Open Telegram, send `/newbot` to [@BotFather](https://t.me/BotFather), follow the prompts, and copy the token. @@ -64,13 +68,28 @@ Set `DISCORD_USER_ID` to restrict access to one user; otherwise, any member of t Slack uses Socket Mode and requires two tokens. Use `SLACK_BOT_TOKEN` for the bot user OAuth token (`xoxb-...`) and `SLACK_APP_TOKEN` for the app-level Socket Mode token (`xapp-...`). +WeChat delivers messages over Tencent's iLink gateway via the upstream `@tencent-weixin/openclaw-weixin` plugin, baked into the sandbox base image. +The supported mode in this release is **personal WeChat** (`bot_type=3`). +WeChat Official Account and WeCom/Enterprise WeChat are not wired up yet. +Because the bot token only exists after a successful iLink QR handshake, NemoClaw runs the QR login on the host during `nemoclaw onboard`. +You scan the QR with WeChat on your phone (Discover → Scan), confirm the login, and NemoClaw captures the token, `accountId`, `baseUrl`, and `userId` from the iLink response. +NemoClaw registers the token as the `-wechat-bridge` OpenShell provider and substitutes the `openshell:resolve:env:WECHAT_BOT_TOKEN` placeholder for it inside the sandbox, so the token never lands in the image or on disk inside the running container. +WeChat is DM-only (`allowIdsMode: "dm"`) — NemoClaw adds the operator who scanned the QR to `WECHAT_ALLOWED_IDS` automatically, and you can append more comma-separated WeChat user IDs through the same env var. +You can silence the host-side `[wechat]` diagnostic lines (poll status, IDC redirects, swallowed gateway errors) by exporting `NEMOCLAW_WECHAT_QUIET=1` once the flow is stable in your environment. +Tencent's iLink gateway is a third-party service. +Review your organization's terms-of-service, compliance, and data-residency constraints before enabling WeChat in production. + ## Enable Channels During Onboarding -When the wizard reaches **Messaging channels**, it lists Telegram, Discord, and Slack. +When the wizard reaches **Messaging channels**, it lists Telegram, Discord, Slack, and WeChat. Press a channel number to toggle it on or off, then press **Enter** when done. If a token is not already in the environment or credential store, the wizard prompts for it and saves it. NemoClaw also selects the matching network policy preset during policy setup so the channel can reach its provider API. +If you enable WeChat, the wizard does not prompt for a paste token. +Instead, it renders a QR code in your terminal, polls Tencent's iLink gateway, and captures the bot token after you scan the QR with WeChat on your phone. +The login has an eight-minute deadline, refreshes the QR up to three times on expiry, and follows iLink's IDC redirects automatically — keep the terminal in the foreground until you see `✓ WeChat login confirmed`. + For scripted setup, export the credentials and optional settings for the channels you want to enable before you run onboarding: ```console @@ -82,13 +101,16 @@ $ export SLACK_BOT_TOKEN= $ export SLACK_APP_TOKEN= ``` +This release does not support non-interactive WeChat configuration because the iLink QR handshake requires a human to scan the QR on a paired phone. +Run `nemoclaw onboard` interactively when you want to enable WeChat. + Then run onboarding: ```console $ nemoclaw onboard ``` -Complete the rest of the wizard so the blueprint can create OpenShell providers (for example `-telegram-bridge`), bake channel configuration into the image (`NEMOCLAW_MESSAGING_CHANNELS_B64`), and start the sandbox. +Complete the rest of the wizard so the blueprint can create OpenShell providers (for example `-telegram-bridge`, `-wechat-bridge`), bake channel configuration into the image (`NEMOCLAW_MESSAGING_CHANNELS_B64`), and start the sandbox. ## Add Channels After Onboarding @@ -105,6 +127,7 @@ Add the channel you want: $ nemoclaw my-assistant channels add telegram $ nemoclaw my-assistant channels add discord $ nemoclaw my-assistant channels add slack +$ nemoclaw my-assistant channels add wechat ``` `channels add` prompts for missing credentials, registers the bridge with the OpenShell gateway, updates the sandbox registry, and asks whether to rebuild immediately. @@ -136,27 +159,58 @@ $ DISCORD_BOT_TOKEN= \ nemoclaw my-assistant channels add discord ``` +### `channels add wechat` + +`channels add wechat` follows the same shape as the other channels with two differences driven by the iLink QR handshake. + +First, the command does not prompt for a paste token. +Instead, it renders a QR code in your terminal, polls Tencent's iLink gateway, and captures both the bot token and the per-account metadata (`accountId`, `baseUrl`, `userId`) once you scan the QR with WeChat on your phone (Discover → Scan). +The login has an eight-minute deadline and refreshes the QR up to three times on expiry; keep the terminal in the foreground until you see `✓ WeChat login confirmed`. + +Second, the command requires an interactive terminal. +Non-interactive mode (`NEMOCLAW_NON_INTERACTIVE=1`) fails fast with a clear error because the QR handshake needs a paired phone. + +```console +$ nemoclaw my-assistant channels add wechat +``` + +If `WECHAT_BOT_TOKEN` is already cached for this sandbox (the operator onboarded with WeChat earlier), `channels add wechat` reuses the cached token and skips the QR scan to keep the upstream plugin's existing iLink session intact. +Re-running QR would invalidate that session; use `channels remove wechat` first if you intend to acquire a fresh account. + ## Rotate or Remove Credentials Running `channels add` for a channel that is already configured overwrites the stored tokens and registers the updated bridge provider. +For WeChat the cached-token short-circuit applies; see [`channels add wechat`](#channels-add-wechat) for how to acquire a fresh account. Rebuild the sandbox after the update so the image reflects the current channel set. To remove a channel and clear its stored credentials, run: ```console $ nemoclaw my-assistant channels remove telegram +$ nemoclaw my-assistant channels remove wechat ``` +`channels remove wechat` clears the bot token, deletes the `-wechat-bridge` OpenShell provider, and drops wechat from the sandbox's enabled-channel set. +The next rebuild produces an image without the wechat channel block in `openclaw.json` and without the per-account state files under `/sandbox/.openclaw/openclaw-weixin/`. + Use `channels stop` when you want to pause a bridge without deleting credentials: ```console $ nemoclaw my-assistant channels stop telegram $ nemoclaw my-assistant channels start telegram + +$ nemoclaw my-assistant channels stop wechat +$ nemoclaw my-assistant channels start wechat ``` -Telegram, Discord, and Slack each allow only one active consumer per channel credential. +For WeChat specifically, `channels stop wechat` followed by a rebuild keeps the per-account state files under `/sandbox/.openclaw/openclaw-weixin/accounts/` intact even though the bridge is no longer wired up in `openclaw.json`. +A subsequent `channels start wechat` + rebuild revives the bridge against the same iLink account without a fresh QR scan. +The bot token is held by the OpenShell provider across the stop/start cycle. + +Telegram, Discord, Slack, and WeChat each allow only one active consumer per channel credential. Multiple sandboxes can use the same channel type at the same time when each sandbox uses a distinct bot/app token. For example, two Telegram sandboxes can DM the same `TELEGRAM_ALLOWED_IDS` account as long as they use different `TELEGRAM_BOT_TOKEN` values. +For WeChat, each sandbox must own a distinct iLink `accountId` (bot identity) — running two sandboxes against the same WeChat account causes one of them to lose messages. If you enable a messaging channel and another sandbox already uses the same token, onboarding prompts you to confirm before continuing in interactive mode and exits non-zero in non-interactive mode. If NemoClaw only has legacy channel metadata and cannot compare credential hashes, it keeps the conservative warning; re-run `channels add ` with the intended token to refresh the stored non-secret hash. `nemoclaw status` reports cross-sandbox overlaps so you can resolve duplicates before messages start dropping. @@ -165,13 +219,14 @@ If NemoClaw only has legacy channel metadata and cannot compare credential hashe Use `channels stop` when you want to pause one bridge and keep the sandbox running. Use `nemoclaw tunnel stop` or its deprecated alias `nemoclaw stop` when you want to stop host auxiliary services and also ask NemoClaw to stop the OpenClaw gateway inside the selected sandbox. -Stopping the in-sandbox gateway stops Telegram, Discord, and Slack polling for that sandbox until you restart the sandbox or gateway. +Stopping the in-sandbox gateway stops Telegram, Discord, Slack, and WeChat polling for that sandbox until you restart the sandbox or gateway. ## Confirm Delivery After the sandbox is running, send a message to the configured bot or app. If delivery fails, use `openshell term` on the host, check gateway logs, and verify network policy allows the channel API. -Use the matching policy preset (`telegram`, `discord`, or `slack`) or review [Common Integration Policy Examples](../network-policy/integration-policy-examples.md). +Use the matching policy preset (`telegram`, `discord`, `slack`, or `wechat`) or review [Common Integration Policy Examples](../network-policy/integration-policy-examples.md). +For WeChat specifically, the in-sandbox bridge emits a single `[wechat] [] provider ready` line on stderr after the first successful iLink hit and an annotated line when the agent turn fails after the provider connected; the diagnostics preload produces both lines, which help you tell "channel up, inference broken" apart from "channel never connected". ## Tunnel Command diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 48df6fc6dcf..3dd4c2f20b2 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -1172,6 +1172,7 @@ These flags toggle optional behaviors during onboarding; set them before running | `NEMOCLAW_OPENSHELL_GATEWAY_BIN` | path | Advanced override for the `openshell-gateway` binary used by the Linux Docker-driver gateway. Defaults to the binary next to `openshell`, then common install paths. | | `NEMOCLAW_OPENSHELL_SANDBOX_BIN` | path | Advanced override for the `openshell-sandbox` binary passed to the Linux Docker-driver gateway supervisor. Defaults to the binary next to `openshell`, then common install paths. | | `NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR` | path | Advanced override for the Linux Docker-driver gateway pid file and SQLite state directory. Defaults to `~/.local/state/nemoclaw/openshell-docker-gateway`. | +| `NEMOCLAW_WECHAT_QUIET` | `1` to enable | Silences the `[wechat]` diagnostic lines printed during the host-side WeChat QR login (poll status, IDC redirects, swallowed gateway errors). Visible by default while the WeChat path stabilizes; set `1` once the flow is reliable in your environment. | ### Probe Timeouts diff --git a/nemoclaw-blueprint/policies/presets/wechat.yaml b/nemoclaw-blueprint/policies/presets/wechat.yaml new file mode 100644 index 00000000000..8d0363f1977 --- /dev/null +++ b/nemoclaw-blueprint/policies/presets/wechat.yaml @@ -0,0 +1,44 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# WeChat (personal) channel egress, via @tencent-weixin/openclaw-weixin. +# +# OpenShell's SSRF engine matches `host:` as a literal string — `*.wechat.com` +# style wildcards are accepted by NemoClaw's preset validator but never expand +# at runtime, so any traffic to a non-listed subdomain logs `policy:- engine:ssrf` +# and fails DNS resolution. Until OpenShell ships wildcard support, every iLink +# IDC host the upstream plugin can hit must be listed explicitly here. +# +# Known hosts (extend when an operator observes a new IDC redirect): +# - ilinkai.weixin.qq.com bootstrap; hard-coded in src/ext/wechat/qr.ts +# - ilinkai.wechat.com per-account baseUrl returned after QR confirm +# +# To discover more: tail the sandbox OCSF log for `DENIED ... -> :443` +# entries during the bridge's getUpdates loop and add the host below, then +# rebuild. The host also surfaces in `session.wechatConfig.baseUrl` for the +# operator's own account at login time. +preset: + name: wechat + description: "WeChat (personal) iLink API access via @tencent-weixin/openclaw-weixin" + +network_policies: + wechat_bridge: + name: wechat_bridge + endpoints: + - host: ilinkai.weixin.qq.com + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: POST, path: "/**" } + - host: ilinkai.wechat.com + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } + - allow: { method: POST, path: "/**" } + binaries: + - { path: /usr/local/bin/node } + - { path: /usr/bin/node } diff --git a/nemoclaw-blueprint/policies/tiers.yaml b/nemoclaw-blueprint/policies/tiers.yaml index 89e1d15bd03..68bef937868 100644 --- a/nemoclaw-blueprint/policies/tiers.yaml +++ b/nemoclaw-blueprint/policies/tiers.yaml @@ -39,5 +39,6 @@ tiers: - { name: slack, access: read-write } - { name: discord, access: read-write } - { name: telegram, access: read-write } + - { name: wechat, access: read-write } - { name: jira, access: read-write } - { name: outlook, access: read-write } diff --git a/nemoclaw-blueprint/scripts/wechat-diagnostics.js b/nemoclaw-blueprint/scripts/wechat-diagnostics.js new file mode 100644 index 00000000000..e713bad16e8 --- /dev/null +++ b/nemoclaw-blueprint/scripts/wechat-diagnostics.js @@ -0,0 +1,151 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// wechat-diagnostics.js — adds runtime breadcrumbs for the +// @tencent-weixin/openclaw-weixin channel without changing channel behavior. +// Mirrors telegram-diagnostics.js: surfaces a single "provider ready" line +// once iLink answers a CGI call, and prints an annotated line if an agent +// turn fails after the WeChat bridge has connected so operators can tell +// "channel up, inference broken" apart from "channel never connected". + +(function () { + 'use strict'; + + if (process.__nemoclawWechatDiagnosticsInstalled) return; + try { + Object.defineProperty(process, '__nemoclawWechatDiagnosticsInstalled', { value: true }); + } catch (_e) { + process.__nemoclawWechatDiagnosticsInstalled = true; + } + + var providerStarted = false; + var readyLogged = false; + var inferenceLogged = false; + var inDiagnosticWrite = false; + + function sanitize(value) { + var text = String(value || ''); + // iLink puts the bot token in URL query params (?bot_token=...) and + // sometimes in JSON bodies; redact both shapes. Keep the parameter name + // visible so an operator can still see the request shape. + text = text.replace(/(bot_token=)[^&\s"']+/gi, '$1'); + text = text.replace(/("bot_token"\s*:\s*")[^"]+/gi, '$1'); + text = text.replace(/Bearer\s+[A-Za-z0-9._~+\/=-]+/g, 'Bearer '); + text = text.replace( + /\b(api[_-]?key|token|authorization|wechat[_-]?bot[_-]?token)\b(["']?\s*[:=]\s*["']?)[^"'\s,)]+/gi, + '$1$2' + ); + return text; + } + + var originalStderrWrite = process.stderr.write.bind(process.stderr); + + function emit(line) { + if (inDiagnosticWrite) return; + inDiagnosticWrite = true; + try { + originalStderrWrite(line + '\n'); + } finally { + inDiagnosticWrite = false; + } + } + + function describeRequest(arg1, arg2) { + var url = null; + var opts = null; + if (typeof arg1 === 'string' || arg1 instanceof URL) { + try { + url = new URL(String(arg1)); + } catch (_e) { + url = null; + } + if (arg2 && typeof arg2 === 'object' && typeof arg2 !== 'function') opts = arg2; + } else if (arg1 && typeof arg1 === 'object') { + opts = arg1; + } + + var hostname = ''; + var pathStr = ''; + if (url) { + hostname = url.hostname || ''; + pathStr = (url.pathname || '') + (url.search || ''); + } + if (opts) { + hostname = String(opts.hostname || opts.host || hostname || ''); + pathStr = String(opts.path || pathStr || ''); + } + if (hostname.indexOf(':') !== -1) hostname = hostname.split(':')[0]; + return { hostname: hostname, path: pathStr }; + } + + // The iLink gateway uses dynamic per-account subdomains under + // *.weixin.qq.com — and *.wechat.com (e.g. ilinkai.wechat.com) — so match + // the suffix rather than a single host. We treat any successful 2xx hit + // on a /ilink/bot/* path as "provider ready". + function isWechatHost(hostname) { + if (!hostname) return false; + return ( + hostname === 'weixin.qq.com' || + hostname.endsWith('.weixin.qq.com') || + hostname === 'wechat.com' || + hostname.endsWith('.wechat.com') + ); + } + + function accountIdFromEnv() { + var raw = process.env.WECHAT_ACCOUNT_ID; + if (typeof raw !== 'string') return 'default'; + var trimmed = raw.trim(); + return trimmed || 'default'; + } + + function maybeLogWechatReady(info, statusCode) { + if (readyLogged) return; + if (!info || !isWechatHost(info.hostname)) return; + if (info.path.indexOf('/ilink/bot/') !== 0 && info.path.indexOf('/ilink/bot') !== 0) return; + if (Number(statusCode) < 200 || Number(statusCode) >= 300) return; + providerStarted = true; + readyLogged = true; + emit('[wechat] [' + accountIdFromEnv() + '] provider ready (iLink reachable; agent replies use inference.local)'); + } + + function wrapHttp(mod, methodName) { + var original = mod[methodName]; + if (typeof original !== 'function') return; + mod[methodName] = function () { + var info = describeRequest(arguments[0], arguments[1]); + var req = original.apply(this, arguments); + if (isWechatHost(info.hostname) && req && typeof req.once === 'function') { + req.once('response', function (res) { + maybeLogWechatReady(info, res && res.statusCode); + }); + } + return req; + }; + } + + process.stderr.write = function (chunk, _encoding, _cb) { + var ret = originalStderrWrite.apply(process.stderr, arguments); + if (!inDiagnosticWrite && !inferenceLogged) { + var text = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk || ''); + if (!providerStarted && /\[wechat\]\s*\[[^\]]+\]\s*starting provider\b/i.test(text)) { + providerStarted = true; + } + if (providerStarted && /Embedded agent failed before reply|LLM request failed|FailoverError/i.test(text)) { + inferenceLogged = true; + var line = text.split(/\r?\n/).find(function (entry) { + return /Embedded agent failed before reply|LLM request failed|FailoverError/i.test(entry); + }) || text; + emit('[wechat] [' + accountIdFromEnv() + '] agent turn failed after provider startup; inference error: ' + sanitize(line).slice(0, 600)); + } + } + return ret; + }; + + var http = require('http'); + var https = require('https'); + wrapHttp(http, 'request'); + wrapHttp(http, 'get'); + wrapHttp(https, 'request'); + wrapHttp(https, 'get'); +})(); diff --git a/package-lock.json b/package-lock.json index 5d5dba13b43..30244a7a29d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@oclif/core": "^4.10.5", "js-yaml": "^4.1.1", "p-retry": "^4.6.2", + "qrcode-terminal": "^0.12.0", "yaml": "^2.8.3" }, "bin": { @@ -6068,6 +6069,14 @@ "once": "^1.3.1" } }, + "node_modules/qrcode-terminal": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz", + "integrity": "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==", + "bin": { + "qrcode-terminal": "bin/qrcode-terminal.js" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", diff --git a/package.json b/package.json index cc6b6467d30..1cf24253b32 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "@oclif/core": "^4.10.5", "js-yaml": "^4.1.1", "p-retry": "^4.6.2", + "qrcode-terminal": "^0.12.0", "yaml": "^2.8.3" }, "bundleDependencies": [ diff --git a/scripts/generate-openclaw-config.py b/scripts/generate-openclaw-config.py index 65510cfa847..799f1037a4f 100755 --- a/scripts/generate-openclaw-config.py +++ b/scripts/generate-openclaw-config.py @@ -30,6 +30,7 @@ NEMOCLAW_MESSAGING_ALLOWED_IDS_B64 Base64-encoded allowed IDs map NEMOCLAW_DISCORD_GUILDS_B64 Base64-encoded Discord guild config NEMOCLAW_TELEGRAM_CONFIG_B64 Base64-encoded Telegram config (e.g. {"requireMention": true}) + NEMOCLAW_WECHAT_CONFIG_B64 Base64-encoded WeChat config (e.g. {"accountId": "...", "baseUrl": "...", "userId": "..."}) NEMOCLAW_DISABLE_DEVICE_AUTH Set to "1" to force-disable device auth NEMOCLAW_PROXY_HOST Egress proxy host (default: 10.200.0.1) NEMOCLAW_PROXY_PORT Egress proxy port (default: 3128) @@ -442,8 +443,18 @@ def build_config(env: dict | None = None) -> dict: env.get("NEMOCLAW_TELEGRAM_CONFIG_B64", "e30=") or "e30=" ).decode("utf-8") ) - - _token_keys = {"discord": "token", "telegram": "botToken", "slack": "botToken"} + # NEMOCLAW_WECHAT_CONFIG_B64 is intentionally not decoded here. The + # WeChat plugin's per-account state (accountId/baseUrl/userId) is read by + # seed-wechat-accounts.py, which the Dockerfile invokes separately after + # `openclaw plugins install` registers the openclaw-weixin channel id. + # Decoding it here too would create a misleading second consumer that + # nothing acts on. + + _token_keys = { + "discord": "token", + "telegram": "botToken", + "slack": "botToken", + } _env_keys = { "discord": "DISCORD_BOT_TOKEN", "telegram": "TELEGRAM_BOT_TOKEN", @@ -482,6 +493,24 @@ def _placeholder(channel: str, env_key: str) -> str: account["allowFrom"] = _allowed_ids[ch] _ch_cfg[ch] = {"accounts": {"default": account}} + # WeChat (openclaw-weixin) is NOT added to channels.* here — writing + # channels.openclaw-weixin upfront makes `openclaw plugins install` fail + # with "unknown channel id: openclaw-weixin" because the plugin registry + # hasn't seen the channel yet (chicken-and-egg). The block is written + # AFTER `openclaw plugins install` runs, by scripts/seed-wechat-accounts.py, + # which adds: + # channels.openclaw-weixin.channelConfigUpdatedAt = + # channels.openclaw-weixin.accounts..enabled = true + # The upstream plugin's auth/accounts.ts reads that block at boot to + # decide which accounts to start; without enabled=true the bridge no-ops. + # + # Per-account secrets (token, baseUrl, userId) still live in the plugin's + # own state dir at /openclaw-weixin/accounts/.json + # (also seeded by seed-wechat-accounts.py). DM allowlist uses the + # framework allowFrom file at credentials/openclaw-weixin-{accountId}- + # allowFrom.json — not the openclaw.json accounts..allowFrom mechanism + # that telegram/discord/slack use. + if "discord" in _ch_cfg and _discord_guilds: _ch_cfg["discord"].update( {"groupPolicy": "allowlist", "guilds": _discord_guilds} @@ -561,6 +590,12 @@ def _placeholder(channel: str, env_key: str) -> str: "acpx": {"enabled": False}, "bonjour": {"enabled": False}, "qqbot": {"enabled": False}, + # The @tencent-weixin/openclaw-weixin plugin is pre-installed in the + # base image (Dockerfile.base) so onboarding does not depend on the + # public npm registry for it. Enable the entry unconditionally — the + # bridge no-ops at startup unless seed-wechat-accounts.py has also + # registered an accountId under channels.openclaw-weixin.accounts. + "openclaw-weixin": {"enabled": True}, } _bundled_provider_plugins = { "amazon-bedrock": {"amazon-bedrock", "bedrock"}, @@ -673,6 +708,10 @@ def main() -> None: with open(path, "w") as f: json.dump(config, f, indent=2) os.chmod(path, 0o600) + # NOTE: seed-wechat-accounts.py is invoked separately from the Dockerfile + # AFTER `openclaw plugins install`. Calling it here would write + # channels.openclaw-weixin before the plugin registers its channel id, + # which makes the install fail with "unknown channel id: openclaw-weixin". if __name__ == "__main__": diff --git a/scripts/seed-wechat-accounts.py b/scripts/seed-wechat-accounts.py new file mode 100755 index 00000000000..55f22c9aad2 --- /dev/null +++ b/scripts/seed-wechat-accounts.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Seed @tencent-weixin/openclaw-weixin's local account store with the +# session metadata captured by NemoClaw's host-side QR login (see +# src/lib/wechat/login.ts). Runs once at sandbox image build time. +# +# Skips the upstream plugin's own `openclaw channels login` flow, which +# would otherwise drive an in-sandbox QR scan that has no terminal and no +# paired phone access. +# +# Files written (matching auth/accounts.ts in @tencent-weixin/openclaw-weixin@2.4.2): +# /openclaw-weixin/accounts.json — JSON array of accountIds +# /openclaw-weixin/accounts/.json — { token, savedAt, baseUrl, userId } +# /openclaw.json (channels.openclaw-weixin) — registered channel + accounts..enabled +# +# The third file is the one OpenClaw consults at startup to know the channel +# is registered. Without channels.openclaw-weixin.accounts..enabled=true +# in openclaw.json, the plugin's auth/accounts.ts considers the account +# disabled and the bridge won't start, even if the per-account state files +# above exist. We mutate openclaw.json HERE (post-install) rather than in +# generate-openclaw-config.py because writing channels.openclaw-weixin +# upfront races with `openclaw plugins install`, which fails with "unknown +# channel id: openclaw-weixin" if the channel block exists before the plugin +# has registered it. +# +# State dir resolution mirrors the upstream's resolveStateDir(): +# $OPENCLAW_STATE_DIR || $CLAWDBOT_STATE_DIR || ~/.openclaw +# +# Token field carries the canonical NemoClaw placeholder +# `openshell:resolve:env:WECHAT_BOT_TOKEN`. The OpenShell L7 proxy rewrites +# that string to the real bot token at egress, so the secret never lands +# on disk inside the image. +# +# Inputs (from environment, populated by the Dockerfile patcher): +# NEMOCLAW_WECHAT_CONFIG_B64 Base64-encoded JSON: {accountId, baseUrl, userId}. +# When accountId is empty (no host-side QR login +# captured), the script no-ops cleanly. +# NEMOCLAW_MESSAGING_CHANNELS_B64 Base64-encoded JSON array of active channel names. +# When "wechat" is absent (operator stopped the +# channel via `nemoclaw channels stop +# wechat`), we still write the per-account state +# files so a later `channels start wechat` can +# revive the bridge without a fresh QR scan — but +# we skip patching openclaw.json, so the bridge +# stays dormant until the channel is re-enabled. + +from __future__ import annotations + +import base64 +import datetime as _dt +import json +import os +import pathlib +import sys + + +WECHAT_TOKEN_PLACEHOLDER = "openshell:resolve:env:WECHAT_BOT_TOKEN" + + +def _wechat_enabled() -> bool: + """Decide whether wechat is in the active-channel whitelist for this build. + + NEMOCLAW_MESSAGING_CHANNELS_B64 carries the list of channels onboard + selected after applying the disable filter. When wechat is absent the + bridge must stay dormant on this image, so we skip the openclaw.json + patch even though the per-account state files still get written. + """ + raw = os.environ.get("NEMOCLAW_MESSAGING_CHANNELS_B64", "W10=") or "W10=" + try: + channels = json.loads(base64.b64decode(raw).decode("utf-8")) + except (ValueError, json.JSONDecodeError): + return False + return isinstance(channels, list) and "wechat" in channels + + +def _state_dir() -> pathlib.Path: + raw = ( + os.environ.get("OPENCLAW_STATE_DIR") + or os.environ.get("CLAWDBOT_STATE_DIR") + or os.path.join(os.path.expanduser("~"), ".openclaw") + ) + return pathlib.Path(raw.strip()).resolve() + + +def _decode_config() -> dict: + raw = os.environ.get("NEMOCLAW_WECHAT_CONFIG_B64", "e30=") or "e30=" + try: + decoded = base64.b64decode(raw).decode("utf-8") + parsed = json.loads(decoded) + except (ValueError, json.JSONDecodeError) as err: + print( + f"[seed-wechat-accounts] could not decode NEMOCLAW_WECHAT_CONFIG_B64: {err}", + file=sys.stderr, + ) + return {} + return parsed if isinstance(parsed, dict) else {} + + +def _atomic_write(path: pathlib.Path, payload: str, mode: int) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(payload, encoding="utf-8") + os.chmod(tmp, mode) + os.replace(tmp, path) + + +def _js_iso_utc() -> str: + """ISO-8601 UTC with millisecond precision and trailing 'Z' — the format + JavaScript's Date.toISOString() emits, which is what the upstream plugin + writes to channelConfigUpdatedAt.""" + now = _dt.datetime.now(_dt.timezone.utc) + return f"{now.strftime('%Y-%m-%dT%H:%M:%S')}.{now.microsecond // 1000:03d}Z" + + +def _patch_openclaw_config(account_id: str) -> None: + """Register channels.openclaw-weixin.accounts..enabled=true in + openclaw.json. The upstream plugin's auth/accounts.ts reads this block to + decide which accounts to start at boot.""" + cfg_path = _state_dir() / "openclaw.json" + if not cfg_path.exists(): + # generate-openclaw-config.py runs before us and is responsible for + # producing openclaw.json. If it's missing, something else broke; bail + # without inventing a config. + print( + f"[seed-wechat-accounts] {cfg_path} not found; cannot register channel", + file=sys.stderr, + ) + return + + try: + cfg = json.loads(cfg_path.read_text(encoding="utf-8")) + except json.JSONDecodeError as err: + print( + f"[seed-wechat-accounts] could not parse {cfg_path}: {err}", + file=sys.stderr, + ) + return + if not isinstance(cfg, dict): + print( + f"[seed-wechat-accounts] {cfg_path} root is not a JSON object; cannot register channel", + file=sys.stderr, + ) + return + + channels = cfg.setdefault("channels", {}) + weixin = channels.setdefault("openclaw-weixin", {}) + weixin["channelConfigUpdatedAt"] = _js_iso_utc() + accounts = weixin.setdefault("accounts", {}) + accounts[account_id] = {"enabled": True} + + _atomic_write(cfg_path, json.dumps(cfg, indent=2) + "\n", 0o600) + print( + f"[seed-wechat-accounts] registered channels.openclaw-weixin.accounts.{account_id} in {cfg_path}" + ) + + +def main() -> int: + config = _decode_config() + account_id = (config.get("accountId") or "").strip() + base_url = (config.get("baseUrl") or "").strip() + user_id = (config.get("userId") or "").strip() + + # accountId is non-secret but mandatory: without it we can't pick a + # filename, and the upstream plugin won't see any registered accounts. + # Empty accountId is the expected state when the operator did not go + # through a host-side QR login (e.g. wechat channel never picked) — + # no-op silently instead of warning, since this script now runs on + # every build from generate-openclaw-config.py. + if not account_id: + return 0 + + plugin_dir = _state_dir() / "openclaw-weixin" + accounts_index = plugin_dir / "accounts.json" + account_file = plugin_dir / "accounts" / f"{account_id}.json" + + # Per-account credential file. Schema mirrors WeixinAccountData; ordering + # mirrors saveWeixinAccount() so a future upstream save merges cleanly. + account_payload: dict[str, str] = { + "token": WECHAT_TOKEN_PLACEHOLDER, + "savedAt": _dt.datetime.now(_dt.timezone.utc).isoformat(), + } + if base_url: + account_payload["baseUrl"] = base_url + if user_id: + account_payload["userId"] = user_id + + _atomic_write(account_file, json.dumps(account_payload, indent=2) + "\n", 0o600) + + # Account index. Append-only semantics: if the upstream plugin or a prior + # seed step already registered other accountIds, preserve them. + existing: list[str] = [] + if accounts_index.exists(): + try: + raw = json.loads(accounts_index.read_text(encoding="utf-8")) + if isinstance(raw, list): + existing = [item for item in raw if isinstance(item, str) and item.strip()] + except json.JSONDecodeError: + existing = [] + + if account_id not in existing: + existing.append(account_id) + _atomic_write(accounts_index, json.dumps(existing, indent=2) + "\n", 0o600) + + print( + f"[seed-wechat-accounts] seeded {account_file} and registered {account_id} in {accounts_index}" + ) + + # Only register the channel in openclaw.json when wechat is enabled for + # this build. When the operator stopped the channel before rebuild, + # NEMOCLAW_MESSAGING_CHANNELS_B64 omits "wechat" and we leave the patch + # off — the account state files above are still on disk and ready for a + # later `channels start wechat` rebuild to activate. + if _wechat_enabled(): + _patch_openclaw_config(account_id) + else: + print( + "[seed-wechat-accounts] wechat not in active channels; preserving account " + "state files but skipping openclaw.json channel registration." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/ext/wechat/login.test.ts b/src/ext/wechat/login.test.ts new file mode 100644 index 00000000000..6e15934eac3 --- /dev/null +++ b/src/ext/wechat/login.test.ts @@ -0,0 +1,235 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { runWechatHostQrLogin } from "../../../dist/ext/wechat/login"; +import type { FetchLike } from "../../../dist/ext/wechat/qr"; + +type StatusBody = { + status: string; + bot_token?: string; + ilink_bot_id?: string; + baseurl?: string; + ilink_user_id?: string; + redirect_host?: string; +}; + +interface ScriptedRoute { + match: (url: string) => boolean; + bodies: StatusBody[] | { qrcode: string; qrcode_img_content: string }[]; +} + +/** Builds a fetch that walks a scripted sequence per matching route. The + * test asserts on the resulting login result, so timing/ordering of polls + * is observable through the route's body queue. */ +function scriptedFetch(routes: ScriptedRoute[]): { fetch: FetchLike; calls: string[] } { + const queues = routes.map((r) => ({ ...r, queue: [...r.bodies] })); + const calls: string[] = []; + const fetch: FetchLike = async (url) => { + calls.push(url); + const route = queues.find((r) => r.match(url)); + if (!route) { + return { ok: false, status: 599, text: async () => `unmatched ${url}` }; + } + const body = route.queue.length > 0 ? route.queue.shift()! : route.bodies[route.bodies.length - 1]; + return { + ok: true, + status: 200, + text: async () => JSON.stringify(body), + }; + }; + return { fetch, calls }; +} + +const isInit = (u: string) => u.includes("/ilink/bot/get_bot_qrcode"); +const isStatus = (u: string) => u.includes("/ilink/bot/get_qrcode_status"); + +const noopRender = (): void => {}; +const noopLog = (): void => {}; +const fastSleep = async (): Promise => {}; + +describe("runWechatHostQrLogin", () => { + it("returns ok with the bot token + per-account metadata on confirmed", async () => { + const { fetch } = scriptedFetch([ + { + match: isInit, + bodies: [{ qrcode: "qr-cookie-1", qrcode_img_content: "https://example.com/qr/1" }], + }, + { + match: isStatus, + bodies: [ + { status: "wait" }, + { status: "scaned" }, + { + status: "confirmed", + bot_token: "secret-bot-token", + ilink_bot_id: "bot-123", + baseurl: "https://idc-9.weixin.qq.com", + ilink_user_id: "user-abc", + }, + ], + }, + ]); + + const result = await runWechatHostQrLogin({ + fetch, + renderQr: noopRender, + log: noopLog, + sleep: fastSleep, + }); + + expect(result).toEqual({ + kind: "ok", + credentials: { + token: "secret-bot-token", + accountId: "bot-123", + baseUrl: "https://idc-9.weixin.qq.com", + userId: "user-abc", + }, + }); + }); + + it("follows scaned_but_redirect by switching the polling base URL", async () => { + const calls: string[] = []; + const { fetch } = scriptedFetch([ + { + match: isInit, + bodies: [{ qrcode: "qr-cookie-2", qrcode_img_content: "https://example.com/qr/2" }], + }, + { + match: isStatus, + bodies: [ + { status: "scaned_but_redirect", redirect_host: "idc-3.weixin.qq.com" }, + { + status: "confirmed", + bot_token: "tok-2", + ilink_bot_id: "bot-2", + baseurl: "https://idc-3.weixin.qq.com", + ilink_user_id: "user-2", + }, + ], + }, + ]); + + const tracingFetch: FetchLike = async (url, init) => { + calls.push(url); + return fetch(url, init); + }; + + const result = await runWechatHostQrLogin({ + fetch: tracingFetch, + renderQr: noopRender, + log: noopLog, + sleep: fastSleep, + }); + + expect(result.kind).toBe("ok"); + // First poll hits the bootstrap host; after the redirect, polling + // moves to the IDC the server pointed us at. + const statusCalls = calls.filter((u) => u.includes("get_qrcode_status")); + expect(statusCalls[0]).toContain("ilinkai.weixin.qq.com"); + expect(statusCalls[1]).toContain("idc-3.weixin.qq.com"); + }); + + it("refreshes the QR up to 3 times before giving up with kind=expired", async () => { + const { fetch } = scriptedFetch([ + { + match: isInit, + bodies: [ + { qrcode: "q1", qrcode_img_content: "u1" }, + { qrcode: "q2", qrcode_img_content: "u2" }, + { qrcode: "q3", qrcode_img_content: "u3" }, + ], + }, + { + // Every status response is "expired" until refresh budget exhausts. + match: isStatus, + bodies: [{ status: "expired" }], + }, + ]); + + const result = await runWechatHostQrLogin({ + fetch, + renderQr: noopRender, + log: noopLog, + sleep: fastSleep, + }); + + expect(result).toEqual({ kind: "expired", reason: "max_refresh_exceeded" }); + }); + + it("returns kind=timeout when the deadline elapses without confirmation", async () => { + const { fetch } = scriptedFetch([ + { match: isInit, bodies: [{ qrcode: "q", qrcode_img_content: "u" }] }, + { match: isStatus, bodies: [{ status: "wait" }] }, + ]); + + let virtualNow = 1_000_000; + const result = await runWechatHostQrLogin({ + fetch, + renderQr: noopRender, + log: noopLog, + // sleep advances the virtual clock so the deadline is hit deterministically. + sleep: async (ms) => { + virtualNow += ms; + }, + now: () => virtualNow, + totalTimeoutMs: 5_000, + pollIntervalMs: 1_000, + }); + + expect(result).toEqual({ kind: "timeout" }); + }); + + it("returns kind=aborted when an external signal fires before the first poll", async () => { + const { fetch } = scriptedFetch([ + { match: isInit, bodies: [{ qrcode: "q", qrcode_img_content: "u" }] }, + { match: isStatus, bodies: [{ status: "wait" }] }, + ]); + + const controller = new AbortController(); + controller.abort(); + const result = await runWechatHostQrLogin({ + fetch, + renderQr: noopRender, + log: noopLog, + sleep: fastSleep, + signal: controller.signal, + }); + + expect(result).toEqual({ kind: "aborted" }); + }); + + it("returns kind=error when the QR init request fails", async () => { + const fetch: FetchLike = async () => { + throw new Error("DNS lookup failed"); + }; + const result = await runWechatHostQrLogin({ + fetch, + renderQr: noopRender, + log: noopLog, + sleep: fastSleep, + }); + expect(result.kind).toBe("error"); + }); + + it("returns kind=error when confirmed but the server omits required metadata", async () => { + const { fetch } = scriptedFetch([ + { match: isInit, bodies: [{ qrcode: "q", qrcode_img_content: "u" }] }, + { + match: isStatus, + // missing baseurl + ilink_user_id — orchestrator must surface this + // as an error rather than silently returning partial credentials. + bodies: [{ status: "confirmed", bot_token: "tok", ilink_bot_id: "bot" }], + }, + ]); + const result = await runWechatHostQrLogin({ + fetch, + renderQr: noopRender, + log: noopLog, + sleep: fastSleep, + }); + expect(result.kind).toBe("error"); + }); +}); diff --git a/src/ext/wechat/login.ts b/src/ext/wechat/login.ts new file mode 100644 index 00000000000..b152b92baae --- /dev/null +++ b/src/ext/wechat/login.ts @@ -0,0 +1,288 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Host-side WeChat (personal) QR login orchestration. +// +// Drives the iLink QR handshake end-to-end: fetch the QR, render it in the +// terminal, poll for status, handle IDC redirects + QR refresh on expiry, +// and return the resulting credentials. Pure orchestration — the iLink +// HTTP layer lives in ./qr.ts and the terminal renderer is injected so +// tests can stay offline. + +import { + fetchWechatQrSession, + pollWechatQrStatus, + type FetchLike, + type WechatQrSession, + type WechatQrStatusResponse, + WechatQrError, + WECHAT_ILINK_BOOTSTRAP_BASE_URL, +} from "./qr"; + +/** Total deadline for a single login attempt. 8 minutes is long enough to + * cover a slow human + IDC redirects and short enough that a forgotten + * terminal eventually times out. */ +const DEFAULT_LOGIN_TIMEOUT_MS = 8 * 60_000; + +/** Pause between status polls when the server returned a fast response. */ +const DEFAULT_POLL_INTERVAL_MS = 1_000; + +/** Maximum number of QR refresh attempts per login. */ +const MAX_QR_REFRESH_COUNT = 3; + +export interface WechatLoginCredentials { + /** Bot token. Persist into OpenShell as the `WECHAT_BOT_TOKEN` provider + * credential; never write to disk. */ + token: string; + /** Stable per-account id (`ilink_bot_id`). Non-secret. */ + accountId: string; + /** Per-account base URL for subsequent CGI calls. Rotates via IDC; treat + * as authoritative at login time and re-fetch on next login. */ + baseUrl: string; + /** WeChat user id of the operator who scanned. Add to `WECHAT_ALLOWED_IDS` + * unless overridden. Non-secret but PII-adjacent — redact when logging. */ + userId: string; +} + +export type WechatLoginResult = + | { kind: "ok"; credentials: WechatLoginCredentials } + | { kind: "timeout" } + | { kind: "expired"; reason: "max_refresh_exceeded" } + | { kind: "aborted" } + | { kind: "error"; message: string }; + +export interface WechatLoginOptions { + /** Inject a fetch fake for tests. */ + fetch?: FetchLike; + /** Render a QR in the terminal. Defaults to qrcode-terminal. Tests can + * swap this for a no-op or capture. */ + renderQr?: (qrUrl: string) => void; + /** Sink for human-readable progress messages. Defaults to stderr; tests + * can capture. */ + log?: (message: string) => void; + /** Cooperative cancellation hook. */ + signal?: AbortSignal; + /** Override the overall login deadline. */ + totalTimeoutMs?: number; + /** Override the inter-poll pause. */ + pollIntervalMs?: number; + /** Override the bootstrap iLink host (offline tests). */ + bootstrapBaseUrl?: string; + /** Clock seam for tests. */ + now?: () => number; + /** Sleep seam for tests. */ + sleep?: (ms: number) => Promise; +} + +interface ResolvedLoginOptions { + fetch?: FetchLike; + renderQr: (qrUrl: string) => void; + log: (message: string) => void; + signal?: AbortSignal; + totalTimeoutMs: number; + pollIntervalMs: number; + bootstrapBaseUrl: string; + now: () => number; + sleep: (ms: number) => Promise; +} + +/** Default terminal renderer. Loaded lazily so unit tests that mock the + * renderer don't pay the import cost or the side effect of writing to + * stdout. */ +function defaultRenderer(qrUrl: string): void { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const qrterm = require("qrcode-terminal") as { + generate(text: string, opts: { small?: boolean }, cb?: (rendered: string) => void): void; + }; + qrterm.generate(qrUrl, { small: true }); +} + +function resolveOptions(opts: WechatLoginOptions = {}): ResolvedLoginOptions { + return { + fetch: opts.fetch, + renderQr: opts.renderQr ?? defaultRenderer, + log: opts.log ?? ((msg: string) => process.stderr.write(`${msg}\n`)), + signal: opts.signal, + totalTimeoutMs: opts.totalTimeoutMs ?? DEFAULT_LOGIN_TIMEOUT_MS, + pollIntervalMs: opts.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS, + bootstrapBaseUrl: opts.bootstrapBaseUrl ?? WECHAT_ILINK_BOOTSTRAP_BASE_URL, + now: opts.now ?? (() => Date.now()), + // Do NOT unref this timer. The inter-poll sleep is the only thing + // holding the event loop open between iterations once the previous + // fetch's keep-alive socket is released (notably after an IDC redirect + // switches hosts). An unref'd timer there causes Node to exit silently + // mid-login. + sleep: + opts.sleep ?? + ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))), + }; +} + +function emitQr(session: WechatQrSession, opts: ResolvedLoginOptions): void { + opts.log(""); + opts.log(" Scan the QR below with WeChat on your phone (look for: Discover → Scan)."); + opts.log(" If the QR does not render, open this URL on your phone instead:"); + opts.log(` ${session.qrcodeUrl}`); + opts.log(""); + try { + opts.renderQr(session.qrcodeUrl); + } catch (err) { + opts.log(` (could not render terminal QR: ${err instanceof Error ? err.message : String(err)})`); + } +} + +/** Run the host-side QR login end-to-end. Returns a discriminated result so + * callers can branch on success/expiry/timeout/abort without try/catch. */ +export async function runWechatHostQrLogin( + options: WechatLoginOptions = {}, +): Promise { + const opts = resolveOptions(options); + if (opts.signal?.aborted) return { kind: "aborted" }; + + let session: WechatQrSession; + try { + session = await fetchWechatQrSession({ + fetch: opts.fetch, + bootstrapBaseUrl: opts.bootstrapBaseUrl, + }); + } catch (err) { + return { kind: "error", message: errorMessage(err) }; + } + + emitQr(session, opts); + let scannedAnnounced = false; + // Counts refreshes only (the initial QR is not a refresh). MAX_QR_REFRESH_COUNT + // is the upper bound on refreshes per login; starting at 0 keeps the + // increment-then-compare guard at "case expired" allowing exactly that many. + let qrRefreshCount = 0; + let currentBaseUrl = opts.bootstrapBaseUrl; + const deadline = opts.now() + opts.totalTimeoutMs; + let lastStatus: string | undefined; + // Diagnostic sink — visible by default while the WeChat path is new so + // operators can self-diagnose IDC redirects and silently-swallowed + // gateway errors. Quiet via NEMOCLAW_WECHAT_QUIET=1 once the flow is + // stable in their environment. + const debug = process.env.NEMOCLAW_WECHAT_QUIET === "1" + ? (_msg: string) => {} + : (msg: string) => opts.log(` [wechat] ${msg}`); + debug(`polling ${currentBaseUrl}`); + + while (opts.now() < deadline) { + if (opts.signal?.aborted) return { kind: "aborted" }; + + let status: WechatQrStatusResponse; + try { + status = await pollWechatQrStatus({ + baseUrl: currentBaseUrl, + qrcode: session.qrcode, + fetch: opts.fetch, + signal: opts.signal, + onDebug: debug, + }); + } catch (err) { + // pollWechatQrStatus already swallows abort + gateway timeouts; any + // error escaping here is a real protocol/HTTP failure we can't recover + // from without restarting the login. + debug(`poll fatal: ${errorMessage(err)}`); + return { kind: "error", message: errorMessage(err) }; + } + if (status.status !== lastStatus) { + debug( + `status=${status.status}${status.redirect_host ? ` redirect_host=${status.redirect_host}` : ""}`, + ); + lastStatus = status.status; + } + + switch (status.status) { + case "wait": + await opts.sleep(opts.pollIntervalMs); + continue; + + case "scaned": + if (!scannedAnnounced) { + opts.log(" ✓ QR scanned. Confirm the login on your phone to continue…"); + scannedAnnounced = true; + } + await opts.sleep(opts.pollIntervalMs); + continue; + + case "scaned_but_redirect": { + if (status.redirect_host) { + currentBaseUrl = `https://${status.redirect_host}`; + opts.log(` → IDC redirect — continuing on ${status.redirect_host}`); + debug(`polling ${currentBaseUrl}`); + } + await opts.sleep(opts.pollIntervalMs); + continue; + } + + case "expired": { + qrRefreshCount += 1; + if (qrRefreshCount > MAX_QR_REFRESH_COUNT) { + return { kind: "expired", reason: "max_refresh_exceeded" }; + } + opts.log(` ⏳ QR expired — refreshing (${qrRefreshCount}/${MAX_QR_REFRESH_COUNT})…`); + try { + session = await fetchWechatQrSession({ + fetch: opts.fetch, + bootstrapBaseUrl: opts.bootstrapBaseUrl, + }); + } catch (err) { + return { kind: "error", message: errorMessage(err) }; + } + currentBaseUrl = opts.bootstrapBaseUrl; + scannedAnnounced = false; + emitQr(session, opts); + await opts.sleep(opts.pollIntervalMs); + continue; + } + + case "confirmed": { + const credentials = extractCredentials(status); + if (!credentials) { + return { + kind: "error", + message: "WeChat login confirmed but server omitted bot_token / ilink_bot_id.", + }; + } + opts.log(" ✓ WeChat login confirmed."); + return { kind: "ok", credentials }; + } + } + } + + return { kind: "timeout" }; +} + +function extractCredentials(status: WechatQrStatusResponse): WechatLoginCredentials | null { + if ( + typeof status.bot_token !== "string" || + typeof status.ilink_bot_id !== "string" || + typeof status.baseurl !== "string" || + typeof status.ilink_user_id !== "string" + ) { + return null; + } + return { + token: status.bot_token, + accountId: normalizeWeixinAccountId(status.ilink_bot_id), + baseUrl: status.baseurl, + userId: status.ilink_user_id, + }; +} + +/** Mirrors `normalizeAccountId` from `openclaw/plugin-sdk/account-id`, which + * the upstream @tencent-weixin/openclaw-weixin plugin uses to derive its + * on-disk filenames. Replaces `@` and `.` with `-` so e.g. + * `b0f5860fdecb@im.bot` → `b0f5860fdecb-im-bot`. We normalize at capture + * time so the build-time seed step writes files under the same name the + * upstream plugin will look for at runtime. */ +export function normalizeWeixinAccountId(rawId: string): string { + return rawId.replace(/[@.]/g, "-"); +} + +function errorMessage(err: unknown): string { + if (err instanceof WechatQrError) return `${err.kind}: ${err.message}`; + if (err instanceof Error) return err.message; + return String(err); +} diff --git a/src/ext/wechat/qr.test.ts b/src/ext/wechat/qr.test.ts new file mode 100644 index 00000000000..df85a90c348 --- /dev/null +++ b/src/ext/wechat/qr.test.ts @@ -0,0 +1,159 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { + encodeIlinkClientVersion, + fetchWechatQrSession, + pollWechatQrStatus, + WechatQrError, + WECHAT_ILINK_BOOTSTRAP_BASE_URL, + WECHAT_ILINK_DEFAULT_BOT_TYPE, + type FetchLike, +} from "../../../dist/ext/wechat/qr"; + +type Capture = { url: string; init?: { method?: string; headers?: Record } }; + +function makeFetch( + responder: (req: Capture) => { ok: boolean; status: number; body: string }, +): { fetch: FetchLike; calls: Capture[] } { + const calls: Capture[] = []; + const fetch: FetchLike = async (url, init) => { + const capture = { url, init }; + calls.push(capture); + const reply = responder(capture); + return { + ok: reply.ok, + status: reply.status, + text: async () => reply.body, + }; + }; + return { fetch, calls }; +} + +describe("encodeIlinkClientVersion", () => { + it("packs SemVer parts into iLink's uint32 layout", () => { + expect(encodeIlinkClientVersion("2.1.7")).toBe((2 << 16) | (1 << 8) | 7); + expect(encodeIlinkClientVersion("0.0.0")).toBe(0); + expect(encodeIlinkClientVersion("1.0.11")).toBe((1 << 16) | 11); + }); + + it("treats missing or non-numeric parts as zero so we never throw on init", () => { + expect(encodeIlinkClientVersion("")).toBe(0); + expect(encodeIlinkClientVersion("abc.def")).toBe(0); + }); +}); + +describe("fetchWechatQrSession", () => { + it("hits the bootstrap iLink host with bot_type=3 and the iLink-App-Id header", async () => { + const { fetch, calls } = makeFetch(() => ({ + ok: true, + status: 200, + body: JSON.stringify({ qrcode: "qrcode-cookie", qrcode_img_content: "https://example.com/qr" }), + })); + + const session = await fetchWechatQrSession({ fetch }); + expect(session.qrcode).toBe("qrcode-cookie"); + expect(session.qrcodeUrl).toBe("https://example.com/qr"); + expect(calls).toHaveLength(1); + const [call] = calls; + expect(call.url).toBe( + `${WECHAT_ILINK_BOOTSTRAP_BASE_URL}/ilink/bot/get_bot_qrcode?bot_type=${WECHAT_ILINK_DEFAULT_BOT_TYPE}`, + ); + expect(call.init?.method).toBe("GET"); + expect(call.init?.headers?.["iLink-App-Id"]).toBe("bot"); + }); + + it("wraps non-2xx responses in a typed WechatQrError so callers can branch on .kind", async () => { + const { fetch } = makeFetch(() => ({ ok: false, status: 503, body: "gateway down" })); + await expect(fetchWechatQrSession({ fetch })).rejects.toMatchObject({ + name: "WechatQrError", + kind: "http", + status: 503, + }); + }); + + it("rejects responses missing qrcode or qrcode_img_content fields with a parse error", async () => { + const { fetch } = makeFetch(() => ({ + ok: true, + status: 200, + body: JSON.stringify({ qrcode: "ok-but-no-img" }), + })); + await expect(fetchWechatQrSession({ fetch })).rejects.toBeInstanceOf(WechatQrError); + }); +}); + +describe("pollWechatQrStatus", () => { + it("parses confirmed responses and surfaces the bot_token / metadata fields", async () => { + const { fetch } = makeFetch(() => ({ + ok: true, + status: 200, + body: JSON.stringify({ + status: "confirmed", + bot_token: "secret-bot-token", + ilink_bot_id: "bot-123", + baseurl: "https://idc-7.weixin.qq.com", + ilink_user_id: "user-abc", + }), + })); + const result = await pollWechatQrStatus({ + baseUrl: "https://ilinkai.weixin.qq.com", + qrcode: "qrcode-cookie", + fetch, + }); + expect(result.status).toBe("confirmed"); + expect(result.bot_token).toBe("secret-bot-token"); + expect(result.ilink_bot_id).toBe("bot-123"); + expect(result.baseurl).toBe("https://idc-7.weixin.qq.com"); + expect(result.ilink_user_id).toBe("user-abc"); + }); + + it("returns 'wait' on transport-level failure so the orchestrator simply retries", async () => { + const failing: FetchLike = async () => { + throw new Error("ECONNRESET"); + }; + const result = await pollWechatQrStatus({ + baseUrl: "https://ilinkai.weixin.qq.com", + qrcode: "qrcode-cookie", + fetch: failing, + }); + expect(result.status).toBe("wait"); + }); + + it("treats 5xx gateway hiccups (e.g. Cloudflare 524) as 'wait'", async () => { + const { fetch } = makeFetch(() => ({ ok: false, status: 524, body: "" })); + const result = await pollWechatQrStatus({ + baseUrl: "https://ilinkai.weixin.qq.com", + qrcode: "qrcode-cookie", + fetch, + }); + expect(result.status).toBe("wait"); + }); + + it("surfaces 4xx responses as a typed WechatQrError", async () => { + const { fetch } = makeFetch(() => ({ ok: false, status: 401, body: "unauthorized" })); + await expect( + pollWechatQrStatus({ + baseUrl: "https://ilinkai.weixin.qq.com", + qrcode: "qrcode-cookie", + fetch, + }), + ).rejects.toMatchObject({ name: "WechatQrError", kind: "http", status: 401 }); + }); + + it("accepts a pre-aborted external signal as 'wait' rather than throwing", async () => { + // External cancellation aborts the long-poll fetch; the function still + // resolves with 'wait' so the orchestrator can re-check its own deadline. + const { fetch } = makeFetch(() => ({ ok: true, status: 200, body: '{"status":"wait"}' })); + const controller = new AbortController(); + controller.abort(); + const result = await pollWechatQrStatus({ + baseUrl: "https://ilinkai.weixin.qq.com", + qrcode: "qrcode-cookie", + fetch, + signal: controller.signal, + }); + expect(result.status).toBe("wait"); + }); +}); diff --git a/src/ext/wechat/qr.ts b/src/ext/wechat/qr.ts new file mode 100644 index 00000000000..33206767738 --- /dev/null +++ b/src/ext/wechat/qr.ts @@ -0,0 +1,289 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Host-side iLink QR login client for WeChat (personal). +// +// This is a NemoClaw-native re-implementation of the QR-login handshake +// the upstream @tencent-weixin/openclaw-weixin plugin runs in-sandbox +// (https://docs.openclaw.ai/channels/wechat). Running it on the host +// instead of inside the sandbox lets NemoClaw capture the resulting bot +// token and per-account metadata up front, store the secret in OpenShell +// as a provider credential, and never persist it inside the sandbox image +// or its state directory. The captured session is then seeded into the +// upstream plugin's on-disk account store at image build time (see +// scripts/seed-wechat-accounts.py), so the upstream plugin starts +// already-logged-in and never tries to drive its own QR login inside the +// sandbox. +// +// Endpoints (Tencent iLink CGI, observed against the public gateway): +// GET https://ilinkai.weixin.qq.com/ilink/bot/get_bot_qrcode?bot_type=3 +// → { qrcode, qrcode_img_content } +// GET /ilink/bot/get_qrcode_status?qrcode= +// → { status, bot_token?, ilink_bot_id?, baseurl?, ilink_user_id?, +// redirect_host? } (long-poll, server holds up to ~30s) + +/** Fixed iLink gateway used to mint a fresh QR. Per-account base URLs are + * served back via the `scaned_but_redirect` status; pin only the bootstrap + * host here. */ +export const WECHAT_ILINK_BOOTSTRAP_BASE_URL = "https://ilinkai.weixin.qq.com"; + +/** `bot_type=3` selects the personal-WeChat bot variant on iLink. */ +export const WECHAT_ILINK_DEFAULT_BOT_TYPE = "3"; + +/** Required by iLink — selects the bot client surface. */ +export const WECHAT_ILINK_APP_ID = "bot"; + +/** iLink-App-ClientVersion is encoded as `(major<<16)|(minor<<8)|patch`. + * Pinned in lockstep with the @tencent-weixin/openclaw-weixin version + * installed in the sandbox image, so the iLink gateway sees the same + * client version from both the host login and the in-sandbox plugin. + * Bump together with the version pinned in the Dockerfile. */ +export const WECHAT_ILINK_CLIENT_VERSION = encodeIlinkClientVersion("2.4.2"); + +/** Client-side ceiling for a single status long-poll. 35s keeps us within + * typical 60s gateway/proxy idle windows. */ +export const WECHAT_QR_POLL_TIMEOUT_MS = 35_000; + +export type WechatQrStatus = "wait" | "scaned" | "expired" | "confirmed" | "scaned_but_redirect"; + +export interface WechatQrSession { + /** Opaque token to pass to subsequent status polls. Treat as secret-ish: + * exposing it lets a third party hijack this in-flight login. */ + qrcode: string; + /** URL the user opens / scans in WeChat. Safe to render. */ + qrcodeUrl: string; +} + +export interface WechatQrStatusResponse { + status: WechatQrStatus; + bot_token?: string; + ilink_bot_id?: string; + baseurl?: string; + ilink_user_id?: string; + redirect_host?: string; +} + +/** Minimal fetch contract — covers the global `fetch` and any test fake. */ +export type FetchLike = ( + url: string, + init?: { method?: string; headers?: Record; signal?: AbortSignal }, +) => Promise<{ ok: boolean; status: number; text(): Promise }>; + +export interface WechatQrClientOptions { + /** Override transport; defaults to global `fetch`. */ + fetch?: FetchLike; + /** Override bootstrap base URL — useful for offline tests. */ + bootstrapBaseUrl?: string; + /** Override bot type — defaults to `3` (personal WeChat). */ + botType?: string; + /** Hard cap on the bootstrap request. Default 10s — long enough for the + * iLink TLS handshake on a slow network, short enough that a black-holed + * gateway doesn't hang the onboarding flow indefinitely. */ + timeoutMs?: number; +} + +const WECHAT_QR_BOOTSTRAP_TIMEOUT_MS = 10_000; + +const KNOWN_WECHAT_QR_STATUSES: ReadonlySet = new Set([ + "wait", + "scaned", + "expired", + "confirmed", + "scaned_but_redirect", +]); + +export class WechatQrError extends Error { + constructor( + public readonly kind: "network" | "http" | "parse", + message: string, + public readonly status?: number, + ) { + super(message); + this.name = "WechatQrError"; + } +} + +/** Encode a SemVer string the way iLink expects: `(major<<16)|(minor<<8)|patch`. */ +export function encodeIlinkClientVersion(semver: string): number { + const parts = semver.split(".").map((p) => Number.parseInt(p, 10)); + const major = Number.isFinite(parts[0]) ? parts[0] : 0; + const minor = Number.isFinite(parts[1]) ? parts[1] : 0; + const patch = Number.isFinite(parts[2]) ? parts[2] : 0; + return ((major & 0xff) << 16) | ((minor & 0xff) << 8) | (patch & 0xff); +} + +function buildIlinkHeaders(): Record { + return { + "iLink-App-Id": WECHAT_ILINK_APP_ID, + "iLink-App-ClientVersion": String(WECHAT_ILINK_CLIENT_VERSION), + }; +} + +function ensureTrailingSlash(url: string): string { + return url.endsWith("/") ? url : `${url}/`; +} + +/** Bootstrap a new QR session against the fixed iLink host. The returned + * `qrcode` is the cookie used for subsequent polling; `qrcodeUrl` is what + * the operator scans in WeChat. */ +export async function fetchWechatQrSession( + opts: WechatQrClientOptions = {}, +): Promise { + const transport = opts.fetch ?? (globalThis.fetch as FetchLike | undefined); + if (!transport) { + throw new WechatQrError("network", "global fetch is not available; pass opts.fetch"); + } + const baseUrl = ensureTrailingSlash(opts.bootstrapBaseUrl ?? WECHAT_ILINK_BOOTSTRAP_BASE_URL); + const botType = opts.botType ?? WECHAT_ILINK_DEFAULT_BOT_TYPE; + const url = new URL( + `ilink/bot/get_bot_qrcode?bot_type=${encodeURIComponent(botType)}`, + baseUrl, + ); + + const timeoutMs = opts.timeoutMs ?? WECHAT_QR_BOOTSTRAP_TIMEOUT_MS; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + let response: Awaited>; + try { + response = await transport(url.toString(), { + method: "GET", + headers: buildIlinkHeaders(), + signal: controller.signal, + }); + } catch (err) { + if (isAbortError(err)) { + throw new WechatQrError( + "network", + `WeChat QR init request timed out after ${timeoutMs}ms`, + ); + } + throw new WechatQrError("network", `WeChat QR init request failed: ${stringify(err)}`); + } finally { + clearTimeout(timer); + } + if (!response.ok) { + const body = await safeText(response); + throw new WechatQrError("http", `WeChat QR init returned ${response.status}: ${body}`, response.status); + } + const text = await response.text(); + let parsed: { qrcode?: unknown; qrcode_img_content?: unknown }; + try { + parsed = JSON.parse(text) as typeof parsed; + } catch (err) { + throw new WechatQrError("parse", `WeChat QR init returned non-JSON body: ${stringify(err)}`); + } + if (typeof parsed.qrcode !== "string" || typeof parsed.qrcode_img_content !== "string") { + throw new WechatQrError( + "parse", + "WeChat QR init response missing qrcode or qrcode_img_content fields", + ); + } + return { qrcode: parsed.qrcode, qrcodeUrl: parsed.qrcode_img_content }; +} + +/** Long-poll status for an existing QR session. The `baseUrl` may change + * mid-flow when the server returns `scaned_but_redirect`; callers should + * pass the latest base URL. Treats abort and gateway timeouts as a benign + * `wait` so the orchestrator can simply re-poll. The `onDebug` callback + * fires for the silently-swallowed events (transport errors, 5xx, abort) + * so the orchestrator can surface them when needed — without it, those + * failures are invisible to the operator. */ +export async function pollWechatQrStatus(params: { + baseUrl: string; + qrcode: string; + fetch?: FetchLike; + timeoutMs?: number; + signal?: AbortSignal; + onDebug?: (event: string) => void; +}): Promise { + const transport = params.fetch ?? (globalThis.fetch as FetchLike | undefined); + if (!transport) { + throw new WechatQrError("network", "global fetch is not available; pass params.fetch"); + } + const url = new URL( + `ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(params.qrcode)}`, + ensureTrailingSlash(params.baseUrl), + ); + + const timeoutMs = params.timeoutMs ?? WECHAT_QR_POLL_TIMEOUT_MS; + const localController = new AbortController(); + const timer = setTimeout(() => localController.abort(), timeoutMs); + const externalAbort = () => localController.abort(); + if (params.signal) { + if (params.signal.aborted) localController.abort(); + else params.signal.addEventListener("abort", externalAbort, { once: true }); + } + + try { + let response: Awaited>; + params.onDebug?.(`poll request → ${url.toString()}`); + try { + response = await transport(url.toString(), { + method: "GET", + headers: buildIlinkHeaders(), + signal: localController.signal, + }); + } catch (err) { + // Abort and gateway-timeout-shaped errors fall through as `wait`. + // Only the orchestrator's overall deadline ends the loop. + if (isAbortError(err)) { + params.onDebug?.(`poll abort (treated as wait)`); + return { status: "wait" }; + } + params.onDebug?.(`poll transport error: ${stringify(err)} (treated as wait)`); + return { status: "wait" }; + } + params.onDebug?.(`poll response ← status=${response.status}`); + if (!response.ok) { + // 5xx gateway hiccups also fall through as `wait` — Cloudflare 524s + // are routine on the iLink long-poll path. + if (response.status >= 500) { + params.onDebug?.(`poll http ${response.status} (treated as wait)`); + return { status: "wait" }; + } + const body = await safeText(response); + throw new WechatQrError( + "http", + `WeChat QR status returned ${response.status}: ${body}`, + response.status, + ); + } + const text = await response.text(); + let parsed: WechatQrStatusResponse; + try { + parsed = JSON.parse(text) as WechatQrStatusResponse; + } catch (err) { + throw new WechatQrError("parse", `WeChat QR status returned non-JSON body: ${stringify(err)}`); + } + if (typeof parsed?.status !== "string") { + throw new WechatQrError("parse", "WeChat QR status response missing 'status' field"); + } + if (!KNOWN_WECHAT_QR_STATUSES.has(parsed.status as WechatQrStatus)) { + throw new WechatQrError( + "parse", + `WeChat QR status returned unknown status '${parsed.status}'`, + ); + } + return parsed; + } finally { + clearTimeout(timer); + if (params.signal) params.signal.removeEventListener("abort", externalAbort); + } +} + +function isAbortError(err: unknown): boolean { + return err instanceof Error && (err.name === "AbortError" || err.name === "TimeoutError"); +} + +async function safeText(response: { text(): Promise }): Promise { + try { + return await response.text(); + } catch { + return ""; + } +} + +function stringify(err: unknown): string { + if (err instanceof Error) return err.message; + return String(err); +} diff --git a/src/lib/actions/inference-set.test.ts b/src/lib/actions/inference-set.test.ts index 8cd4e16d5a7..965f74746f9 100644 --- a/src/lib/actions/inference-set.test.ts +++ b/src/lib/actions/inference-set.test.ts @@ -82,6 +82,7 @@ function baseSession(overrides: Partial = {}): Session { migratedLegacyValueHashes: null, gpuPassthrough: false, telegramConfig: null, + wechatConfig: null, metadata: { gatewayName: "nemoclaw", fromDockerfile: null }, steps: {}, ...overrides, diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index 589ebe347aa..01f4cf1568c 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -12,11 +12,17 @@ import { recoverNamedGatewayRuntime } from "../../gateway-runtime-action"; const { isNonInteractive } = require("../../onboard") as { isNonInteractive: () => boolean }; const onboardProviders = require("../../onboard/providers"); import * as policies from "../../policy"; +// Lazy-required: keeps qrcode-terminal + the iLink HTTP client out of the +// import graph for non-host-qr channels-add calls. +const { HOST_QR_LOGIN_HANDLERS } = require("../../host-qr-handlers") as typeof import("../../host-qr-handlers"); +const onboardSession = require("../../state/onboard-session") as typeof import("../../state/onboard-session"); + import { parsePolicyAddArgs } from "../../domain/policy-channel"; import * as registry from "../../state/registry"; import { runOpenshell } from "../../adapters/openshell/runtime"; import { rebuildSandbox } from "./rebuild"; import { + type ChannelDef, KNOWN_CHANNELS, clearChannelTokens, getChannelDef, @@ -24,6 +30,7 @@ import { knownChannelNames, persistChannelTokens, } from "../../sandbox/channels"; +import type { HostQrLoginResult } from "../../host-qr-handlers"; const useColor = !process.env.NO_COLOR && !!process.stdout.isTTY; const trueColor = @@ -395,30 +402,15 @@ async function promptAndRebuild(sandboxName: string, actionDesc: string): Promis await rebuildSandbox(sandboxName, ["--yes"]); } -export async function addSandboxChannel(sandboxName: string, args: string[] = []): Promise { - const dryRun = args.includes("--dry-run"); - const channelArg = args.find((arg) => !arg.startsWith("-")); - if (!channelArg) { - console.error(` Usage: ${CLI_NAME} channels add [--dry-run]`); - console.error(` Valid channels: ${knownChannelNames().join(", ")}`); - process.exit(1); - } - - const channel = getChannelDef(channelArg); - if (!channel) { - console.error(` Unknown channel '${channelArg}'.`); - console.error(` Valid channels: ${knownChannelNames().join(", ")}`); - process.exit(1); - } - const canonical = channelArg.trim().toLowerCase(); - - if (dryRun) { - console.log(` --dry-run: would enable channel '${canonical}' for '${sandboxName}'.`); - return; - } - +// Paste-prompt token acquisition for Telegram / Discord / Slack — extracted +// from the original inline loop so `addSandboxChannel` can fork cleanly on +// `loginMethod`. +async function acquirePasteTokens( + channelArg: string, + channel: ChannelDef, + acquired: Record, +): Promise { const tokenKeys = getChannelTokenKeys(channel); - const acquired: Record = {}; for (const envKey of tokenKeys) { const isPrimary = envKey === channel.envKey; const help = isPrimary ? channel.help : channel.appTokenHelp; @@ -429,7 +421,7 @@ export async function addSandboxChannel(sandboxName: string, args: string[] = [] continue; } if (isNonInteractive()) { - console.error(` Missing ${envKey} for channel '${canonical}'.`); + console.error(` Missing ${envKey} for channel '${channelArg}'.`); console.error( ` Set ${envKey} in the environment or via '${CLI_NAME} credentials' before running in non-interactive mode.`, ); @@ -444,6 +436,160 @@ export async function addSandboxChannel(sandboxName: string, args: string[] = [] } acquired[envKey] = token; } +} + +// Host-QR token acquisition for WeChat (the only channel with +// `loginMethod: "host-qr"` today). Drives the iLink QR handshake on the +// host, captures the bot token and the non-secret per-account metadata +// (accountId, baseUrl, userId), and stashes the metadata where the +// upcoming rebuild can find it: +// - `process.env` — for the in-process rebuild that fires next +// (`promptAndRebuild` → `rebuildSandbox` → +// `onboard --resume` reads WECHAT_ACCOUNT_ID +// etc. via the wechatConfig builder). +// - `session.wechatConfig` — for a deferred rebuild started from a fresh +// process. `rebuildSandbox`'s env-stash reads +// back from here. +async function acquireHostQrChannel( + sandboxName: string, + channelArg: string, + channel: ChannelDef, + acquired: Record, +): Promise { + const envKey = channel.envKey; + if (!envKey) { + console.error(` Channel '${channelArg}' does not declare a credential environment key.`); + process.exit(1); + } + // Cached-token short-circuit. A sandbox originally onboarded with this + // channel already has the bot token in OpenShell + the per-account + // metadata in session.wechatConfig. Re-running QR would invalidate the + // upstream plugin's existing iLink session; prefer the cache and let + // the rebuild's env-stash re-bake from session. + const cached = getCredential(envKey); + if (cached) { + if (channelArg === "wechat") { + // The rebuild needs accountId/baseUrl/userId to reconstruct the + // upstream plugin's account state file via seed-wechat-accounts.py. + // Restore them from session here so a deferred rebuild (started in a + // fresh process where rebuild.ts hasn't stashed yet) still finds + // them — and bail loudly if the session was cleared. Only honor the + // session entry when it belongs to THIS sandbox, otherwise we'd bake + // another sandbox's WECHAT_* into this image. + const savedSession = onboardSession.loadSession(); + const savedWechat = + savedSession?.sandboxName === sandboxName ? savedSession.wechatConfig ?? null : null; + if (savedWechat?.accountId && !process.env.WECHAT_ACCOUNT_ID) { + process.env.WECHAT_ACCOUNT_ID = savedWechat.accountId; + if (savedWechat.baseUrl) process.env.WECHAT_BASE_URL = savedWechat.baseUrl; + if (savedWechat.userId) process.env.WECHAT_USER_ID = savedWechat.userId; + } + if (!process.env.WECHAT_ACCOUNT_ID) { + console.error(" Cached WeChat token found, but per-account metadata is missing."); + console.error( + ` Run '${CLI_NAME} ${sandboxName} channels remove ${channelArg}' then '${CLI_NAME} ${sandboxName} channels add ${channelArg}' to capture a fresh account via QR.`, + ); + process.exit(1); + } + } + acquired[envKey] = cached; + return; + } + if (isNonInteractive()) { + console.error( + ` '${channelArg}' requires an interactive QR login; cannot run in non-interactive mode.`, + ); + console.error( + ` Run '${CLI_NAME} ${sandboxName} channels add ${channelArg}' interactively instead.`, + ); + process.exit(1); + } + const handler = HOST_QR_LOGIN_HANDLERS[channelArg]; + if (!handler) { + console.error(` No host-qr handler registered for '${channelArg}'.`); + process.exit(1); + } + console.log(""); + console.log(` ${channel.help}`); + let result: HostQrLoginResult; + try { + result = await handler(); + } catch (err: unknown) { + result = { kind: "error", message: err instanceof Error ? err.message : String(err) }; + } + if (result.kind !== "ok") { + const reason = + result.kind === "timeout" + ? "QR login timed out" + : result.kind === "expired" + ? "QR expired too many times" + : result.kind === "aborted" + ? "login aborted" + : `login failed: ${result.message ?? "unknown error"}`; + console.error(` Aborted — ${reason}.`); + process.exit(1); + } + if (!result.token) { + console.error(" Aborted — host-qr handler returned no token."); + process.exit(1); + } + acquired[envKey] = result.token; + if (result.extraEnv) { + for (const [key, value] of Object.entries(result.extraEnv)) { + process.env[key] = value; + } + } + if (channel.userIdEnvKey && result.defaultUserId && !process.env[channel.userIdEnvKey]) { + process.env[channel.userIdEnvKey] = result.defaultUserId; + } + if (channelArg === "wechat" && result.extraEnv) { + const captured = { + accountId: result.extraEnv.WECHAT_ACCOUNT_ID, + baseUrl: result.extraEnv.WECHAT_BASE_URL, + userId: result.extraEnv.WECHAT_USER_ID, + }; + onboardSession.updateSession((current) => { + const prior = current.wechatConfig; + current.wechatConfig = { + accountId: captured.accountId || prior?.accountId, + baseUrl: captured.baseUrl || prior?.baseUrl, + userId: captured.userId || prior?.userId, + }; + return current; + }); + } + const suffix = result.summary ? ` (${result.summary})` : ""; + console.log(` ${G}✓${R} ${channelArg} token saved${suffix}.`); +} + +export async function addSandboxChannel(sandboxName: string, args: string[] = []): Promise { + const dryRun = args.includes("--dry-run"); + const rawChannelArg = args.find((arg) => !arg.startsWith("-")); + if (!rawChannelArg) { + console.error(` Usage: ${CLI_NAME} channels add [--dry-run]`); + console.error(` Valid channels: ${knownChannelNames().join(", ")}`); + process.exit(1); + } + + const channel = getChannelDef(rawChannelArg); + if (!channel) { + console.error(` Unknown channel '${rawChannelArg}'.`); + console.error(` Valid channels: ${knownChannelNames().join(", ")}`); + process.exit(1); + } + const canonical = rawChannelArg.trim().toLowerCase(); + + if (dryRun) { + console.log(` --dry-run: would enable channel '${canonical}' for '${sandboxName}'.`); + return; + } + + const acquired: Record = {}; + if (channel.loginMethod === "host-qr") { + await acquireHostQrChannel(sandboxName, canonical, channel, acquired); + } else { + await acquirePasteTokens(canonical, channel, acquired); + } persistChannelTokens(acquired); // Push to the gateway and update the registry NOW so that answering @@ -488,20 +634,20 @@ function applyChannelPresetIfAvailable(sandboxName: string, channelName: string) export async function removeSandboxChannel(sandboxName: string, args: string[] = []): Promise { const dryRun = args.includes("--dry-run"); - const channelArg = args.find((arg) => !arg.startsWith("-")); - if (!channelArg) { + const rawChannelArg = args.find((arg) => !arg.startsWith("-")); + if (!rawChannelArg) { console.error(` Usage: ${CLI_NAME} channels remove [--dry-run]`); console.error(` Valid channels: ${knownChannelNames().join(", ")}`); process.exit(1); } - const channel = getChannelDef(channelArg); + const channel = getChannelDef(rawChannelArg); if (!channel) { - console.error(` Unknown channel '${channelArg}'.`); + console.error(` Unknown channel '${rawChannelArg}'.`); console.error(` Valid channels: ${knownChannelNames().join(", ")}`); process.exit(1); } - const canonical = channelArg.trim().toLowerCase(); + const canonical = rawChannelArg.trim().toLowerCase(); if (dryRun) { console.log(` --dry-run: would remove channel '${canonical}' for '${sandboxName}'.`); diff --git a/src/lib/actions/sandbox/rebuild.ts b/src/lib/actions/sandbox/rebuild.ts index 2790c42e20c..4dc1290326d 100644 --- a/src/lib/actions/sandbox/rebuild.ts +++ b/src/lib/actions/sandbox/rebuild.ts @@ -222,6 +222,35 @@ export async function rebuildSandbox( return; } + // Stash WeChat per-account metadata into process.env before the rebuild + // touches anything destructive. The metadata lives in session.wechatConfig + // (captured during the original onboard's host-side QR login) — the only + // durable source today. Surfacing it as WECHAT_ACCOUNT_ID / WECHAT_BASE_URL + // / WECHAT_USER_ID lets the in-process onboard --resume that fires later + // see it directly via the wechatConfig builder's process.env path. + // `openclaw-weixin/` runtime state is intentionally NOT in state_dirs — + // seed-wechat-accounts.py rebuilds the account files from these envs + // every image build, so keeping the envs here is the only thing the next + // image needs to put the right accountId/baseUrl/userId back into + // openclaw.json + the accounts state file. + { + // Only hydrate from the session when it belongs to THIS sandbox. The + // global session file holds the most recent onboard, which may be for a + // different sandbox — pulling its wechatConfig would leak that + // sandbox's accountId / baseUrl / userId into this image build. + const rebuildSession = onboardSession.loadSession(); + const wc = + rebuildSession?.sandboxName === sandboxName + ? rebuildSession.wechatConfig ?? null + : null; + if (wc?.accountId && !process.env.WECHAT_ACCOUNT_ID) process.env.WECHAT_ACCOUNT_ID = wc.accountId; + if (wc?.baseUrl && !process.env.WECHAT_BASE_URL) process.env.WECHAT_BASE_URL = wc.baseUrl; + if (wc?.userId && !process.env.WECHAT_USER_ID) process.env.WECHAT_USER_ID = wc.userId; + if (wc?.accountId) { + log(`Stashed WeChat account metadata for rebuild: accountId=${wc.accountId}`); + } + } + // Version check — show what's changing const versionCheck = sandboxVersion.checkAgentVersion(sandboxName); console.log(""); diff --git a/src/lib/agent/defs.test.ts b/src/lib/agent/defs.test.ts index 6aa2f441c38..c12d73db4ab 100644 --- a/src/lib/agent/defs.test.ts +++ b/src/lib/agent/defs.test.ts @@ -47,7 +47,7 @@ describe("agent definitions", () => { envFile: null, format: "json", }); - expect(openclaw.messagingPlatforms).toEqual(["telegram", "discord", "slack"]); + expect(openclaw.messagingPlatforms).toEqual(["telegram", "discord", "slack", "wechat"]); expect(openclaw.legacyPaths?.startScript).toContain("scripts/nemoclaw-start.sh"); }); diff --git a/src/lib/credentials/store.ts b/src/lib/credentials/store.ts index 4048a4757d0..fb7e3194a9e 100644 --- a/src/lib/credentials/store.ts +++ b/src/lib/credentials/store.ts @@ -42,6 +42,7 @@ export const KNOWN_CREDENTIAL_ENV_KEYS: readonly string[] = [ "DISCORD_BOT_TOKEN", "SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", + "WECHAT_BOT_TOKEN", ]; // Hard upper bound on the legacy credentials.json size we are willing to diff --git a/src/lib/host-qr-handlers.ts b/src/lib/host-qr-handlers.ts new file mode 100644 index 00000000000..6b39cb50581 --- /dev/null +++ b/src/lib/host-qr-handlers.ts @@ -0,0 +1,77 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Pluggable host-side QR login handlers. +// +// Channels marked `loginMethod: "host-qr"` in KNOWN_CHANNELS dispatch through +// this registry instead of the paste prompt. Each handler runs the +// provider-specific QR handshake on the host (so the operator can scan with +// a phone), captures the bot token + non-secret account metadata, and +// returns a normalized result that the onboard flow can apply uniformly. +// +// To register a new host-qr channel: +// 1. Add `loginMethod: "host-qr"` to its ChannelDef in sandbox-channels.ts. +// 2. Add an entry to HOST_QR_LOGIN_HANDLERS below — keep the QR/network +// code under src/ext// and only the adapter here. + +export type HostQrLoginKind = "ok" | "timeout" | "expired" | "aborted" | "error"; + +export interface HostQrLoginResult { + kind: HostQrLoginKind; + /** Free-text reason; populated for kind="error". */ + message?: string; + /** Bot token to save under the channel's envKey. Required for kind="ok". */ + token?: string; + /** Non-secret per-account metadata to stash on process.env so the + * Dockerfile-patch path can serialize it into the channel's build args + * (e.g. NEMOCLAW_WECHAT_CONFIG_B64). Keys are env-var names. */ + extraEnv?: Record; + /** User id to seed into the channel's userIdEnvKey when one isn't set + * (DM-allowlist convenience). */ + defaultUserId?: string; + /** One-line summary appended to the success log, + * e.g. `✓ wechat token saved (account 12345)`. */ + summary?: string; +} + +export type HostQrLoginHandler = () => Promise; + +export const HOST_QR_LOGIN_HANDLERS: Record = { + wechat: async () => { + // Wrap the lazy require + the runWechatHostQrLogin call in a single + // try/catch so any unexpected throw (missing module after bundling, a + // qrcode-terminal native-IO error, an iLink protocol edge case that + // escapes the discriminated result) turns into a structured "error" + // result the onboard dispatcher already knows how to render — instead + // of bubbling an unhandled rejection up through the registry. + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { runWechatHostQrLogin } = require("../ext/wechat/login") as { + runWechatHostQrLogin: typeof import("../ext/wechat/login").runWechatHostQrLogin; + }; + const result = await runWechatHostQrLogin(); + if (result.kind !== "ok") { + return result.kind === "error" + ? { kind: "error", message: result.message } + : { kind: result.kind }; + } + const { token, accountId, baseUrl, userId } = result.credentials; + return { + kind: "ok", + token, + extraEnv: { + WECHAT_ACCOUNT_ID: accountId, + WECHAT_BASE_URL: baseUrl, + WECHAT_USER_ID: userId, + }, + defaultUserId: userId, + summary: `account ${accountId}`, + }; + } catch (err) { + return { + kind: "error", + message: err instanceof Error ? err.message : String(err), + }; + } + }, +}; diff --git a/src/lib/messaging-channel-config.test.ts b/src/lib/messaging-channel-config.test.ts index 45826daaefb..c5a718eaedf 100644 --- a/src/lib/messaging-channel-config.test.ts +++ b/src/lib/messaging-channel-config.test.ts @@ -18,6 +18,7 @@ describe("messaging channel config", () => { "DISCORD_SERVER_ID", "DISCORD_USER_ID", "DISCORD_REQUIRE_MENTION", + "WECHAT_ALLOWED_IDS", "SLACK_ALLOWED_USERS", ]); }); diff --git a/src/lib/messaging-conflict.test.ts b/src/lib/messaging-conflict.test.ts index 8894490791c..c366c7bf5a2 100644 --- a/src/lib/messaging-conflict.test.ts +++ b/src/lib/messaging-conflict.test.ts @@ -213,6 +213,33 @@ describe("backfillMessagingChannels", () => { expect(probe.providerExists).toHaveBeenCalledWith("alice-telegram-bridge"); expect(probe.providerExists).toHaveBeenCalledWith("alice-discord-bridge"); expect(probe.providerExists).toHaveBeenCalledWith("alice-slack-bridge"); + expect(probe.providerExists).toHaveBeenCalledWith("alice-wechat-bridge"); + }); + + it("backfills wechat when only the wechat bridge provider is present", () => { + // The probe-by-suffix mechanism relies on every channel having an entry + // in PROVIDER_SUFFIXES; if wechat were ever dropped from that map, this + // test starts catching the absent provider. + const registry = makeRegistry([{ name: "alice" }]); + const probe: ConflictProbe = { + providerExists: vi.fn((name) => + name === "alice-wechat-bridge" ? "present" : "absent", + ), + }; + backfillMessagingChannels(registry, probe); + expect(registry.updateSandbox).toHaveBeenCalledWith("alice", { + messagingChannels: ["wechat"], + }); + }); + + it("surfaces a wechat conflict when two sandboxes share the channel without hashes", () => { + const registry = makeRegistry([ + { name: "alice", messagingChannels: ["wechat"] }, + { name: "bob", messagingChannels: [] }, + ]); + expect(findChannelConflicts("bob", ["wechat"], registry)).toEqual([ + { channel: "wechat", sandbox: "alice", reason: "unknown-token" }, + ]); }); it("leaves entries with existing messagingChannels alone", () => { diff --git a/src/lib/messaging-conflict.ts b/src/lib/messaging-conflict.ts index 62c3f97bc1d..6d31d6cc64a 100644 --- a/src/lib/messaging-conflict.ts +++ b/src/lib/messaging-conflict.ts @@ -52,6 +52,7 @@ const PROVIDER_SUFFIXES: Record = { telegram: "-telegram-bridge", discord: "-discord-bridge", slack: "-slack-bridge", + wechat: "-wechat-bridge", }; const KNOWN_CHANNELS = Object.keys(PROVIDER_SUFFIXES); diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index acd561987d4..1311640a0ff 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -74,6 +74,14 @@ const { shouldInspectLegacyGatewayGpuPassthrough }: typeof import("./onboard/gat const { syncPresetSelection, }: typeof import("./onboard/policy-preset-sync") = require("./onboard/policy-preset-sync"); +const { + gatherWechatConfig, + hasWechatConfigDrift, + toSessionWechatConfig, +} = require("./onboard/wechat-config") as typeof import("./onboard/wechat-config"); +const { + setupSelectedMessagingChannels, +} = require("./onboard/messaging-channel-setup") as typeof import("./onboard/messaging-channel-setup"); const crypto = require("node:crypto"); const fs = require("fs"); const os = require("os"); @@ -344,7 +352,6 @@ import { hydrateMessagingChannelConfig, type MessagingChannelConfig, mergeMessagingChannelConfigs, - normalizeMessagingChannelConfigValue, readMessagingChannelConfigFromEnv, sanitizeMessagingChannelConfig, } from "./messaging-channel-config"; @@ -1974,6 +1981,7 @@ function getMessagingChannelForEnvKey(envKey: string): string | null { if (envKey === "DISCORD_BOT_TOKEN") return "discord"; if (envKey === "SLACK_BOT_TOKEN") return "slack"; if (envKey === "TELEGRAM_BOT_TOKEN") return "telegram"; + if (envKey === "WECHAT_BOT_TOKEN") return "wechat"; return null; } @@ -5140,6 +5148,11 @@ async function createSandbox( envKey: "TELEGRAM_BOT_TOKEN", token: getMessagingToken("TELEGRAM_BOT_TOKEN"), }, + { + name: `${sandboxName}-wechat-bridge`, + envKey: "WECHAT_BOT_TOKEN", + token: getMessagingToken("WECHAT_BOT_TOKEN"), + }, ] .filter(({ envKey }) => !enabledEnvKeys || enabledEnvKeys.has(envKey)) .filter(({ envKey }) => !disabledEnvKeys.has(envKey)); @@ -5699,6 +5712,7 @@ async function createSandbox( telegramConfig.requireMention = telegramRequireMention; } } + const wechatConfig = gatherWechatConfig(onboardSession.loadSession()); // Persist the effective Telegram config into the session so a later resume // can detect drift (TELEGRAM_REQUIRE_MENTION changed since last build) and // force a sandbox recreate — otherwise the old groupPolicy would stay baked @@ -5708,6 +5722,7 @@ async function createSandbox( typeof telegramConfig.requireMention === "boolean" ? { requireMention: telegramConfig.requireMention as boolean } : null; + current.wechatConfig = toSessionWechatConfig(wechatConfig); current.messagingChannelConfig = messagingChannelConfig; return current; }); @@ -5760,6 +5775,7 @@ async function createSandbox( discordGuilds, resolved ? resolved.ref : null, telegramConfig, + wechatConfig as Record, // Docker-on-Colima uses normal container ownership; keep the old VM chmod // compatibility path disabled unless a future VM-specific flow opts in. false, @@ -8096,9 +8112,6 @@ async function checkTelegramReachability(token: string) { async function setupMessagingChannels(): Promise { step(5, 8, "Messaging channels"); - const getMessagingConfigValue = (envKey: string): string | null => - normalizeMessagingChannelConfigValue(envKey, process.env[envKey]); - // Non-interactive: skip prompt, tokens come from env/credentials if (isNonInteractive() || process.env.NEMOCLAW_NON_INTERACTIVE === "1") { const found = MESSAGING_CHANNELS.filter((c) => getMessagingToken(c.envKey)).map((c) => c.name); @@ -8223,126 +8236,7 @@ async function setupMessagingChannels(): Promise { return []; } - // For each selected channel, prompt for token if not already set - for (const name of selected) { - const ch = MESSAGING_CHANNELS.find((c) => c.name === name); - if (!ch) { - console.log(` Unknown channel: ${name}`); - continue; - } - if (!channelHasStaticToken(ch)) continue; - if (getMessagingToken(ch.envKey)) { - console.log(` ✓ ${ch.name} — already configured`); - } else { - console.log(""); - console.log(` ${ch.help}`); - const token = normalizeCredentialValue(await prompt(` ${ch.label}: `, { secret: true })); - if (token && ch.tokenFormat && !ch.tokenFormat.test(token)) { - console.log( - ` ✗ Invalid format. ${ch.tokenFormatHint || "Check the token and try again."}`, - ); - console.log(` Skipped ${ch.name} (invalid token format)`); - enabled.delete(ch.name); - continue; - } - if (token) { - saveCredential(ch.envKey, token); - process.env[ch.envKey] = token; - console.log(` ✓ ${ch.name} token saved`); - } else { - console.log(` Skipped ${ch.name} (no token entered)`); - enabled.delete(ch.name); - continue; - } - } - if (ch.appTokenEnvKey) { - const existingAppToken = getMessagingToken(ch.appTokenEnvKey); - if (existingAppToken) { - console.log(` ✓ ${ch.name} app token — already configured`); - } else { - console.log(""); - console.log(` ${ch.appTokenHelp}`); - const appToken = normalizeCredentialValue( - await prompt(` ${ch.appTokenLabel}: `, { secret: true }), - ); - if (appToken && ch.appTokenFormat && !ch.appTokenFormat.test(appToken)) { - console.log( - ` ✗ Invalid format. ${ch.appTokenFormatHint || "Check the token and try again."}`, - ); - console.log(` Skipped ${ch.name} app token (invalid token format)`); - enabled.delete(ch.name); - continue; - } - if (appToken) { - saveCredential(ch.appTokenEnvKey, appToken); - process.env[ch.appTokenEnvKey] = appToken; - console.log(` ✓ ${ch.name} app token saved`); - } else { - console.log(` Skipped ${ch.name} app token (Socket Mode requires both tokens)`); - enabled.delete(ch.name); - continue; - } - } - } - if (ch.serverIdEnvKey) { - const existingServerIds = getMessagingConfigValue(ch.serverIdEnvKey) || ""; - if (existingServerIds) { - process.env[ch.serverIdEnvKey] = existingServerIds; - console.log(` ✓ ${ch.name} — server ID already set: ${existingServerIds}`); - } else { - console.log(` ${ch.serverIdHelp}`); - const serverId = (await prompt(` ${ch.serverIdLabel}: `)).trim(); - if (serverId) { - process.env[ch.serverIdEnvKey] = serverId; - console.log(` ✓ ${ch.name} server ID saved`); - } else { - console.log(` Skipped ${ch.name} server ID (guild channels stay disabled)`); - } - } - } - // Mention-control prompt: fires for any channel that exposes a - // requireMention env key. Discord gates the prompt behind a configured - // server ID (mention control only makes sense in a guild). Telegram - // has no serverIdEnvKey because mention control applies to every group - // the bot is added to, so the prompt always fires there. See #1737. - const requireMentionKey = ch.requireMentionEnvKey; - if (requireMentionKey && (!ch.serverIdEnvKey || Boolean(process.env[ch.serverIdEnvKey]))) { - const existingRequireMention = getMessagingConfigValue(requireMentionKey); - if (existingRequireMention === "0" || existingRequireMention === "1") { - process.env[requireMentionKey] = existingRequireMention; - const mode = existingRequireMention === "0" ? "all messages" : "@mentions only"; - console.log(` ✓ ${ch.name} — reply mode already set: ${mode}`); - } else { - console.log(` ${ch.requireMentionHelp}`); - const answer = (await prompt(" Reply only when @mentioned? [Y/n]: ")).trim().toLowerCase(); - const value = answer === "n" || answer === "no" ? "0" : "1"; - process.env[requireMentionKey] = value; - const mode = value === "0" ? "all messages" : "@mentions only"; - console.log(` ✓ ${ch.name} reply mode saved: ${mode}`); - } - } - // Prompt for user/sender ID when the channel supports allowlisting - if (ch.userIdEnvKey && (!ch.serverIdEnvKey || process.env[ch.serverIdEnvKey])) { - const existingIds = getMessagingConfigValue(ch.userIdEnvKey) || ""; - if (existingIds) { - process.env[ch.userIdEnvKey] = existingIds; - console.log(` ✓ ${ch.name} — allowed IDs already set: ${existingIds}`); - } else { - console.log(` ${ch.userIdHelp}`); - const userId = (await prompt(` ${ch.userIdLabel}: `)).trim(); - if (userId) { - process.env[ch.userIdEnvKey] = userId; - console.log(` ✓ ${ch.name} allowed IDs saved`); - } else { - const skippedReason = - ch.allowIdsMode === "guild" - ? "any member in the configured server can message the bot" - : "bot will require manual pairing"; - console.log(` Skipped ${ch.name} user ID (${skippedReason})`); - } - } - } - } + await setupSelectedMessagingChannels(selected, enabled, MESSAGING_CHANNELS); console.log(""); // Channels where the user declined to enter a token were dropped from @@ -8396,6 +8290,7 @@ function getSuggestedPolicyPresets({ maybeSuggestMessagingPreset("telegram", "TELEGRAM_BOT_TOKEN"); maybeSuggestMessagingPreset("slack", "SLACK_BOT_TOKEN"); maybeSuggestMessagingPreset("discord", "DISCORD_BOT_TOKEN"); + maybeSuggestMessagingPreset("wechat", "WECHAT_BOT_TOKEN"); if (webSearchConfig) suggestions.push("brave"); @@ -10536,11 +10431,13 @@ async function onboard(opts: OnboardOptions = {}): Promise { const sandboxGpuConfigChanged = sandboxName ? hasSandboxGpuDrift(sandboxName, sandboxGpuConfig) : false; + const wechatConfigChanged = hasWechatConfigDrift(session); const resumeSandbox = resume && !webSearchConfigChanged && !telegramConfigChanged && !sandboxGpuConfigChanged && + !wechatConfigChanged && !messagingChannelConfigChanged && session?.steps?.sandbox?.status === "complete" && sandboxReuseState === "ready"; @@ -10567,6 +10464,11 @@ async function onboard(opts: OnboardOptions = {}): Promise { if (sandboxName) { registry.removeSandbox(sandboxName); } + } else if (wechatConfigChanged) { + note(" [resume] WeChat account metadata changed; recreating sandbox."); + if (sandboxName) { + registry.removeSandbox(sandboxName); + } } else if (messagingChannelConfigChanged) { note(" [resume] Messaging channel configuration changed; recreating sandbox."); if (sandboxName) { diff --git a/src/lib/onboard/dockerfile-patch.test.ts b/src/lib/onboard/dockerfile-patch.test.ts index 6b5f13fbbc8..ccbd536cd45 100644 --- a/src/lib/onboard/dockerfile-patch.test.ts +++ b/src/lib/onboard/dockerfile-patch.test.ts @@ -90,6 +90,7 @@ describe("dockerfile patch helpers", () => { { discord: ["456"] }, "ghcr.io/nvidia/nemoclaw/sandbox-base@sha256:abc", { requireMention: true }, + {}, true, ); @@ -171,6 +172,7 @@ describe("dockerfile patch helpers", () => { {}, null, {}, + {}, false, "http://127.0.0.1:11434/v1", ); diff --git a/src/lib/onboard/dockerfile-patch.ts b/src/lib/onboard/dockerfile-patch.ts index 4f7913198e2..a45a4ec9505 100644 --- a/src/lib/onboard/dockerfile-patch.ts +++ b/src/lib/onboard/dockerfile-patch.ts @@ -47,6 +47,7 @@ export function patchStagedDockerfile( discordGuilds: LooseObject = {}, baseImageRef: string | null = null, telegramConfig: LooseObject = {}, + wechatConfig: LooseObject = {}, darwinVmCompat = false, inferenceBaseUrlOverride: string | null = null, ): void { @@ -225,5 +226,11 @@ export function patchStagedDockerfile( `ARG NEMOCLAW_TELEGRAM_CONFIG_B64=${encodeSanitizedDockerJsonArg(telegramConfig)}`, ); } + if (wechatConfig && Object.keys(wechatConfig).length > 0) { + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_WECHAT_CONFIG_B64=.*$/m, + `ARG NEMOCLAW_WECHAT_CONFIG_B64=${encodeSanitizedDockerJsonArg(wechatConfig)}`, + ); + } fs.writeFileSync(dockerfilePath, dockerfile); } diff --git a/src/lib/onboard/host-qr-dispatch.ts b/src/lib/onboard/host-qr-dispatch.ts new file mode 100644 index 00000000000..1d0326372ae --- /dev/null +++ b/src/lib/onboard/host-qr-dispatch.ts @@ -0,0 +1,79 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { saveCredential } from "../credentials/store"; +import { HOST_QR_LOGIN_HANDLERS } from "../host-qr-handlers"; +import type { ChannelDef } from "../sandbox/channels"; + +export interface HostQrDispatchOutcome { + ok: boolean; + summary?: string; + reason?: string; +} + +/** + * Run a channel's host-side QR login handler and apply its token + + * non-secret metadata side effects (credential save, process.env stash, + * DM-allowlist default). Extracted from `setupMessagingChannels` to keep + * `src/lib/onboard.ts` focused on flow rather than per-channel mechanism. + * + * Belt-and-suspenders: handlers may wrap their own body in try/catch, but + * a future handler might not — wrap the await in a real try/catch so any + * throw that escapes before the Promise is returned still becomes a + * structured "error" outcome and the channel is skipped instead of + * crashing onboarding. + */ +export async function dispatchHostQrLogin( + ch: ChannelDef & { name: string }, +): Promise { + const handler = HOST_QR_LOGIN_HANDLERS[ch.name]; + if (!handler) return { ok: false, reason: "no host-qr handler registered" }; + let result: Awaited>; + try { + result = await handler(); + } catch (err: unknown) { + result = { kind: "error", message: err instanceof Error ? err.message : String(err) }; + } + if (result.kind !== "ok") { + const reason = + result.kind === "timeout" + ? "QR login timed out" + : result.kind === "expired" + ? "QR expired too many times" + : result.kind === "aborted" + ? "login aborted" + : `login failed: ${result.message ?? "unknown error"}`; + return { ok: false, reason }; + } + if (result.token && ch.envKey) { + saveCredential(ch.envKey, result.token); + process.env[ch.envKey] = result.token; + } + // Non-secret per-account metadata: the in-sandbox wrapper plugin reads + // these via NEMOCLAW_*_CONFIG_B64 build args, so seed-wechat-accounts.py + // (and equivalents) can pre-seed credentials without re-running the QR + // handshake. See `patchStagedDockerfile`'s `wechatConfig` parameter. + if (result.extraEnv) { + for (const [key, value] of Object.entries(result.extraEnv)) { + process.env[key] = value; + } + } + // Merge the scanned operator's id into the DM allowlist. The channel's + // userIdHelp documents this as "added automatically; supply additional + // ids as a comma-separated list", so an operator-supplied list must not + // displace the scanner — otherwise the person who paired the bot can + // lock themselves out of DM access. Dedupe via Set; preserve the + // existing comma format (no space) the rest of the stack writes. + if (ch.userIdEnvKey && result.defaultUserId) { + const existing = process.env[ch.userIdEnvKey] ?? ""; + const merged = new Set( + existing + .split(",") + .map((v) => v.trim()) + .filter(Boolean), + ); + merged.add(result.defaultUserId); + process.env[ch.userIdEnvKey] = Array.from(merged).join(","); + } + return { ok: true, summary: result.summary }; +} diff --git a/src/lib/onboard/messaging-channel-setup.ts b/src/lib/onboard/messaging-channel-setup.ts new file mode 100644 index 00000000000..d2d909fe159 --- /dev/null +++ b/src/lib/onboard/messaging-channel-setup.ts @@ -0,0 +1,168 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + getCredential, + normalizeCredentialValue, + prompt, + saveCredential, +} from "../credentials/store"; +import { normalizeMessagingChannelConfigValue } from "../messaging-channel-config"; +import { channelHasStaticToken, type ChannelDef } from "../sandbox/channels"; +import { dispatchHostQrLogin } from "./host-qr-dispatch"; + +type ChannelEntry = { name: string } & ChannelDef; + +const getMessagingToken = (envKey: string): string | null => + getCredential(envKey) || normalizeCredentialValue(process.env[envKey]) || null; + +const getMessagingConfigValue = (envKey: string): string | null => + normalizeMessagingChannelConfigValue(envKey, process.env[envKey]); + +/** + * Prompt for token + per-channel config (app token, server ID, mention + * mode, allowlist IDs) for each selected messaging channel. Mutates + * `process.env` for non-secret config and saves credentials via + * `saveCredential`. Channels where the user declined or supplied an + * invalid token are removed from `enabled`. + * + * Extracted from `setupMessagingChannels` in onboard.ts so the + * per-channel interactive loop lives outside the top-level entrypoint + * (src/lib/onboard.ts file-growth budget). + */ +export async function setupSelectedMessagingChannels( + selected: readonly string[], + enabled: Set, + messagingChannels: readonly ChannelEntry[], +): Promise { + for (const name of selected) { + const ch = messagingChannels.find((c) => c.name === name); + if (!ch) { + console.log(` Unknown channel: ${name}`); + continue; + } + if (channelHasStaticToken(ch) && getMessagingToken(ch.envKey)) { + console.log(` ✓ ${ch.name} — already configured`); + } else if (ch.loginMethod === "host-qr") { + console.log(""); + console.log(` ${ch.help}`); + const outcome = await dispatchHostQrLogin(ch); + if (!outcome.ok) { + console.log(` Skipped ${ch.name} (${outcome.reason})`); + enabled.delete(ch.name); + continue; + } + const suffix = outcome.summary ? ` (${outcome.summary})` : ""; + console.log(` ✓ ${ch.name} token saved${suffix}`); + } else { + if (!channelHasStaticToken(ch)) continue; + console.log(""); + console.log(` ${ch.help}`); + const token = normalizeCredentialValue(await prompt(` ${ch.label}: `, { secret: true })); + if (token && ch.tokenFormat && !ch.tokenFormat.test(token)) { + console.log( + ` ✗ Invalid format. ${ch.tokenFormatHint || "Check the token and try again."}`, + ); + console.log(` Skipped ${ch.name} (invalid token format)`); + enabled.delete(ch.name); + continue; + } + if (token) { + saveCredential(ch.envKey, token); + process.env[ch.envKey] = token; + console.log(` ✓ ${ch.name} token saved`); + } else { + console.log(` Skipped ${ch.name} (no token entered)`); + enabled.delete(ch.name); + continue; + } + } + if (ch.appTokenEnvKey) { + const existingAppToken = getMessagingToken(ch.appTokenEnvKey); + if (existingAppToken) { + console.log(` ✓ ${ch.name} app token — already configured`); + } else { + console.log(""); + console.log(` ${ch.appTokenHelp}`); + const appToken = normalizeCredentialValue( + await prompt(` ${ch.appTokenLabel}: `, { secret: true }), + ); + if (appToken && ch.appTokenFormat && !ch.appTokenFormat.test(appToken)) { + console.log( + ` ✗ Invalid format. ${ch.appTokenFormatHint || "Check the token and try again."}`, + ); + console.log(` Skipped ${ch.name} app token (invalid token format)`); + enabled.delete(ch.name); + continue; + } + if (appToken) { + saveCredential(ch.appTokenEnvKey, appToken); + process.env[ch.appTokenEnvKey] = appToken; + console.log(` ✓ ${ch.name} app token saved`); + } else { + console.log(` Skipped ${ch.name} app token (Socket Mode requires both tokens)`); + enabled.delete(ch.name); + continue; + } + } + } + if (ch.serverIdEnvKey) { + const existingServerIds = getMessagingConfigValue(ch.serverIdEnvKey) || ""; + if (existingServerIds) { + process.env[ch.serverIdEnvKey] = existingServerIds; + console.log(` ✓ ${ch.name} — server ID already set: ${existingServerIds}`); + } else { + console.log(` ${ch.serverIdHelp}`); + const serverId = (await prompt(` ${ch.serverIdLabel}: `)).trim(); + if (serverId) { + process.env[ch.serverIdEnvKey] = serverId; + console.log(` ✓ ${ch.name} server ID saved`); + } else { + console.log(` Skipped ${ch.name} server ID (guild channels stay disabled)`); + } + } + } + // Mention-control prompt: fires for any channel that exposes a + // requireMention env key. Discord gates the prompt behind a configured + // server ID (mention control only makes sense in a guild). Telegram + // has no serverIdEnvKey because mention control applies to every group + // the bot is added to, so the prompt always fires there. See #1737. + const requireMentionKey = ch.requireMentionEnvKey; + if (requireMentionKey && (!ch.serverIdEnvKey || Boolean(process.env[ch.serverIdEnvKey]))) { + const existingRequireMention = getMessagingConfigValue(requireMentionKey); + if (existingRequireMention === "0" || existingRequireMention === "1") { + process.env[requireMentionKey] = existingRequireMention; + const mode = existingRequireMention === "0" ? "all messages" : "@mentions only"; + console.log(` ✓ ${ch.name} — reply mode already set: ${mode}`); + } else { + console.log(` ${ch.requireMentionHelp}`); + const answer = (await prompt(" Reply only when @mentioned? [Y/n]: ")).trim().toLowerCase(); + const value = answer === "n" || answer === "no" ? "0" : "1"; + process.env[requireMentionKey] = value; + const mode = value === "0" ? "all messages" : "@mentions only"; + console.log(` ✓ ${ch.name} reply mode saved: ${mode}`); + } + } + // Prompt for user/sender ID when the channel supports allowlisting + if (ch.userIdEnvKey && (!ch.serverIdEnvKey || process.env[ch.serverIdEnvKey])) { + const existingIds = getMessagingConfigValue(ch.userIdEnvKey) || ""; + if (existingIds) { + process.env[ch.userIdEnvKey] = existingIds; + console.log(` ✓ ${ch.name} — allowed IDs already set: ${existingIds}`); + } else { + console.log(` ${ch.userIdHelp}`); + const userId = (await prompt(` ${ch.userIdLabel}: `)).trim(); + if (userId) { + process.env[ch.userIdEnvKey] = userId; + console.log(` ✓ ${ch.name} allowed IDs saved`); + } else { + const skippedReason = + ch.allowIdsMode === "guild" + ? "any member in the configured server can message the bot" + : "bot will require manual pairing"; + console.log(` Skipped ${ch.name} user ID (${skippedReason})`); + } + } + } + } +} diff --git a/src/lib/onboard/messaging-reuse.test.ts b/src/lib/onboard/messaging-reuse.test.ts index a41aa107c18..98b3c45c3cb 100644 --- a/src/lib/onboard/messaging-reuse.test.ts +++ b/src/lib/onboard/messaging-reuse.test.ts @@ -11,9 +11,22 @@ import { const messagingChannels = [ { name: "discord", envKey: "DISCORD_BOT_TOKEN" }, { name: "slack", envKey: "SLACK_BOT_TOKEN" }, + { name: "wechat", envKey: "WECHAT_BOT_TOKEN" }, ]; describe("onboard messaging reuse", () => { + it("maps one bridge provider for single-token messaging channels", () => { + expect(getMessagingProviderNamesForChannel("assistant", "discord")).toEqual([ + "assistant-discord-bridge", + ]); + expect(getMessagingProviderNamesForChannel("assistant", "telegram")).toEqual([ + "assistant-telegram-bridge", + ]); + expect(getMessagingProviderNamesForChannel("assistant", "wechat")).toEqual([ + "assistant-wechat-bridge", + ]); + }); + it("requires both Slack providers before reusing a stored Slack channel", () => { expect(getMessagingProviderNamesForChannel("assistant", "slack")).toEqual([ "assistant-slack-bridge", @@ -52,6 +65,22 @@ describe("onboard messaging reuse", () => { expect(reusedChannels).toEqual(["slack"]); }); + it("reuses a stored WeChat channel when its bridge provider exists", () => { + const reusedChannels = getNonInteractiveStoredMessagingChannels( + false, + null, + "assistant", + messagingChannels, + () => false, + () => ({ messagingChannels: ["wechat"] }), + () => [], + (provider) => provider === "assistant-wechat-bridge", + true, + ); + + expect(reusedChannels).toEqual(["wechat"]); + }); + it("normalizes empty resume messaging channels to null", () => { const reusedChannels = getNonInteractiveStoredMessagingChannels( true, diff --git a/src/lib/onboard/messaging-reuse.ts b/src/lib/onboard/messaging-reuse.ts index 10b71a0e55f..a4f454d6ca0 100644 --- a/src/lib/onboard/messaging-reuse.ts +++ b/src/lib/onboard/messaging-reuse.ts @@ -7,6 +7,7 @@ type SandboxEntry = { messagingChannels?: string[] | null } | null | undefined; export function getMessagingProviderNamesForChannel(sandboxName: string, channel: string): string[] { if (channel === "discord") return [`${sandboxName}-discord-bridge`]; if (channel === "telegram") return [`${sandboxName}-telegram-bridge`]; + if (channel === "wechat") return [`${sandboxName}-wechat-bridge`]; if (channel === "slack") return [`${sandboxName}-slack-bridge`, `${sandboxName}-slack-app`]; return []; } diff --git a/src/lib/onboard/wechat-config.ts b/src/lib/onboard/wechat-config.ts new file mode 100644 index 00000000000..70f603eee12 --- /dev/null +++ b/src/lib/onboard/wechat-config.ts @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { normalizeCredentialValue } from "../credentials/store"; +import type { Session } from "../state/onboard-session"; + +export interface WechatConfigSnapshot { + accountId?: string; + baseUrl?: string; + userId?: string; +} + +/** + * Read WeChat per-account metadata. Prefers fresh values from + * `process.env` (set by the host-qr handler this run, or by + * `rebuildSandbox`'s env-stash); falls back to the recorded session for + * the resume case where `setupMessagingChannels` short-circuits the + * host-qr handler because the bot token is already cached. + * + * Non-secret — the bot token lives in the OpenShell provider, not here. + * The metadata is what `patchStagedDockerfile` serializes into + * `NEMOCLAW_WECHAT_CONFIG_B64` so `seed-wechat-accounts.py` can write + * `/openclaw-weixin/accounts/.json` at image-build time. + */ +export function gatherWechatConfig(session: Session | null): WechatConfigSnapshot { + const cfg: WechatConfigSnapshot = {}; + const accountId = normalizeCredentialValue(process.env.WECHAT_ACCOUNT_ID || ""); + const baseUrl = normalizeCredentialValue(process.env.WECHAT_BASE_URL || ""); + const userId = normalizeCredentialValue(process.env.WECHAT_USER_ID || ""); + if (accountId) cfg.accountId = accountId; + if (baseUrl) cfg.baseUrl = baseUrl; + if (userId) cfg.userId = userId; + if (Object.keys(cfg).length === 0 && session?.wechatConfig) { + if (session.wechatConfig.accountId) cfg.accountId = session.wechatConfig.accountId; + if (session.wechatConfig.baseUrl) cfg.baseUrl = session.wechatConfig.baseUrl; + if (session.wechatConfig.userId) cfg.userId = session.wechatConfig.userId; + } + return cfg; +} + +/** + * Detect WeChat account drift on resume: a fresh host-qr login (or env + * stash) produced an accountId/baseUrl/userId triple that differs from + * what was recorded in the session. Forces a sandbox recreate because + * the per-account base URL is baked into `openclaw.json` at build time — + * an unchanged image would keep talking to the previous IDC host. + */ +export function hasWechatConfigDrift(session: Session | null): boolean { + const recorded = session?.wechatConfig ?? null; + const accountId = normalizeCredentialValue(process.env.WECHAT_ACCOUNT_ID || ""); + if (!accountId) return false; + const baseUrl = normalizeCredentialValue(process.env.WECHAT_BASE_URL || ""); + const userId = normalizeCredentialValue(process.env.WECHAT_USER_ID || ""); + return ( + (recorded?.accountId ?? "") !== accountId || + (recorded?.baseUrl ?? "") !== baseUrl || + (recorded?.userId ?? "") !== userId + ); +} + +/** + * Build the `Session.wechatConfig` payload for `updateSession`. Returns + * `null` when the snapshot has no fields so the session field stays + * normalized (matches `parseWechatConfig`'s null-on-empty contract). + */ +export function toSessionWechatConfig( + cfg: WechatConfigSnapshot, +): { accountId?: string; baseUrl?: string; userId?: string } | null { + return Object.keys(cfg).length > 0 + ? { accountId: cfg.accountId, baseUrl: cfg.baseUrl, userId: cfg.userId } + : null; +} diff --git a/src/lib/policy/index.ts b/src/lib/policy/index.ts index cee3472eeef..762a8debce9 100644 --- a/src/lib/policy/index.ts +++ b/src/lib/policy/index.ts @@ -106,12 +106,16 @@ function getPresetEndpoints(content: string): string[] { * having enabled the channel opens the firewall but leaves the sandbox * without a running bridge. See #1691. */ -const MESSAGING_PRESET_NAMES = new Set(["telegram", "discord", "slack"]); +const MESSAGING_PRESET_LABELS: Record = { + telegram: "Telegram", + discord: "Discord", + slack: "Slack", + wechat: "WeChat", +}; function getMessagingPresetWarning(presetName: string): string | null { - if (!MESSAGING_PRESET_NAMES.has(presetName)) return null; - const label = - presetName === "telegram" ? "Telegram" : presetName === "discord" ? "Discord" : "Slack"; + const label = MESSAGING_PRESET_LABELS[presetName]; + if (!label) return null; return [ `Note: the '${presetName}' preset only opens network egress to the ${label} API.`, `To actually enable ${label} messaging, re-run 'nemoclaw onboard' and select ${label}`, diff --git a/src/lib/sandbox/build-context.ts b/src/lib/sandbox/build-context.ts index a776036db35..64f85ff0f10 100644 --- a/src/lib/sandbox/build-context.ts +++ b/src/lib/sandbox/build-context.ts @@ -113,6 +113,13 @@ function stageOptimizedSandboxBuildContext( path.join(rootDir, "scripts", "generate-openclaw-config.py"), path.join(stagedScriptsDir, "generate-openclaw-config.py"), ); + // WeChat-account seed for the @tencent-weixin/openclaw-weixin plugin — + // runs at image build time when WeChat is enabled to skip the upstream + // plugin's in-sandbox QR login. + fs.copyFileSync( + path.join(rootDir, "scripts", "seed-wechat-accounts.py"), + path.join(stagedScriptsDir, "seed-wechat-accounts.py"), + ); return { buildCtx, stagedDockerfile }; } diff --git a/src/lib/sandbox/channels.test.ts b/src/lib/sandbox/channels.test.ts index e43c4413e49..d6f5ef3caa2 100644 --- a/src/lib/sandbox/channels.test.ts +++ b/src/lib/sandbox/channels.test.ts @@ -15,14 +15,32 @@ import { } from "./channels"; describe("sandbox-channels KNOWN_CHANNELS", () => { - it("covers telegram, discord, and slack", () => { - expect(knownChannelNames()).toEqual(["telegram", "discord", "slack"]); + it("covers telegram, discord, slack, and wechat", () => { + expect(knownChannelNames()).toEqual(["telegram", "discord", "wechat", "slack"]); }); it("exposes the primary bot-token env var for each channel", () => { expect(getChannelDef("telegram")?.envKey).toBe("TELEGRAM_BOT_TOKEN"); expect(getChannelDef("discord")?.envKey).toBe("DISCORD_BOT_TOKEN"); expect(getChannelDef("slack")?.envKey).toBe("SLACK_BOT_TOKEN"); + expect(getChannelDef("wechat")?.envKey).toBe("WECHAT_BOT_TOKEN"); + }); + + it("only wechat declares loginMethod=host-qr", () => { + // Other channels paste a token; WeChat captures it via a host-side QR + // handshake (src/ext/wechat/login.ts). Onboarding branches on this flag, + // so flipping it accidentally would silently route WeChat through the + // paste prompt and break the QR flow. + expect(getChannelDef("wechat")?.loginMethod).toBe("host-qr"); + expect(getChannelDef("telegram")?.loginMethod).toBeUndefined(); + expect(getChannelDef("discord")?.loginMethod).toBeUndefined(); + expect(getChannelDef("slack")?.loginMethod).toBeUndefined(); + }); + + it("declares wechat as DM-only with the WECHAT_ALLOWED_IDS env key", () => { + const wechat = getChannelDef("wechat"); + expect(wechat?.allowIdsMode).toBe("dm"); + expect(wechat?.userIdEnvKey).toBe("WECHAT_ALLOWED_IDS"); }); it("only slack declares a secondary app-token env var", () => { @@ -93,7 +111,7 @@ describe("sandbox-channels token-shape helpers", () => { describe("sandbox-channels listChannels", () => { it("materialises an array with the name merged into each entry", () => { const list = listChannels(); - expect(list.map((c) => c.name)).toEqual(["telegram", "discord", "slack"]); + expect(list.map((c) => c.name)).toEqual(["telegram", "discord", "wechat", "slack"]); const telegram = list.find((c) => c.name === "telegram"); expect(telegram?.envKey).toBe("TELEGRAM_BOT_TOKEN"); expect(telegram?.allowIdsMode).toBe("dm"); diff --git a/src/lib/sandbox/channels.ts b/src/lib/sandbox/channels.ts index 3b08f91c88b..fd0d978be9b 100644 --- a/src/lib/sandbox/channels.ts +++ b/src/lib/sandbox/channels.ts @@ -24,6 +24,9 @@ export interface ChannelDef { tokenFormatHint?: string; appTokenFormat?: RegExp; appTokenFormatHint?: string; + // "host-qr" channels capture the token via a host-side QR handshake instead + // of a paste prompt. Defaults to "token-paste" when omitted. + loginMethod?: "token-paste" | "host-qr"; } export const KNOWN_CHANNELS: Record = { @@ -58,6 +61,19 @@ export const KNOWN_CHANNELS: Record = { userIdLabel: "Discord User ID (optional guild allowlist)", allowIdsMode: "guild", }, + wechat: { + envKey: "WECHAT_BOT_TOKEN", + description: "WeChat (personal) bot messaging", + help: + "Captured automatically via a host-side QR scan during onboard — pair the bot by scanning the QR with WeChat on your phone (Discover → Scan). DM-only.", + label: "WeChat Bot Token", + userIdEnvKey: "WECHAT_ALLOWED_IDS", + userIdHelp: + "Optional: restrict who can DM the bot. The WeChat user id of the operator who scanned is added automatically; supply additional ids as a comma-separated list.", + userIdLabel: "WeChat User ID(s) (DM allowlist)", + allowIdsMode: "dm", + loginMethod: "host-qr", + }, slack: { envKey: "SLACK_BOT_TOKEN", description: "Slack bot messaging", diff --git a/src/lib/state/onboard-session.test.ts b/src/lib/state/onboard-session.test.ts index 0448764bab8..de16e8be523 100644 --- a/src/lib/state/onboard-session.test.ts +++ b/src/lib/state/onboard-session.test.ts @@ -396,6 +396,54 @@ describe("onboard session", () => { expect(fresh.telegramConfig).toBeNull(); }); + it("persists wechatConfig across save/load roundtrips", () => { + // wechatConfig captures the host-side QR handshake result. Persisting it + // is what lets a later `nemoclaw onboard` resume detect IDC-baseUrl + // drift and force a sandbox recreate (see onboard.ts wechatConfigChanged). + const created = session.createSession(); + created.wechatConfig = { + accountId: "ilink-bot-42", + baseUrl: "https://ilinkai.wechat.com", + userId: "user-42", + }; + session.saveSession(created); + + const loaded = session.loadSession()!; + expect(loaded.wechatConfig).toEqual({ + accountId: "ilink-bot-42", + baseUrl: "https://ilinkai.wechat.com", + userId: "user-42", + }); + }); + + it("rejects malformed wechatConfig on load and falls back to null", () => { + // Hand-edited session — non-string fields should be discarded rather than + // round-tripped through to consumers that expect strings. + const seed = session.createSession(); + session.saveSession(seed); + const onDisk = JSON.parse(fs.readFileSync(session.SESSION_FILE, "utf-8")); + onDisk.wechatConfig = { accountId: 7, baseUrl: { nested: true }, userId: null }; + fs.writeFileSync(session.SESSION_FILE, JSON.stringify(onDisk)); + + const loaded = session.loadSession()!; + expect(loaded.wechatConfig).toBeNull(); + }); + + it("keeps wechatConfig partial when only some fields are present", () => { + // The QR handshake currently always produces all three fields, but the + // type allows partial — e.g. a future flow where userId is opted-out. + const created = session.createSession(); + created.wechatConfig = { accountId: "primary" }; + session.saveSession(created); + const loaded = session.loadSession()!; + expect(loaded.wechatConfig).toEqual({ accountId: "primary" }); + }); + + it("defaults wechatConfig to null for fresh sessions", () => { + const fresh = session.createSession(); + expect(fresh.wechatConfig).toBeNull(); + }); + it("persists and clears web search config through safe session updates", () => { session.saveSession(session.createSession()); session.markStepComplete("provider_selection", { @@ -765,6 +813,36 @@ describe("onboard session", () => { expect(loaded.telegramConfig).toBeNull(); }); + it("filterSafeUpdates routes wechatConfig through markStepComplete", () => { + session.saveSession(session.createSession()); + session.markStepComplete("provider_selection", { + wechatConfig: { accountId: "primary", baseUrl: "https://x", userId: "u" }, + }); + + const loaded = session.loadSession()!; + expect(loaded.wechatConfig).toEqual({ + accountId: "primary", + baseUrl: "https://x", + userId: "u", + }); + + // Explicit null clears the field (used when WeChat is removed from the + // enabled channels on a subsequent onboard). + session.markStepComplete("provider_selection", { wechatConfig: null }); + const cleared = session.loadSession()!; + expect(cleared.wechatConfig).toBeNull(); + }); + + it("filterSafeUpdates drops malformed wechatConfig values", () => { + session.saveSession(session.createSession()); + session.markStepComplete("provider_selection", { + wechatConfig: { accountId: 9000 } as unknown as { accountId: string }, + }); + + const loaded = session.loadSession()!; + expect(loaded.wechatConfig).toBeNull(); + }); + it("createSession with messagingChannels override", () => { const created = session.createSession({ messagingChannels: ["telegram", "slack"] }); expect(created.messagingChannels).toEqual(["telegram", "slack"]); diff --git a/src/lib/state/onboard-session.ts b/src/lib/state/onboard-session.ts index e35286008da..ac896220762 100644 --- a/src/lib/state/onboard-session.ts +++ b/src/lib/state/onboard-session.ts @@ -99,6 +99,7 @@ export interface Session { migratedLegacyValueHashes: Record | null; gpuPassthrough: boolean; telegramConfig: TelegramConfig | null; + wechatConfig: WechatConfig | null; metadata: SessionMetadata; steps: Record; } @@ -107,6 +108,18 @@ export interface TelegramConfig { requireMention: boolean; } +export interface WechatConfig { + // Stable per-account id returned by iLink (`ilink_bot_id`). Non-secret. + accountId?: string; + // Per-account base URL. Rotates via IDC redirects, so a change here is a + // signal that we are now talking to a different gateway and the sandbox + // must be rebuilt. + baseUrl?: string; + // WeChat user id of the operator who scanned the QR. PII-adjacent but not + // secret — added to the DM allowlist by default. + userId?: string; +} + export interface LockInfo { pid: number; startedAt: string | null; @@ -143,6 +156,7 @@ export interface SessionUpdates { migratedLegacyValueHashes?: Record; gpuPassthrough?: boolean; telegramConfig?: TelegramConfig | null; + wechatConfig?: WechatConfig | null; metadata?: { gatewayName?: string; fromDockerfile?: string | null }; } @@ -249,6 +263,18 @@ function parseTelegramConfig(value: unknown): TelegramConfig | null { return null; } +function parseWechatConfig(value: unknown): WechatConfig | null { + if (!isObject(value)) return null; + const result: WechatConfig = {}; + const accountId = readString(value.accountId); + const baseUrl = readString(value.baseUrl); + const userId = readString(value.userId); + if (accountId) result.accountId = accountId; + if (baseUrl) result.baseUrl = baseUrl; + if (userId) result.userId = userId; + return Object.keys(result).length > 0 ? result : null; +} + function parseSessionMetadata(value: SessionJsonValue | undefined): SessionMetadata | undefined { if (!isObject(value)) return undefined; return { @@ -334,6 +360,7 @@ export function createSession(overrides: Partial = {}): Session { : null, gpuPassthrough: overrides.gpuPassthrough === true, telegramConfig: parseTelegramConfig(overrides.telegramConfig), + wechatConfig: parseWechatConfig(overrides.wechatConfig), metadata: { gatewayName: overrides.metadata?.gatewayName ?? "nemoclaw", fromDockerfile: overrides.metadata?.fromDockerfile ?? null, @@ -371,6 +398,7 @@ export function normalizeSession(data: Session | SessionJsonValue | undefined): migratedLegacyValueHashes: readStringRecord(data.migratedLegacyValueHashes), gpuPassthrough: data.gpuPassthrough === true, telegramConfig: parseTelegramConfig(data.telegramConfig), + wechatConfig: parseWechatConfig(data.wechatConfig), lastStepStarted: readString(data.lastStepStarted), lastCompletedStep: readString(data.lastCompletedStep), failure: sanitizeFailure(isObject(data.failure) ? data.failure : null), @@ -803,6 +831,12 @@ export function filterSafeUpdates(updates: SessionUpdates): Partial { } else if (updates.telegramConfig === null) { safe.telegramConfig = null; } + if (isObject(updates.wechatConfig)) { + const parsed = parseWechatConfig(updates.wechatConfig); + if (parsed) safe.wechatConfig = parsed; + } else if (updates.wechatConfig === null) { + safe.wechatConfig = null; + } if (isObject(updates.metadata) && typeof updates.metadata.gatewayName === "string") { safe.metadata = { gatewayName: updates.metadata.gatewayName, diff --git a/src/lib/state/sandbox.ts b/src/lib/state/sandbox.ts index 9e6c5cbd7f5..af071fe19ec 100644 --- a/src/lib/state/sandbox.ts +++ b/src/lib/state/sandbox.ts @@ -310,6 +310,19 @@ function auditExtractedSymlinks(dirPath: string, allowedRoots: string[]): string if (stat.isSymbolicLink()) { const linkTarget = readlinkSync(fullPath); + // Whitelisted npm symlinks baked into the base image at build time + // (see AUDIT_SYMLINK_WHITELIST). Accepting them here matches the + // pre-backup audit so legitimate plugin installs in extensions/ + // can survive a rebuild without tripping the post-extraction check. + // Match both the source path AND the link target — a whitelisted + // path with a tampered target falls through to the normal + // containment check. + const relFromDir = path.relative(dirPath, fullPath).split(path.sep).join("/"); + const expectedTarget = AUDIT_SYMLINK_WHITELIST.get(relFromDir); + if (expectedTarget !== undefined && expectedTarget === linkTarget) { + continue; + } + // Resolve relative to the symlink's containing directory (standard). const resolvedRelative = path.resolve(path.dirname(fullPath), linkTarget); @@ -551,6 +564,28 @@ function sanitizeBackupDirectory(dirPath: string): void { // ── Logging ──────────────────────────────────────────────────────── const _verbose = () => process.env.NEMOCLAW_REBUILD_VERBOSE === "1"; + +// Symlinks baked into the base image at build time (Dockerfile.base) by +// `openclaw plugins install`. npm creates these as part of its standard +// install layout — peer-dependency links and .bin shortcuts — and the +// pre-backup audit would otherwise treat them as agent-planted exfil +// attempts. Source paths are relative to the agent state-dir root (e.g. +// for OpenClaw, /sandbox/.openclaw); targets are matched exactly against +// the value of `readlink(source)`. Source-only matching is unsafe: a +// compromised agent could repoint one of these to /etc/passwd and the +// audit would still let it through. Keep in lockstep with +// WECHAT_PLUGIN_VERSION in Dockerfile.base — bump together if the plugin +// install layout changes. +const AUDIT_SYMLINK_WHITELIST: ReadonlyMap = new Map([ + [ + "extensions/openclaw-weixin/node_modules/.bin/qrcode-terminal", + "../qrcode-terminal/bin/qrcode-terminal.js", + ], + [ + "extensions/openclaw-weixin/node_modules/openclaw", + "/usr/local/lib/node_modules/openclaw", + ], +]); function _log(msg: string): void { if (_verbose()) console.error(` [sandbox-state ${new Date().toISOString()}] ${msg}`); } @@ -976,10 +1011,15 @@ export function backupSandboxState(sandboxName: string, options: BackupOptions = // NC-2227-04: Pre-backup audit — reject symlinks, hardlinks, and special // files inside state dirs. A compromised agent could plant a symlink like // workspace/copy -> ../openclaw.json to exfiltrate config via backup. + // + // The printf format emits "\t\t" — %l is + // empty for non-symlinks but always present, so the field count is + // stable. Tab separator assumes state-dir paths don't contain tabs, + // matching the wider convention in this file. const auditCmd = existingDirs .map( (d) => - `find ${shellQuote(`${dir}/${d}`)} \\( -type l -o \\( -type f -a -links +1 \\) -o \\( ! -type f -a ! -type d \\) \\) -printf "%y %p\\n" 2>/dev/null`, + `find ${shellQuote(`${dir}/${d}`)} \\( -type l -o \\( -type f -a -links +1 \\) -o \\( ! -type f -a ! -type d \\) \\) -printf "%y\\t%p\\t%l\\n" 2>/dev/null`, ) .join(" && "); _log(`Pre-backup audit: checking for symlinks, hard links, and special files`); @@ -1005,22 +1045,50 @@ export function backupSandboxState(sandboxName: string, options: BackupOptions = } const auditOutput = (auditResult.stdout || "").trim(); if (auditOutput.length > 0) { - // Found symlinks or special files — log them and reject the backup - const violations = auditOutput.split("\n").filter((l) => l.length > 0); - _log( - `SECURITY: Pre-backup audit found ${violations.length} unsafe entries: ${violations.slice(0, 5).join("; ")}`, - ); - return { - success: false, - manifest, - backedUpDirs, - failedDirs: [...existingDirs], - backedUpFiles, - failedFiles: stateFiles.map((f) => f.path), - error: `Pre-backup audit rejected: symlinks, hard links, or special files found in state dirs: ${violations.slice(0, 3).join("; ")}`, - }; + const allEntries = auditOutput.split("\n").filter((l) => l.length > 0); + const whitelisted: string[] = []; + const violations: string[] = []; + const dirPrefix = `${dir}/`; + for (const entry of allEntries) { + // find -printf "%y\t%p\t%l\n" → "\t\t" + // (linkTarget is empty for non-symlinks). + const parts = entry.split("\t"); + const type = parts[0] || ""; + const absPath = parts[1] || entry; + const linkTarget = parts[2] || ""; + const relPath = absPath.startsWith(dirPrefix) + ? absPath.slice(dirPrefix.length) + : absPath; + const expectedTarget = + type === "l" ? AUDIT_SYMLINK_WHITELIST.get(relPath) : undefined; + if (expectedTarget !== undefined && expectedTarget === linkTarget) { + whitelisted.push(entry); + } else { + violations.push(entry); + } + } + if (whitelisted.length > 0) { + _log( + `Pre-backup audit whitelisted ${whitelisted.length} entries (base-image npm symlinks): ${whitelisted.slice(0, 5).join("; ")}`, + ); + } + if (violations.length > 0) { + // Non-whitelisted symlinks / hard links / special files — reject + _log( + `SECURITY: Pre-backup audit found ${violations.length} unsafe entries: ${violations.slice(0, 5).join("; ")}`, + ); + return { + success: false, + manifest, + backedUpDirs, + failedDirs: [...existingDirs], + backedUpFiles, + failedFiles: stateFiles.map((f) => f.path), + error: `Pre-backup audit rejected: symlinks, hard links, or special files found in state dirs: ${violations.slice(0, 3).join("; ")}`, + }; + } } - _log("Pre-backup audit passed — no symlinks, hard links, or special files found"); + _log("Pre-backup audit passed — no unsafe symlinks, hard links, or special files found"); // Download via SSH+tar // NC-2227-04: Removed -h flag (was following symlinks). State dirs are diff --git a/test/credentials.test.ts b/test/credentials.test.ts index 0d7e2591018..12a9f4881dd 100644 --- a/test/credentials.test.ts +++ b/test/credentials.test.ts @@ -74,6 +74,17 @@ describe("messaging legacy bridge credentials", () => { // provider credentials, but this credential key stays for deploy.ts. expect(KNOWN_CREDENTIAL_ENV_KEYS).toContain("ALLOWED_CHAT_IDS"); }); + + it("registers WECHAT_BOT_TOKEN alongside the other channel bot tokens", () => { + // The WeChat host-QR onboarding writes the captured token via + // saveCredential("WECHAT_BOT_TOKEN", ...). If this key is missing from + // 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("TELEGRAM_BOT_TOKEN"); + expect(KNOWN_CREDENTIAL_ENV_KEYS).toContain("DISCORD_BOT_TOKEN"); + expect(KNOWN_CREDENTIAL_ENV_KEYS).toContain("SLACK_BOT_TOKEN"); + }); }); describe("host-side credential staging", () => { diff --git a/test/e2e/docs/parity-inventory.generated.json b/test/e2e/docs/parity-inventory.generated.json index 2f6cc307a9b..873e2d1f903 100644 --- a/test/e2e/docs/parity-inventory.generated.json +++ b/test/e2e/docs/parity-inventory.generated.json @@ -7397,7 +7397,7 @@ "assertions": [ { "script": "test/e2e/test-messaging-providers.sh", - "line": 180, + "line": 200, "text": "NVIDIA_API_KEY not set", "polarity": "fail", "normalized_id": "nvidia.api.key.not.set", @@ -7405,7 +7405,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 183, + "line": 203, "text": "NVIDIA_API_KEY is set", "polarity": "pass", "normalized_id": "nvidia.api.key.is.set", @@ -7413,7 +7413,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 186, + "line": 206, "text": "Docker is not running", "polarity": "fail", "normalized_id": "docker.is.not.running", @@ -7421,7 +7421,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 189, + "line": 209, "text": "Docker is running", "polarity": "pass", "normalized_id": "docker.is.running", @@ -7429,7 +7429,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 213, + "line": 234, "text": "Pre-cleanup complete", "polarity": "pass", "normalized_id": "pre.cleanup.complete", @@ -7437,7 +7437,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 293, + "line": 314, "text": "Failed to append Slack policy to base sandbox policy", "polarity": "fail", "normalized_id": "failed.to.append.slack.policy.to.base.sandbox.policy", @@ -7445,7 +7445,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 296, + "line": 317, "text": "Slack network policy pre-merged into base policy", "polarity": "pass", "normalized_id": "slack.network.policy.pre.merged.into.base.policy", @@ -7453,7 +7453,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 301, + "line": 322, "text": "Cannot pre-merge Slack policy: missing base policy or preset file", "polarity": "fail", "normalized_id": "cannot.pre.merge.slack.policy.missing.base.policy.or.preset.file", @@ -7461,7 +7461,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 342, + "line": 363, "text": "M0: install.sh completed (exit 0)", "polarity": "pass", "normalized_id": "m0.install.sh.completed.exit.0", @@ -7469,7 +7469,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 344, + "line": 365, "text": "M0: install.sh failed (exit $install_exit)", "polarity": "fail", "normalized_id": "m0.install.sh.failed.exit.install.exit", @@ -7477,7 +7477,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 352, + "line": 373, "text": "openshell not found on PATH after install", "polarity": "fail", "normalized_id": "openshell.not.found.on.path.after.install", @@ -7485,7 +7485,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 355, + "line": 376, "text": "openshell installed ($(openshell --version 2>&1 || echo unknown))", "polarity": "pass", "normalized_id": "openshell.installed.openshell.version.2.1.echo.unknown", @@ -7493,7 +7493,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 358, + "line": 379, "text": "nemoclaw not found on PATH after install", "polarity": "fail", "normalized_id": "nemoclaw.not.found.on.path.after.install", @@ -7501,7 +7501,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 361, + "line": 382, "text": "nemoclaw installed at $(command -v nemoclaw)", "polarity": "pass", "normalized_id": "nemoclaw.installed.at.command.v.nemoclaw", @@ -7509,7 +7509,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 366, + "line": 387, "text": "M0b: Sandbox '$SANDBOX_NAME' is Ready", "polarity": "pass", "normalized_id": "m0b.sandbox.sandbox.name.is.ready", @@ -7517,7 +7517,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 368, + "line": 389, "text": "M0b: Sandbox '$SANDBOX_NAME' not Ready (list: ${sandbox_list:0:200})", "polarity": "fail", "normalized_id": "m0b.sandbox.sandbox.name.not.ready.list.sandbox.list.0.200", @@ -7525,7 +7525,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 374, + "line": 395, "text": "M1: Provider '${SANDBOX_NAME}-telegram-bridge' exists in gateway", "polarity": "pass", "normalized_id": "m1.provider.sandbox.name.telegram.bridge.exists.in.gateway", @@ -7533,7 +7533,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 376, + "line": 397, "text": "M1: Provider '${SANDBOX_NAME}-telegram-bridge' not found in gateway", "polarity": "fail", "normalized_id": "m1.provider.sandbox.name.telegram.bridge.not.found.in.gateway", @@ -7541,7 +7541,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 381, + "line": 402, "text": "M2: Provider '${SANDBOX_NAME}-discord-bridge' exists in gateway", "polarity": "pass", "normalized_id": "m2.provider.sandbox.name.discord.bridge.exists.in.gateway", @@ -7549,7 +7549,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 383, + "line": 404, "text": "M2: Provider '${SANDBOX_NAME}-discord-bridge' not found in gateway", "polarity": "fail", "normalized_id": "m2.provider.sandbox.name.discord.bridge.not.found.in.gateway", @@ -7557,7 +7557,23 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 397, + "line": 411, + "text": "M-W1: Provider '${SANDBOX_NAME}-wechat-bridge' exists in gateway", + "polarity": "pass", + "normalized_id": "m.w1.provider.sandbox.name.wechat.bridge.exists.in.gateway", + "mapping_status": "deferred" + }, + { + "script": "test/e2e/test-messaging-providers.sh", + "line": 413, + "text": "M-W1: Provider '${SANDBOX_NAME}-wechat-bridge' not found in gateway (non-interactive QR-skip path may be broken)", + "polarity": "fail", + "normalized_id": "m.w1.provider.sandbox.name.wechat.bridge.not.found.in.gateway.non.interactive.qr.skip.path.may.be.broken", + "mapping_status": "deferred" + }, + { + "script": "test/e2e/test-messaging-providers.sh", + "line": 427, "text": "M3: Real Telegram token leaked into sandbox env", "polarity": "fail", "normalized_id": "m3.real.telegram.token.leaked.into.sandbox.env", @@ -7565,7 +7581,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 399, + "line": 429, "text": "M3: Sandbox TELEGRAM_BOT_TOKEN is a placeholder (not the real token)", "polarity": "pass", "normalized_id": "m3.sandbox.telegram.bot.token.is.a.placeholder.not.the.real.token", @@ -7573,7 +7589,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 410, + "line": 440, "text": "M4: Real Discord token leaked into sandbox env", "polarity": "fail", "normalized_id": "m4.real.discord.token.leaked.into.sandbox.env", @@ -7581,7 +7597,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 412, + "line": 442, "text": "M4: Sandbox DISCORD_BOT_TOKEN is a placeholder (not the real token)", "polarity": "pass", "normalized_id": "m4.sandbox.discord.bot.token.is.a.placeholder.not.the.real.token", @@ -7589,7 +7605,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 419, + "line": 449, "text": "M5: At least one messaging placeholder detected in sandbox", "polarity": "pass", "normalized_id": "m5.at.least.one.messaging.placeholder.detected.in.sandbox", @@ -7597,7 +7613,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 444, + "line": 474, "text": "M5a: Real Telegram token found in full sandbox environment dump", "polarity": "fail", "normalized_id": "m5a.real.telegram.token.found.in.full.sandbox.environment.dump", @@ -7605,7 +7621,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 446, + "line": 476, "text": "M5a: Real Telegram token absent from full sandbox environment", "polarity": "pass", "normalized_id": "m5a.real.telegram.token.absent.from.full.sandbox.environment", @@ -7613,7 +7629,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 453, + "line": 483, "text": "M5b: Real Telegram token found in sandbox process list", "polarity": "fail", "normalized_id": "m5b.real.telegram.token.found.in.sandbox.process.list", @@ -7621,7 +7637,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 455, + "line": 485, "text": "M5b: Real Telegram token absent from sandbox process list", "polarity": "pass", "normalized_id": "m5b.real.telegram.token.absent.from.sandbox.process.list", @@ -7629,7 +7645,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 462, + "line": 492, "text": "M5c: Real Telegram token found on sandbox filesystem: ${sandbox_fs_tg}", "polarity": "fail", "normalized_id": "m5c.real.telegram.token.found.on.sandbox.filesystem.sandbox.fs.tg", @@ -7637,7 +7653,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 464, + "line": 494, "text": "M5c: Real Telegram token absent from sandbox filesystem", "polarity": "pass", "normalized_id": "m5c.real.telegram.token.absent.from.sandbox.filesystem", @@ -7645,7 +7661,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 470, + "line": 500, "text": "M5d: Telegram placeholder confirmed present in sandbox environment", "polarity": "pass", "normalized_id": "m5d.telegram.placeholder.confirmed.present.in.sandbox.environment", @@ -7653,7 +7669,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 472, + "line": 502, "text": "M5d: Telegram placeholder not found in sandbox environment", "polarity": "fail", "normalized_id": "m5d.telegram.placeholder.not.found.in.sandbox.environment", @@ -7661,7 +7677,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 482, + "line": 512, "text": "M5e: Real Discord token found in full sandbox environment dump", "polarity": "fail", "normalized_id": "m5e.real.discord.token.found.in.full.sandbox.environment.dump", @@ -7669,7 +7685,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 484, + "line": 514, "text": "M5e: Real Discord token absent from full sandbox environment", "polarity": "pass", "normalized_id": "m5e.real.discord.token.absent.from.full.sandbox.environment", @@ -7677,7 +7693,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 491, + "line": 521, "text": "M5f: Real Discord token found in sandbox process list", "polarity": "fail", "normalized_id": "m5f.real.discord.token.found.in.sandbox.process.list", @@ -7685,7 +7701,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 493, + "line": 523, "text": "M5f: Real Discord token absent from sandbox process list", "polarity": "pass", "normalized_id": "m5f.real.discord.token.absent.from.sandbox.process.list", @@ -7693,7 +7709,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 499, + "line": 529, "text": "M5g: Real Discord token found on sandbox filesystem: ${sandbox_fs_dc}", "polarity": "fail", "normalized_id": "m5g.real.discord.token.found.on.sandbox.filesystem.sandbox.fs.dc", @@ -7701,7 +7717,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 501, + "line": 531, "text": "M5g: Real Discord token absent from sandbox filesystem", "polarity": "pass", "normalized_id": "m5g.real.discord.token.absent.from.sandbox.filesystem", @@ -7709,7 +7725,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 507, + "line": 537, "text": "M5h: Discord placeholder confirmed present in sandbox environment", "polarity": "pass", "normalized_id": "m5h.discord.placeholder.confirmed.present.in.sandbox.environment", @@ -7717,7 +7733,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 509, + "line": 539, "text": "M5h: Discord placeholder not found in sandbox environment", "polarity": "fail", "normalized_id": "m5h.discord.placeholder.not.found.in.sandbox.environment", @@ -7725,7 +7741,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 524, + "line": 554, "text": "M-S5a: Real Slack bot token found in full sandbox environment dump", "polarity": "fail", "normalized_id": "m.s5a.real.slack.bot.token.found.in.full.sandbox.environment.dump", @@ -7733,7 +7749,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 526, + "line": 556, "text": "M-S5a: Real Slack bot token absent from full sandbox environment", "polarity": "pass", "normalized_id": "m.s5a.real.slack.bot.token.absent.from.full.sandbox.environment", @@ -7741,7 +7757,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 533, + "line": 563, "text": "M-S5b: Real Slack bot token found in sandbox process list", "polarity": "fail", "normalized_id": "m.s5b.real.slack.bot.token.found.in.sandbox.process.list", @@ -7749,7 +7765,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 535, + "line": 565, "text": "M-S5b: Real Slack bot token absent from sandbox process list", "polarity": "pass", "normalized_id": "m.s5b.real.slack.bot.token.absent.from.sandbox.process.list", @@ -7757,7 +7773,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 541, + "line": 571, "text": "M-S5c: Real Slack bot token found on sandbox filesystem: ${sandbox_fs_sl}", "polarity": "fail", "normalized_id": "m.s5c.real.slack.bot.token.found.on.sandbox.filesystem.sandbox.fs.sl", @@ -7765,7 +7781,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 543, + "line": 573, "text": "M-S5c: Real Slack bot token absent from sandbox filesystem", "polarity": "pass", "normalized_id": "m.s5c.real.slack.bot.token.absent.from.sandbox.filesystem", @@ -7773,7 +7789,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 551, + "line": 581, "text": "M-S5d: Real Slack app token found in full sandbox environment dump", "polarity": "fail", "normalized_id": "m.s5d.real.slack.app.token.found.in.full.sandbox.environment.dump", @@ -7781,7 +7797,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 553, + "line": 583, "text": "M-S5d: Real Slack app token absent from sandbox environment", "polarity": "pass", "normalized_id": "m.s5d.real.slack.app.token.absent.from.sandbox.environment", @@ -7789,7 +7805,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 558, + "line": 588, "text": "M-S5d2: Real Slack app token found in sandbox process list", "polarity": "fail", "normalized_id": "m.s5d2.real.slack.app.token.found.in.sandbox.process.list", @@ -7797,7 +7813,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 560, + "line": 590, "text": "M-S5d2: Real Slack app token absent from sandbox process list", "polarity": "pass", "normalized_id": "m.s5d2.real.slack.app.token.absent.from.sandbox.process.list", @@ -7805,7 +7821,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 564, + "line": 594, "text": "M-S5e: Real Slack app token found on sandbox filesystem: ${sandbox_fs_sapp}", "polarity": "fail", "normalized_id": "m.s5e.real.slack.app.token.found.on.sandbox.filesystem.sandbox.fs.sapp", @@ -7813,7 +7829,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 566, + "line": 596, "text": "M-S5e: Real Slack app token absent from sandbox filesystem", "polarity": "pass", "normalized_id": "m.s5e.real.slack.app.token.absent.from.sandbox.filesystem", @@ -7821,7 +7837,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 577, + "line": 607, "text": "M-S5f: Real Slack bot/app token spliced into openclaw.json — apply_slack_token_override regression?", "polarity": "fail", "normalized_id": "m.s5f.real.slack.bot.app.token.spliced.into.openclaw.json.apply.slack.token.override.regression", @@ -7829,7 +7845,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 581, + "line": 611, "text": "M-S5f: openclaw.json holds both Bolt-shape Slack placeholders (no real token on disk)", "polarity": "pass", "normalized_id": "m.s5f.openclaw.json.holds.both.bolt.shape.slack.placeholders.no.real.token.on.disk", @@ -7837,7 +7853,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 590, + "line": 620, "text": "M-S5g: removed Slack token rewriter preload still present in NODE_OPTIONS", "polarity": "fail", "normalized_id": "m.s5g.removed.slack.token.rewriter.preload.still.present.in.node.options", @@ -7845,7 +7861,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 592, + "line": 622, "text": "M-S5g: Slack token rewriter preload absent from NODE_OPTIONS", "polarity": "pass", "normalized_id": "m.s5g.slack.token.rewriter.preload.absent.from.node.options", @@ -7853,7 +7869,87 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 612, + "line": 638, + "text": "M-W3: Real WeChat token leaked into sandbox env", + "polarity": "fail", + "normalized_id": "m.w3.real.wechat.token.leaked.into.sandbox.env", + "mapping_status": "deferred" + }, + { + "script": "test/e2e/test-messaging-providers.sh", + "line": 640, + "text": "M-W3: Sandbox WECHAT_BOT_TOKEN is a placeholder (not the real token)", + "polarity": "pass", + "normalized_id": "m.w3.sandbox.wechat.bot.token.is.a.placeholder.not.the.real.token", + "mapping_status": "retired" + }, + { + "script": "test/e2e/test-messaging-providers.sh", + "line": 649, + "text": "M-W3a: Real WeChat token found in full sandbox environment dump", + "polarity": "fail", + "normalized_id": "m.w3a.real.wechat.token.found.in.full.sandbox.environment.dump", + "mapping_status": "deferred" + }, + { + "script": "test/e2e/test-messaging-providers.sh", + "line": 651, + "text": "M-W3a: Real WeChat token absent from full sandbox environment", + "polarity": "pass", + "normalized_id": "m.w3a.real.wechat.token.absent.from.full.sandbox.environment", + "mapping_status": "deferred" + }, + { + "script": "test/e2e/test-messaging-providers.sh", + "line": 658, + "text": "M-W3b: Real WeChat token found in sandbox process list", + "polarity": "fail", + "normalized_id": "m.w3b.real.wechat.token.found.in.sandbox.process.list", + "mapping_status": "deferred" + }, + { + "script": "test/e2e/test-messaging-providers.sh", + "line": 660, + "text": "M-W3b: Real WeChat token absent from sandbox process list", + "polarity": "pass", + "normalized_id": "m.w3b.real.wechat.token.absent.from.sandbox.process.list", + "mapping_status": "deferred" + }, + { + "script": "test/e2e/test-messaging-providers.sh", + "line": 668, + "text": "M-W3c: Real WeChat token found on sandbox filesystem: ${sandbox_fs_wc}", + "polarity": "fail", + "normalized_id": "m.w3c.real.wechat.token.found.on.sandbox.filesystem.sandbox.fs.wc", + "mapping_status": "deferred" + }, + { + "script": "test/e2e/test-messaging-providers.sh", + "line": 670, + "text": "M-W3c: Real WeChat token absent from sandbox filesystem", + "polarity": "pass", + "normalized_id": "m.w3c.real.wechat.token.absent.from.sandbox.filesystem", + "mapping_status": "deferred" + }, + { + "script": "test/e2e/test-messaging-providers.sh", + "line": 676, + "text": "M-W3d: WeChat placeholder confirmed present in sandbox environment", + "polarity": "pass", + "normalized_id": "m.w3d.wechat.placeholder.confirmed.present.in.sandbox.environment", + "mapping_status": "retired" + }, + { + "script": "test/e2e/test-messaging-providers.sh", + "line": 678, + "text": "M-W3d: WeChat placeholder not found in sandbox environment", + "polarity": "fail", + "normalized_id": "m.w3d.wechat.placeholder.not.found.in.sandbox.environment", + "mapping_status": "retired" + }, + { + "script": "test/e2e/test-messaging-providers.sh", + "line": 701, "text": "M6: Could not read openclaw.json channels (${channel_json:0:200})", "polarity": "fail", "normalized_id": "m6.could.not.read.openclaw.json.channels.channel.json.0.200", @@ -7861,7 +7957,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 629, + "line": 718, "text": "M6: Telegram channel botToken present in openclaw.json", "polarity": "pass", "normalized_id": "m6.telegram.channel.bottoken.present.in.openclaw.json", @@ -7869,7 +7965,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 636, + "line": 725, "text": "M7: Telegram botToken is not the host-side token (placeholder confirmed)", "polarity": "pass", "normalized_id": "m7.telegram.bottoken.is.not.the.host.side.token.placeholder.confirmed", @@ -7877,7 +7973,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 638, + "line": 727, "text": "M7: Telegram botToken matches host-side token — credential leaked into config!", "polarity": "fail", "normalized_id": "m7.telegram.bottoken.matches.host.side.token.credential.leaked.into.config", @@ -7885,7 +7981,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 653, + "line": 742, "text": "M8: Discord channel token present in openclaw.json", "polarity": "pass", "normalized_id": "m8.discord.channel.token.present.in.openclaw.json", @@ -7893,7 +7989,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 660, + "line": 749, "text": "M9: Discord token is not the host-side token (placeholder confirmed)", "polarity": "pass", "normalized_id": "m9.discord.token.is.not.the.host.side.token.placeholder.confirmed", @@ -7901,7 +7997,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 662, + "line": 751, "text": "M9: Discord token matches host-side token — credential leaked into config!", "polarity": "fail", "normalized_id": "m9.discord.token.matches.host.side.token.credential.leaked.into.config", @@ -7909,7 +8005,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 677, + "line": 766, "text": "M10: Telegram channel is enabled", "polarity": "pass", "normalized_id": "m10.telegram.channel.is.enabled", @@ -7917,7 +8013,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 692, + "line": 781, "text": "M11: Discord channel is enabled", "polarity": "pass", "normalized_id": "m11.discord.channel.is.enabled", @@ -7925,7 +8021,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 707, + "line": 796, "text": "M11b: Telegram dmPolicy is 'allowlist'", "polarity": "pass", "normalized_id": "m11b.telegram.dmpolicy.is.allowlist", @@ -7933,7 +8029,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 709, + "line": 798, "text": "M11b: Telegram dmPolicy is '$tg_dm_policy' (expected 'allowlist')", "polarity": "fail", "normalized_id": "m11b.telegram.dmpolicy.is.tg.dm.policy.expected.allowlist", @@ -7941,7 +8037,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 737, + "line": 826, "text": "M11c: Telegram allowFrom contains all expected user IDs: $tg_allow_from", "polarity": "pass", "normalized_id": "m11c.telegram.allowfrom.contains.all.expected.user.ids.tg.allow.from", @@ -7949,7 +8045,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 739, + "line": 828, "text": "M11c: Telegram allowFrom ($tg_allow_from) is missing IDs: ${missing_ids[*]} (expected all of: $TELEGRAM_IDS)", "polarity": "fail", "normalized_id": "m11c.telegram.allowfrom.tg.allow.from.is.missing.ids.missing.ids.expected.all.of.telegram.ids", @@ -7957,7 +8053,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 755, + "line": 844, "text": "M11d: Telegram groupPolicy is 'open'", "polarity": "pass", "normalized_id": "m11d.telegram.grouppolicy.is.open", @@ -7965,7 +8061,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 757, + "line": 846, "text": "M11d: Telegram groupPolicy is '$tg_group_policy' (expected 'open')", "polarity": "fail", "normalized_id": "m11d.telegram.grouppolicy.is.tg.group.policy.expected.open", @@ -7973,7 +8069,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 773, + "line": 862, "text": "M11e: Slack channel configured with placeholder tokens (guard needed)", "polarity": "pass", "normalized_id": "m11e.slack.channel.configured.with.placeholder.tokens.guard.needed", @@ -7981,7 +8077,55 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 803, + "line": 887, + "text": "M-W8: WeChat account '$WECHAT_ACCOUNT' is enabled in openclaw.json (channels.openclaw-weixin)", + "polarity": "pass", + "normalized_id": "m.w8.wechat.account.wechat.account.is.enabled.in.openclaw.json.channels.openclaw.weixin", + "mapping_status": "deferred" + }, + { + "script": "test/e2e/test-messaging-providers.sh", + "line": 903, + "text": "M-W9: Real WeChat token spliced into accounts/${WECHAT_ACCOUNT}.json — seed-wechat-accounts.py placeholder regression", + "polarity": "fail", + "normalized_id": "m.w9.real.wechat.token.spliced.into.accounts.wechat.account.json.seed.wechat.accounts.py.placeholder.regression", + "mapping_status": "deferred" + }, + { + "script": "test/e2e/test-messaging-providers.sh", + "line": 905, + "text": "M-W9: WeChat per-account credential file uses the L7-resolved placeholder", + "polarity": "pass", + "normalized_id": "m.w9.wechat.per.account.credential.file.uses.the.l7.resolved.placeholder", + "mapping_status": "deferred" + }, + { + "script": "test/e2e/test-messaging-providers.sh", + "line": 907, + "text": "M-W9: WeChat per-account credential file has unexpected token shape: $(echo ", + "polarity": "fail", + "normalized_id": "m.w9.wechat.per.account.credential.file.has.unexpected.token.shape.echo", + "mapping_status": "deferred" + }, + { + "script": "test/e2e/test-messaging-providers.sh", + "line": 926, + "text": "M-W10: WeChat accounts.json index contains '$WECHAT_ACCOUNT'", + "polarity": "pass", + "normalized_id": "m.w10.wechat.accounts.json.index.contains.wechat.account", + "mapping_status": "deferred" + }, + { + "script": "test/e2e/test-messaging-providers.sh", + "line": 928, + "text": "M-W10: WeChat accounts.json missing '$WECHAT_ACCOUNT' (raw: $(echo ", + "polarity": "fail", + "normalized_id": "m.w10.wechat.accounts.json.missing.wechat.account.raw.echo", + "mapping_status": "deferred" + }, + { + "script": "test/e2e/test-messaging-providers.sh", + "line": 949, "text": "M12: Node.js reached api.telegram.org (${tg_reach})", "polarity": "pass", "normalized_id": "m12.node.js.reached.api.telegram.org.tg.reach", @@ -7989,7 +8133,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 809, + "line": 955, "text": "M12: Node.js could not reach api.telegram.org (${tg_reach:0:200})", "polarity": "fail", "normalized_id": "m12.node.js.could.not.reach.api.telegram.org.tg.reach.0.200", @@ -7997,7 +8141,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 824, + "line": 970, "text": "M13: Node.js reached discord.com (${dc_reach})", "polarity": "pass", "normalized_id": "m13.node.js.reached.discord.com.dc.reach", @@ -8005,7 +8149,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 828, + "line": 974, "text": "M13: Node.js could not reach discord.com (${dc_reach:0:200})", "polarity": "fail", "normalized_id": "m13.node.js.could.not.reach.discord.com.dc.reach.0.200", @@ -8013,7 +8157,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 835, + "line": 981, "text": "M13b: Hermetic fake Discord Gateway started on host port ${FAKE_DISCORD_GATEWAY_PORT}", "polarity": "pass", "normalized_id": "m13b.hermetic.fake.discord.gateway.started.on.host.port.fake.discord.gateway.port", @@ -8021,7 +8165,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 837, + "line": 983, "text": "M13b: Failed to start hermetic fake Discord Gateway", "polarity": "fail", "normalized_id": "m13b.failed.to.start.hermetic.fake.discord.gateway", @@ -8029,7 +8173,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 842, + "line": 988, "text": "M13c: Applied native WebSocket policy with credential rewrite for fake Discord Gateway", "polarity": "pass", "normalized_id": "m13c.applied.native.websocket.policy.with.credential.rewrite.for.fake.discord.gateway", @@ -8037,7 +8181,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 844, + "line": 990, "text": "M13c: Failed to apply fake Discord Gateway policy: $(tail -20 /tmp/nemoclaw-fake-discord-policy.log 2>/dev/null | tr '\\n' ' ' | cut -c1-300)", "polarity": "fail", "normalized_id": "m13c.failed.to.apply.fake.discord.gateway.policy.tail.20.tmp.nemoclaw.fake.discord.policy.log.2.dev.null.tr.n.cut.c1.300", @@ -8045,7 +8189,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 854, + "line": 1000, "text": "M13d: Native WebSocket upgrade reached fake Discord Gateway through OpenShell", "polarity": "pass", "normalized_id": "m13d.native.websocket.upgrade.reached.fake.discord.gateway.through.openshell", @@ -8053,7 +8197,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 856, + "line": 1002, "text": "M13d: Native WebSocket upgrade failed: ${dc_ws_native:0:300}", "polarity": "fail", "normalized_id": "m13d.native.websocket.upgrade.failed.dc.ws.native.0.300", @@ -8061,7 +8205,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 863, + "line": 1009, "text": "M13e: Discord HELLO, placeholder IDENTIFY, READY, and heartbeat ACK completed", "polarity": "pass", "normalized_id": "m13e.discord.hello.placeholder.identify.ready.and.heartbeat.ack.completed", @@ -8069,7 +8213,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 865, + "line": 1011, "text": "M13e: Discord Gateway protocol proof incomplete: ${dc_ws_native:0:400}", "polarity": "fail", "normalized_id": "m13e.discord.gateway.protocol.proof.incomplete.dc.ws.native.0.400", @@ -8077,7 +8221,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 871, + "line": 1017, "text": "M13f: Fake Gateway received host-side Discord token; sandbox-visible IDENTIFY used only the placeholder", "polarity": "pass", "normalized_id": "m13f.fake.gateway.received.host.side.discord.token.sandbox.visible.identify.used.only.the.placeholder", @@ -8085,7 +8229,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 876, + "line": 1022, "text": "M13f: Fake Gateway did not prove placeholder-to-token rewrite at the relay boundary", "polarity": "fail", "normalized_id": "m13f.fake.gateway.did.not.prove.placeholder.to.token.rewrite.at.the.relay.boundary", @@ -8093,7 +8237,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 892, + "line": 1038, "text": "M13g: Unregistered Discord WebSocket placeholder is rejected before upstream token exposure", "polarity": "pass", "normalized_id": "m13g.unregistered.discord.websocket.placeholder.is.rejected.before.upstream.token.exposure", @@ -8101,7 +8245,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 894, + "line": 1040, "text": "M13g: Unregistered Discord WebSocket placeholder reached READY or leaked upstream", "polarity": "fail", "normalized_id": "m13g.unregistered.discord.websocket.placeholder.reached.ready.or.leaked.upstream", @@ -8109,7 +8253,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 900, + "line": 1046, "text": "M14: curl to api.telegram.org blocked (binary restriction enforced)", "polarity": "pass", "normalized_id": "m14.curl.to.api.telegram.org.blocked.binary.restriction.enforced", @@ -8117,7 +8261,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 902, + "line": 1048, "text": "M14: curl returned empty (likely blocked by policy)", "polarity": "pass", "normalized_id": "m14.curl.returned.empty.likely.blocked.by.policy", @@ -8125,7 +8269,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 906, + "line": 1052, "text": "M14: curl not available in sandbox (defense in depth)", "polarity": "pass", "normalized_id": "m14.curl.not.available.in.sandbox.defense.in.depth", @@ -8133,7 +8277,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 940, + "line": 1086, "text": "M15: Telegram getMe returned 200 — real token verified!", "polarity": "pass", "normalized_id": "m15.telegram.getme.returned.200.real.token.verified", @@ -8141,7 +8285,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 945, + "line": 1091, "text": "M15: Telegram getMe returned $tg_status — L7 proxy rewrote placeholder (fake token rejected by API)", "polarity": "pass", "normalized_id": "m15.telegram.getme.returned.tg.status.l7.proxy.rewrote.placeholder.fake.token.rejected.by.api", @@ -8149,7 +8293,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 946, + "line": 1092, "text": "M16: Full chain verified: sandbox → proxy → token rewrite → Telegram API", "polarity": "pass", "normalized_id": "m16.full.chain.verified.sandbox.proxy.token.rewrite.telegram.api", @@ -8157,7 +8301,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 952, + "line": 1098, "text": "M15: Telegram API call failed with error: ${tg_api:0:200}", "polarity": "fail", "normalized_id": "m15.telegram.api.call.failed.with.error.tg.api.0.200", @@ -8165,7 +8309,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 954, + "line": 1100, "text": "M15: Unexpected Telegram response (status=$tg_status): ${tg_api:0:200}", "polarity": "fail", "normalized_id": "m15.unexpected.telegram.response.status.tg.status.tg.api.0.200", @@ -8173,7 +8317,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 981, + "line": 1127, "text": "M17: Discord users/@me returned 200 — real token verified!", "polarity": "pass", "normalized_id": "m17.discord.users.me.returned.200.real.token.verified", @@ -8181,7 +8325,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 983, + "line": 1129, "text": "M17: Discord users/@me returned 401 — L7 proxy rewrote placeholder (fake token rejected by API)", "polarity": "pass", "normalized_id": "m17.discord.users.me.returned.401.l7.proxy.rewrote.placeholder.fake.token.rejected.by.api", @@ -8189,7 +8333,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 987, + "line": 1133, "text": "M17: Discord API call failed with error: ${dc_api:0:200}", "polarity": "fail", "normalized_id": "m17.discord.api.call.failed.with.error.dc.api.0.200", @@ -8197,7 +8341,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 989, + "line": 1135, "text": "M17: Unexpected Discord response (status=$dc_status): ${dc_api:0:200}", "polarity": "fail", "normalized_id": "m17.unexpected.discord.response.status.dc.status.dc.api.0.200", @@ -8205,7 +8349,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 1001, + "line": 1147, "text": "M-S14a: Hermetic fake Slack API started on host port ${FAKE_SLACK_API_PORT}", "polarity": "pass", "normalized_id": "m.s14a.hermetic.fake.slack.api.started.on.host.port.fake.slack.api.port", @@ -8213,7 +8357,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 1003, + "line": 1149, "text": "M-S14a: Failed to start hermetic fake Slack API", "polarity": "fail", "normalized_id": "m.s14a.failed.to.start.hermetic.fake.slack.api", @@ -8221,7 +8365,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 1008, + "line": 1154, "text": "M-S14b: Applied REST policy for hermetic fake Slack API", "polarity": "pass", "normalized_id": "m.s14b.applied.rest.policy.for.hermetic.fake.slack.api", @@ -8229,7 +8373,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 1010, + "line": 1156, "text": "M-S14b: Failed to apply fake Slack API policy: $(tail -20 /tmp/nemoclaw-fake-slack-policy.log 2>/dev/null | tr '\\n' ' ' | cut -c1-300)", "polarity": "fail", "normalized_id": "m.s14b.failed.to.apply.fake.slack.api.policy.tail.20.tmp.nemoclaw.fake.slack.policy.log.2.dev.null.tr.n.cut.c1.300", @@ -8237,7 +8381,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 1061, + "line": 1207, "text": "M-S15: Slack auth.test returned ok:true — real token round-trip verified!", "polarity": "pass", "normalized_id": "m.s15.slack.auth.test.returned.ok.true.real.token.round.trip.verified", @@ -8245,7 +8389,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 1063, + "line": 1209, "text": "M-S15: Slack auth.test returned invalid_auth — full chain verified (OpenShell alias rewrite → fake Slack)", "polarity": "pass", "normalized_id": "m.s15.slack.auth.test.returned.invalid.auth.full.chain.verified.openshell.alias.rewrite.fake.slack", @@ -8253,7 +8397,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 1066, + "line": 1212, "text": "M-S15a: fake Slack saw host-side bot token in header and urlencoded body", "polarity": "pass", "normalized_id": "m.s15a.fake.slack.saw.host.side.bot.token.in.header.and.urlencoded.body", @@ -8261,7 +8405,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 1068, + "line": 1214, "text": "M-S15a: fake Slack capture did not prove bot header/body rewrite: ${sl_capture:0:300}", "polarity": "fail", "normalized_id": "m.s15a.fake.slack.capture.did.not.prove.bot.header.body.rewrite.sl.capture.0.300", @@ -8269,7 +8413,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 1073, + "line": 1219, "text": "M-S15: Slack API call failed with error: ${sl_api:0:200}", "polarity": "fail", "normalized_id": "m.s15.slack.api.call.failed.with.error.sl.api.0.200", @@ -8277,7 +8421,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 1075, + "line": 1221, "text": "M-S15: OpenShell did not resolve the Bolt-shape alias", "polarity": "fail", "normalized_id": "m.s15.openshell.did.not.resolve.the.bolt.shape.alias", @@ -8285,7 +8429,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 1077, + "line": 1223, "text": "M-S15: L7 proxy did not substitute the canonical placeholder — substitution chain broken", "polarity": "fail", "normalized_id": "m.s15.l7.proxy.did.not.substitute.the.canonical.placeholder.substitution.chain.broken", @@ -8293,7 +8437,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 1079, + "line": 1225, "text": "M-S15: Unexpected Slack response (status=$sl_status): ${sl_api:0:200}", "polarity": "fail", "normalized_id": "m.s15.unexpected.slack.response.status.sl.status.sl.api.0.200", @@ -8301,7 +8445,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 1100, + "line": 1246, "text": "M-S15b: L7 proxy substitutes openshell:resolve:env:SLACK_BOT_TOKEN at egress (parallels Telegram M15 / Discord M17)", "polarity": "pass", "normalized_id": "m.s15b.l7.proxy.substitutes.openshell.resolve.env.slack.bot.token.at.egress.parallels.telegram.m15.discord.m17", @@ -8309,7 +8453,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 1104, + "line": 1250, "text": "M-S15b: L7 proxy passed canonical placeholder through unchanged — substitution not happening for SLACK_BOT_TOKEN", "polarity": "fail", "normalized_id": "m.s15b.l7.proxy.passed.canonical.placeholder.through.unchanged.substitution.not.happening.for.slack.bot.token", @@ -8317,7 +8461,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 1106, + "line": 1252, "text": "M-S15b: Unexpected response (status=$sl_canon_status): ${sl_canonical:0:200}", "polarity": "fail", "normalized_id": "m.s15b.unexpected.response.status.sl.canon.status.sl.canonical.0.200", @@ -8325,7 +8469,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 1127, + "line": 1273, "text": "M-S15c: unset-var failed closed before upstream exposure", "polarity": "pass", "normalized_id": "m.s15c.unset.var.failed.closed.before.upstream.exposure", @@ -8333,7 +8477,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 1129, + "line": 1275, "text": "M-S15c: unset-var triggered connection-level failure — proxy refuses to forward unsubstituted placeholder", "polarity": "pass", "normalized_id": "m.s15c.unset.var.triggered.connection.level.failure.proxy.refuses.to.forward.unsubstituted.placeholder", @@ -8341,7 +8485,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 1131, + "line": 1277, "text": "M-S15c: unset-var returned HTTP 200 — proxy passed canonical placeholder through unchanged for unset env (substitution may be a no-op)", "polarity": "fail", "normalized_id": "m.s15c.unset.var.returned.http.200.proxy.passed.canonical.placeholder.through.unchanged.for.unset.env.substitution.may.be.a.no.op", @@ -8349,7 +8493,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 1133, + "line": 1279, "text": "M-S15c: unset-var request reached fake Slack — unresolved placeholder escaped the proxy boundary", "polarity": "fail", "normalized_id": "m.s15c.unset.var.request.reached.fake.slack.unresolved.placeholder.escaped.the.proxy.boundary", @@ -8357,7 +8501,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 1154, + "line": 1300, "text": "M-S16: apps.connections.open returned ok:true — real xapp token round-trip verified!", "polarity": "pass", "normalized_id": "m.s16.apps.connections.open.returned.ok.true.real.xapp.token.round.trip.verified", @@ -8365,7 +8509,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 1156, + "line": 1302, "text": "M-S16: apps.connections.open auth-rejected — Socket Mode HTTPS leg verified (OpenShell alias rewrite → fake Slack)", "polarity": "pass", "normalized_id": "m.s16.apps.connections.open.auth.rejected.socket.mode.https.leg.verified.openshell.alias.rewrite.fake.slack", @@ -8373,7 +8517,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 1159, + "line": 1305, "text": "M-S16a: fake Slack saw host-side app token in header and urlencoded body", "polarity": "pass", "normalized_id": "m.s16a.fake.slack.saw.host.side.app.token.in.header.and.urlencoded.body", @@ -8381,7 +8525,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 1161, + "line": 1307, "text": "M-S16a: fake Slack capture did not prove app header/body rewrite: ${sl_app_capture:0:300}", "polarity": "fail", "normalized_id": "m.s16a.fake.slack.capture.did.not.prove.app.header.body.rewrite.sl.app.capture.0.300", @@ -8389,7 +8533,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 1166, + "line": 1312, "text": "M-S16: OpenShell did not resolve the xapp- alias for Socket Mode path", "polarity": "fail", "normalized_id": "m.s16.openshell.did.not.resolve.the.xapp.alias.for.socket.mode.path", @@ -8397,7 +8541,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 1168, + "line": 1314, "text": "M-S16: Unexpected apps.connections.open response (status=$sl_app_status): ${sl_app_api:0:200}", "polarity": "fail", "normalized_id": "m.s16.unexpected.apps.connections.open.response.status.sl.app.status.sl.app.api.0.200", @@ -8405,7 +8549,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 1192, + "line": 1338, "text": "M-S16b: unset app-token failed closed before upstream exposure", "polarity": "pass", "normalized_id": "m.s16b.unset.app.token.failed.closed.before.upstream.exposure", @@ -8413,7 +8557,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 1194, + "line": 1340, "text": "M-S16b: L7 proxy substitutes openshell:resolve:env:SLACK_APP_TOKEN at egress (unset-var control diverged)", "polarity": "pass", "normalized_id": "m.s16b.l7.proxy.substitutes.openshell.resolve.env.slack.app.token.at.egress.unset.var.control.diverged", @@ -8421,7 +8565,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 1196, + "line": 1342, "text": "M-S16b: unset app-token env returned HTTP 200 — proxy may be passing canonical placeholders through unchanged", "polarity": "fail", "normalized_id": "m.s16b.unset.app.token.env.returned.http.200.proxy.may.be.passing.canonical.placeholders.through.unchanged", @@ -8429,7 +8573,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 1198, + "line": 1344, "text": "M-S16b: unset app-token request reached fake Slack — unresolved placeholder escaped the proxy boundary", "polarity": "fail", "normalized_id": "m.s16b.unset.app.token.request.reached.fake.slack.unresolved.placeholder.escaped.the.proxy.boundary", @@ -8437,7 +8581,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 1207, + "line": 1353, "text": "M-S16b: L7 proxy passed canonical placeholder through unchanged for SLACK_APP_TOKEN", "polarity": "fail", "normalized_id": "m.s16b.l7.proxy.passed.canonical.placeholder.through.unchanged.for.slack.app.token", @@ -8445,7 +8589,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 1209, + "line": 1355, "text": "M-S16b: Unexpected response (status=$sl_app_canon_status): ${sl_app_canonical:0:200}", "polarity": "fail", "normalized_id": "m.s16b.unexpected.response.status.sl.app.canon.status.sl.app.canonical.0.200", @@ -8453,7 +8597,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 1224, + "line": 1370, "text": "M18: Telegram getMe returned 200 with real token", "polarity": "pass", "normalized_id": "m18.telegram.getme.returned.200.with.real.token", @@ -8461,7 +8605,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 1226, + "line": 1372, "text": "M18b: Telegram response contains ok:true", "polarity": "pass", "normalized_id": "m18b.telegram.response.contains.ok.true", @@ -8469,7 +8613,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 1229, + "line": 1375, "text": "M18: Expected Telegram getMe 200 with real token, got: $tg_status", "polarity": "fail", "normalized_id": "m18.expected.telegram.getme.200.with.real.token.got.tg.status", @@ -8477,7 +8621,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 1259, + "line": 1405, "text": "M19: Telegram sendMessage succeeded", "polarity": "pass", "normalized_id": "m19.telegram.sendmessage.succeeded", @@ -8485,7 +8629,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 1261, + "line": 1407, "text": "M19: Telegram sendMessage failed: ${send_result:0:200}", "polarity": "fail", "normalized_id": "m19.telegram.sendmessage.failed.send.result.0.200", @@ -8493,7 +8637,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 1273, + "line": 1419, "text": "M20: Discord users/@me returned 200 with real token", "polarity": "pass", "normalized_id": "m20.discord.users.me.returned.200.with.real.token", @@ -8501,7 +8645,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 1275, + "line": 1421, "text": "M20: Expected Discord users/@me 200 with real token, got: $dc_status", "polarity": "fail", "normalized_id": "m20.expected.discord.users.me.200.with.real.token.got.dc.status", @@ -8509,7 +8653,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 1307, + "line": 1453, "text": "S1: Gateway is serving on port 18789 — Slack auth failure did not crash it", "polarity": "pass", "normalized_id": "s1.gateway.is.serving.on.port.18789.slack.auth.failure.did.not.crash.it", @@ -8517,7 +8661,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 1309, + "line": 1455, "text": "S1: Gateway is not serving on port 18789 (${gw_port:0:200})", "polarity": "fail", "normalized_id": "s1.gateway.is.not.serving.on.port.18789.gw.port.0.200", @@ -8525,7 +8669,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 1335, + "line": 1481, "text": "S2: Gateway log shows Slack rejection was caught by channel guard", "polarity": "pass", "normalized_id": "s2.gateway.log.shows.slack.rejection.was.caught.by.channel.guard", @@ -8533,7 +8677,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 1360, + "line": 1506, "text": "Cleanup: Sandbox '$SANDBOX_NAME' intentionally kept", "polarity": "pass", "normalized_id": "cleanup.sandbox.sandbox.name.intentionally.kept", @@ -8541,7 +8685,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 1362, + "line": 1508, "text": "Cleanup: Sandbox '$SANDBOX_NAME' still present after cleanup", "polarity": "fail", "normalized_id": "cleanup.sandbox.sandbox.name.still.present.after.cleanup", @@ -8549,7 +8693,7 @@ }, { "script": "test/e2e/test-messaging-providers.sh", - "line": 1364, + "line": 1510, "text": "Cleanup: Sandbox '$SANDBOX_NAME' removed", "polarity": "pass", "normalized_id": "cleanup.sandbox.sandbox.name.removed", @@ -15795,7 +15939,7 @@ ], "totals": { "scripts": 49, - "assertions": 1943, + "assertions": 1961, "zero_assertion_scripts": 1 } } diff --git a/test/e2e/docs/parity-map.yaml b/test/e2e/docs/parity-map.yaml index b2ecb790f80..8f38500e210 100644 --- a/test/e2e/docs/parity-map.yaml +++ b/test/e2e/docs/parity-map.yaml @@ -5398,6 +5398,96 @@ scripts: reason: legacy assertion is obsolete or negative cleanup behavior after scenario migration reviewer: e2e-maintainers approved_at: '2026-05-13' + - legacy: 'M-W1: Provider ''${SANDBOX_NAME}-wechat-bridge'' exists in gateway' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + secret_requirement: WeChat test credentials + - legacy: 'M-W1: Provider ''${SANDBOX_NAME}-wechat-bridge'' not found in gateway (non-interactive QR-skip path may be broken)' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + secret_requirement: WeChat test credentials + - legacy: 'M-W3: Real WeChat token leaked into sandbox env' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + secret_requirement: WeChat test credentials + - legacy: 'M-W3: Sandbox WECHAT_BOT_TOKEN is a placeholder (not the real token)' + status: retired + reason: legacy assertion is obsolete or negative cleanup behavior after scenario migration + reviewer: e2e-maintainers + approved_at: '2026-05-15' + - legacy: 'M-W3a: Real WeChat token found in full sandbox environment dump' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + secret_requirement: WeChat test credentials + - legacy: 'M-W3a: Real WeChat token absent from full sandbox environment' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + secret_requirement: WeChat test credentials + - legacy: 'M-W3b: Real WeChat token found in sandbox process list' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + secret_requirement: WeChat test credentials + - legacy: 'M-W3b: Real WeChat token absent from sandbox process list' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + secret_requirement: WeChat test credentials + - legacy: 'M-W3c: Real WeChat token found on sandbox filesystem: ${sandbox_fs_wc}' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + secret_requirement: WeChat test credentials + - legacy: 'M-W3c: Real WeChat token absent from sandbox filesystem' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + secret_requirement: WeChat test credentials + - legacy: 'M-W3d: WeChat placeholder confirmed present in sandbox environment' + status: retired + reason: legacy assertion is obsolete or negative cleanup behavior after scenario migration + reviewer: e2e-maintainers + approved_at: '2026-05-15' + - legacy: 'M-W3d: WeChat placeholder not found in sandbox environment' + status: retired + reason: legacy assertion is obsolete or negative cleanup behavior after scenario migration + reviewer: e2e-maintainers + approved_at: '2026-05-15' + - legacy: 'M-W8: WeChat account ''$WECHAT_ACCOUNT'' is enabled in openclaw.json (channels.openclaw-weixin)' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + secret_requirement: WeChat test credentials + - legacy: 'M-W9: Real WeChat token spliced into accounts/${WECHAT_ACCOUNT}.json — seed-wechat-accounts.py placeholder regression' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + secret_requirement: WeChat test credentials + - legacy: 'M-W9: WeChat per-account credential file uses the L7-resolved placeholder' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + secret_requirement: WeChat test credentials + - legacy: 'M-W9: WeChat per-account credential file has unexpected token shape: $(echo ' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + secret_requirement: WeChat test credentials + - legacy: 'M-W10: WeChat accounts.json index contains ''$WECHAT_ACCOUNT''' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + secret_requirement: WeChat test credentials + - legacy: 'M-W10: WeChat accounts.json missing ''$WECHAT_ACCOUNT'' (raw: $(echo ' + status: deferred + reason: live legacy behavior requires non-deterministic infrastructure; retained for bucket parity tracking + owner: e2e-maintainers + secret_requirement: WeChat test credentials test-network-policy.sh: scenario: ubuntu-repo-cloud-openclaw status: migrated diff --git a/test/e2e/test-messaging-providers.sh b/test/e2e/test-messaging-providers.sh index 8c7fa854622..2d6f7bbc6cb 100755 --- a/test/e2e/test-messaging-providers.sh +++ b/test/e2e/test-messaging-providers.sh @@ -46,6 +46,11 @@ # SLACK_APP_TOKEN — defaults to fake token (xapp-fake-...) # SLACK_BOT_TOKEN_REVOKED — optional: revoked xoxb- token to test auth pre-validation (#2340) # SLACK_APP_TOKEN_REVOKED — optional: paired xapp- token for the revoked bot token +# WECHAT_BOT_TOKEN — defaults to fake token; presence skips host-side QR login +# WECHAT_ACCOUNT_ID — defaults to fake iLink account ID (seed-wechat-accounts.py key) +# WECHAT_BASE_URL — defaults to fake iLink baseUrl (per-account API host) +# WECHAT_USER_ID — defaults to fake operator wechat user ID (seeds DM allowlist) +# WECHAT_ALLOWED_IDS — optional: comma-separated DM allowlist for wechat # TELEGRAM_CHAT_ID_E2E — optional: enables sendMessage test # NEMOCLAW_OPENSHELL_BIN — optional OpenShell binary under test # NEMOCLAW_FRESH=1 — auto-set to discard interrupted onboard sessions @@ -118,11 +123,26 @@ DISCORD_TOKEN="${DISCORD_BOT_TOKEN:-test-fake-discord-token-e2e}" SLACK_TOKEN="${SLACK_BOT_TOKEN:-xoxb-fake-slack-token-e2e}" SLACK_APP="${SLACK_APP_TOKEN:-xapp-fake-slack-app-token-e2e}" TELEGRAM_IDS="${TELEGRAM_ALLOWED_IDS:-123456789,987654321}" +# WeChat: pre-seeding WECHAT_BOT_TOKEN + the per-account metadata env vars lets +# the non-interactive onboard path (src/lib/onboard.ts:8433) treat wechat as +# "already configured" and skip the host-qr handler entirely. Fake values are +# enough — Phase 1-3 verify placeholders/isolation; no live iLink contact is +# made because no token exchange happens at build time. +WECHAT_TOKEN="${WECHAT_BOT_TOKEN:-test-fake-wechat-token-e2e}" +WECHAT_ACCOUNT="${WECHAT_ACCOUNT_ID:-e2e-fake-account-12345}" +WECHAT_BASE="${WECHAT_BASE_URL:-https://ilinkai-fake-e2e.wechat.com}" +WECHAT_USER="${WECHAT_USER_ID:-wxid_e2efakeoperator}" +WECHAT_IDS="${WECHAT_ALLOWED_IDS:-${WECHAT_USER}}" export TELEGRAM_BOT_TOKEN="$TELEGRAM_TOKEN" export DISCORD_BOT_TOKEN="$DISCORD_TOKEN" export SLACK_BOT_TOKEN="$SLACK_TOKEN" export SLACK_APP_TOKEN="$SLACK_APP" export TELEGRAM_ALLOWED_IDS="$TELEGRAM_IDS" +export WECHAT_BOT_TOKEN="$WECHAT_TOKEN" +export WECHAT_ACCOUNT_ID="$WECHAT_ACCOUNT" +export WECHAT_BASE_URL="$WECHAT_BASE" +export WECHAT_USER_ID="$WECHAT_USER" +export WECHAT_ALLOWED_IDS="$WECHAT_IDS" # Run a command inside the sandbox via stdin (avoids exposing sensitive args in process list) sandbox_exec_stdin() { @@ -192,6 +212,7 @@ info "Telegram token: ${TELEGRAM_TOKEN:0:10}... (${#TELEGRAM_TOKEN} chars)" info "Discord token: ${DISCORD_TOKEN:0:10}... (${#DISCORD_TOKEN} chars)" info "Slack bot token: configured (${#SLACK_TOKEN} chars)" info "Slack app token: configured (${#SLACK_APP} chars)" +info "WeChat token: configured (${#WECHAT_TOKEN} chars), account=${WECHAT_ACCOUNT}" info "Sandbox name: $SANDBOX_NAME" # ══════════════════════════════════════════════════════════════════ @@ -383,6 +404,15 @@ else fail "M2: Provider '${SANDBOX_NAME}-discord-bridge' not found in gateway" fi +# M-W1: Verify WeChat provider exists in gateway. Non-interactive onboard +# saw WECHAT_BOT_TOKEN in env (skipping host-qr login) and registered the +# bridge provider just like the other channels. +if openshell provider get "${SANDBOX_NAME}-wechat-bridge" >/dev/null 2>&1; then + pass "M-W1: Provider '${SANDBOX_NAME}-wechat-bridge' exists in gateway" +else + fail "M-W1: Provider '${SANDBOX_NAME}-wechat-bridge' not found in gateway (non-interactive QR-skip path may be broken)" +fi + # ══════════════════════════════════════════════════════════════════ # Phase 2: Credential Isolation — env vars inside sandbox # ══════════════════════════════════════════════════════════════════ @@ -592,6 +622,65 @@ else pass "M-S5g: Slack token rewriter preload absent from NODE_OPTIONS" fi +# ── WeChat credential isolation ─────────────────────────────────── +# Mirrors M5a/M5b/M5c for WeChat. The host-side WECHAT_BOT_TOKEN must +# never appear on any observable surface inside the sandbox — the +# upstream @tencent-weixin/openclaw-weixin plugin reads it via the +# placeholder in /openclaw-weixin/accounts/.json and the +# L7 proxy rewrites at egress. + +# M-W3: WECHAT_BOT_TOKEN inside the sandbox must NOT contain the host token. +sandbox_wechat=$(sandbox_exec "printenv WECHAT_BOT_TOKEN" 2>/dev/null || true) +if [ -z "$sandbox_wechat" ]; then + info "WECHAT_BOT_TOKEN not set inside sandbox (provider-only mode)" + WECHAT_PLACEHOLDER="" +elif echo "$sandbox_wechat" | grep -qF "$WECHAT_TOKEN"; then + fail "M-W3: Real WeChat token leaked into sandbox env" +else + pass "M-W3: Sandbox WECHAT_BOT_TOKEN is a placeholder (not the real token)" + WECHAT_PLACEHOLDER="$sandbox_wechat" + info "WeChat placeholder: ${WECHAT_PLACEHOLDER:0:30}..." +fi + +# M-W3a: Full environment dump must not contain the real WeChat token. +if [ -z "$sandbox_env_all" ]; then + skip "M-W3a: Environment variable list is empty" +elif echo "$sandbox_env_all" | grep -qF "$WECHAT_TOKEN"; then + fail "M-W3a: Real WeChat token found in full sandbox environment dump" +else + pass "M-W3a: Real WeChat token absent from full sandbox environment" +fi + +# M-W3b: Process list must not contain the real WeChat token. +if [ -z "$sandbox_ps" ]; then + skip "M-W3b: Process list is empty" +elif echo "$sandbox_ps" | grep -qF "$WECHAT_TOKEN"; then + fail "M-W3b: Real WeChat token found in sandbox process list" +else + pass "M-W3b: Real WeChat token absent from sandbox process list" +fi + +# M-W3c: Recursive filesystem search for the real WeChat token. The seed +# script writes the placeholder, not the token — a hit here would mean +# something upstream is splicing the real value into account state files. +sandbox_fs_wc=$(printf '%s' "$WECHAT_TOKEN" | sandbox_exec_stdin "grep -rFlm1 -f - /sandbox /home /etc /tmp /var 2>/dev/null || true") +if [ -n "$sandbox_fs_wc" ]; then + fail "M-W3c: Real WeChat token found on sandbox filesystem: ${sandbox_fs_wc}" +else + pass "M-W3c: Real WeChat token absent from sandbox filesystem" +fi + +# M-W3d: WeChat placeholder must be present in the sandbox environment. +if [ -n "$WECHAT_PLACEHOLDER" ]; then + if echo "$sandbox_env_all" | grep -qF "$WECHAT_PLACEHOLDER"; then + pass "M-W3d: WeChat placeholder confirmed present in sandbox environment" + else + fail "M-W3d: WeChat placeholder not found in sandbox environment" + fi +else + skip "M-W3d: No WeChat placeholder to verify (provider-only mode)" +fi + # ══════════════════════════════════════════════════════════════════ # Phase 3: Config Patching — openclaw.json channels # ══════════════════════════════════════════════════════════════════ @@ -781,6 +870,63 @@ print('yes' if 'slack' in d else 'no') else skip "M11e: No Slack channel in config" fi + + # M-W8: WeChat channel registered under channels.openclaw-weixin with the + # configured accountId enabled. Written by seed-wechat-accounts.py during + # image build using NEMOCLAW_WECHAT_CONFIG_B64. Absence here means + # NEMOCLAW_WECHAT_CONFIG_B64 was empty or seed-wechat-accounts.py was + # skipped — both regressions on the non-interactive QR-skip path. + wechat_enabled=$(echo "$channel_json" | python3 -c " +import json, sys +d = json.load(sys.stdin) +accounts = d.get('openclaw-weixin', {}).get('accounts', {}) +account = accounts.get('$WECHAT_ACCOUNT', {}) +print(account.get('enabled', False)) +" 2>/dev/null || true) + if [ "$wechat_enabled" = "True" ]; then + pass "M-W8: WeChat account '$WECHAT_ACCOUNT' is enabled in openclaw.json (channels.openclaw-weixin)" + else + skip "M-W8: WeChat account not enabled in openclaw.json (expected in non-root sandbox or seed-wechat-accounts.py was skipped)" + fi +fi + +# M-W9: Per-account credential file holds the WECHAT_BOT_TOKEN placeholder, +# not the real token. seed-wechat-accounts.py writes +# /openclaw-weixin/accounts/.json with +# token = "openshell:resolve:env:WECHAT_BOT_TOKEN". A real-token hit +# would mean someone bypassed the placeholder constant. +wechat_account_json=$(sandbox_exec "cat /sandbox/.openclaw/openclaw-weixin/accounts/${WECHAT_ACCOUNT}.json 2>/dev/null || true" 2>/dev/null || true) +if [ -z "$wechat_account_json" ] || echo "$wechat_account_json" | grep -qi "no such file"; then + skip "M-W9: WeChat per-account credential file not found (seed-wechat-accounts.py may have been skipped)" +else + if echo "$wechat_account_json" | grep -qF "$WECHAT_TOKEN"; then + fail "M-W9: Real WeChat token spliced into accounts/${WECHAT_ACCOUNT}.json — seed-wechat-accounts.py placeholder regression" + elif echo "$wechat_account_json" | grep -qF "openshell:resolve:env:WECHAT_BOT_TOKEN"; then + pass "M-W9: WeChat per-account credential file uses the L7-resolved placeholder" + else + fail "M-W9: WeChat per-account credential file has unexpected token shape: $(echo "$wechat_account_json" | tr -d '\n' | cut -c1-200)" + fi +fi + +# M-W10: Accounts index lists the configured accountId. Written by +# seed-wechat-accounts.py before the per-account file; the upstream plugin's +# auth/accounts.ts boots accounts that appear in this index. +wechat_index_json=$(sandbox_exec "cat /sandbox/.openclaw/openclaw-weixin/accounts.json 2>/dev/null || true" 2>/dev/null || true) +if [ -z "$wechat_index_json" ] || echo "$wechat_index_json" | grep -qi "no such file"; then + skip "M-W10: WeChat accounts.json index not found" +else + if echo "$wechat_index_json" | python3 -c " +import json, sys +try: + ids = json.load(sys.stdin) + sys.exit(0 if isinstance(ids, list) and '$WECHAT_ACCOUNT' in ids else 1) +except Exception: + sys.exit(2) +" 2>/dev/null; then + pass "M-W10: WeChat accounts.json index contains '$WECHAT_ACCOUNT'" + else + fail "M-W10: WeChat accounts.json missing '$WECHAT_ACCOUNT' (raw: $(echo "$wechat_index_json" | tr -d '\n' | cut -c1-200))" + fi fi # ══════════════════════════════════════════════════════════════════ diff --git a/test/generate-openclaw-config.test.ts b/test/generate-openclaw-config.test.ts index 3f9b791a8b7..611b5709abf 100644 --- a/test/generate-openclaw-config.test.ts +++ b/test/generate-openclaw-config.test.ts @@ -226,6 +226,44 @@ describe("generate-openclaw-config.py: config generation", () => { expect(config.channels.telegram.groups).toBeUndefined(); }); + it("does not write channels.openclaw-weixin from generate-openclaw-config (Dockerfile seed runs separately)", () => { + // Commit a21e123 reverted the chained seed: generate-openclaw-config.py + // intentionally leaves channels.openclaw-weixin unset, even when a + // wechatConfig is provided. The Dockerfile invokes + // seed-wechat-accounts.py separately, AFTER `openclaw plugins install` + // registers the openclaw-weixin channel id. Writing the channel block + // here would trigger "unknown channel id: openclaw-weixin" on install. + const channels = Buffer.from(JSON.stringify(["wechat"])).toString("base64"); + const wechatConfig = Buffer.from( + JSON.stringify({ accountId: "primary", baseUrl: "https://example", userId: "u1" }), + ).toString("base64"); + const config = runConfigScript({ + NEMOCLAW_MESSAGING_CHANNELS_B64: channels, + NEMOCLAW_WECHAT_CONFIG_B64: wechatConfig, + }); + expect(config.channels?.["openclaw-weixin"]).toBeUndefined(); + // The "wechat" alias is the NemoClaw channel name, not an OpenClaw + // channel id — must never appear under channels. + expect(config.channels?.wechat).toBeUndefined(); + }); + + it("omits channels.openclaw-weixin when no accountId was captured", () => { + // No QR-login result → seed step bails on the empty accountId and + // leaves openclaw.json untouched, so the bridge stays dormant. + const channels = Buffer.from(JSON.stringify(["wechat"])).toString("base64"); + const config = runConfigScript({ NEMOCLAW_MESSAGING_CHANNELS_B64: channels }); + expect(config.channels?.["openclaw-weixin"]).toBeUndefined(); + expect(config.channels?.wechat).toBeUndefined(); + }); + + it("enables the openclaw-weixin plugin entry unconditionally", () => { + // The plugin ships in the base image, so we activate the entry on every + // build. With no seeded account, the upstream auth/accounts.ts no-ops + // and the bridge never starts. + const config = runConfigScript({}); + expect(config.plugins?.entries?.["openclaw-weixin"]?.enabled).toBe(true); + }); + it("emits canonical openshell:resolve:env: placeholders for non-Slack channels", () => { const channels = Buffer.from(JSON.stringify(["telegram", "discord"])).toString("base64"); const config = runConfigScript({ NEMOCLAW_MESSAGING_CHANNELS_B64: channels }); diff --git a/test/onboard.test.ts b/test/onboard.test.ts index 3391f473918..30387015a13 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -1212,6 +1212,7 @@ network_policies: {}, null, {}, + {}, true, ); const patched = fs.readFileSync(dockerfilePath, "utf8"); @@ -4156,7 +4157,7 @@ const { setupInference, getSandboxInferenceConfig } = require(${onboardPath}); }); }); - it("prepares managed Model Router dependencies instead of using PATH when managed command is absent", testTimeoutOptions(20_000), () => { + it("prepares managed Model Router dependencies instead of using PATH when managed command is absent", testTimeoutOptions(30_000), () => { const repoRoot = path.join(import.meta.dirname, ".."); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-router-venv-")); const fakeBin = path.join(tmpDir, "bin"); diff --git a/test/policies.test.ts b/test/policies.test.ts index 70e5925f4ec..eaa3fea940c 100644 --- a/test/policies.test.ts +++ b/test/policies.test.ts @@ -9,7 +9,7 @@ import { createRequire } from "node:module"; import type { Interface as ReadlineInterface } from "node:readline"; import { afterEach, describe, it, expect, vi } from "vitest"; import { spawnSync } from "node:child_process"; -import policies from "../dist/lib/policy"; +import * as policies from "../dist/lib/policy"; import { execTimeout } from "./helpers/timeouts"; const requireForTest = createRequire(import.meta.url); @@ -130,9 +130,9 @@ selectFromList(items, options) describe("policies", () => { describe("listPresets", () => { - it("returns all 12 presets", () => { + it("returns all 13 presets", () => { const presets = policies.listPresets(); - expect(presets.length).toBe(12); + expect(presets.length).toBe(13); }); it("each preset has name and description", () => { @@ -160,6 +160,7 @@ describe("policies", () => { "pypi", "slack", "telegram", + "wechat", ]; expect(names).toEqual(expected); }); @@ -240,6 +241,20 @@ describe("policies", () => { expect(hosts).toEqual(["api.telegram.org"]); }); + it("extracts the explicit iLink hosts from wechat preset", () => { + // OpenShell's SSRF engine doesn't expand `*.` wildcards at + // runtime, so the preset lists each known iLink IDC host explicitly. + // Both hosts are load-bearing today — `ilinkai.weixin.qq.com` is the + // bootstrap (hard-coded in src/ext/wechat/qr.ts), `ilinkai.wechat.com` + // is the per-account baseUrl returned after QR confirm. Additional + // IDC hosts may need to be added when operators observe new + // `DENIED ... -> :443` lines in OCSF logs. + const content = requirePresetContent(policies.loadPreset("wechat")); + const hosts = policies.getPresetEndpoints(content); + expect(hosts).toContain("ilinkai.weixin.qq.com"); + expect(hosts).toContain("ilinkai.wechat.com"); + }); + it("every preset has at least one endpoint", () => { for (const p of policies.listPresets()) { const content = requirePresetContent(policies.loadPreset(p.name)); @@ -264,9 +279,10 @@ describe("policies", () => { expect(warning).toContain("nemoclaw onboard"); }); - it("returns a warning for discord and slack", () => { + it("returns a warning for discord, slack, and wechat", () => { expect(policies.getMessagingPresetWarning("discord")).toContain("Discord"); expect(policies.getMessagingPresetWarning("slack")).toContain("Slack"); + expect(policies.getMessagingPresetWarning("wechat")).toContain("WeChat"); }); it("returns null for non-messaging presets", () => { @@ -1082,6 +1098,27 @@ exit 1 expect(content).not.toMatch(/host:\s*api\.telegram\.org[\s\S]*?tls:/); }); + it("wechat REST preset enumerates explicit iLink hosts on port 443 with allow GET/POST", () => { + // OpenShell's SSRF engine doesn't expand `*.` wildcards at + // runtime, so each iLink IDC host the upstream plugin can hit must be + // listed explicitly. The proxy must still see + // protocol/enforcement/method allowlists on each entry — dropping any + // of those silently widens egress past what the preset documents. + const content = requirePresetContent(policies.loadPreset("wechat")); + for (const host of ["ilinkai\\.weixin\\.qq\\.com", "ilinkai\\.wechat\\.com"]) { + expect(content).toMatch( + new RegExp( + `host:\\s*"?${host}"?[\\s\\S]*?port:\\s*443[\\s\\S]*?protocol:\\s*rest[\\s\\S]*?enforcement:\\s*enforce`, + ), + ); + expect(content).toMatch( + new RegExp( + `host:\\s*"?${host}"?[\\s\\S]*?allow:\\s*\\{\\s*method:\\s*GET[\\s\\S]*?allow:\\s*\\{\\s*method:\\s*POST`, + ), + ); + } + }); + it("pypi preset allows HEAD for pip lazy-wheel metadata checks", () => { // pip and uv use HEAD requests for lazy wheel downloads and // range-request support. GET-only would break pip install. @@ -1403,6 +1440,16 @@ selectForRemoval(items, options) expect(result.stdout).toMatch(/re-run 'nemoclaw onboard' and select Telegram/); }); + it("warns the user that the wechat preset alone does not enable WeChat messaging", () => { + const result = runPolicyAdd("y", [], {}, "wechat"); + + expect(result.status).toBe(0); + expect(result.stdout).toMatch( + /Note: the 'wechat' preset only opens network egress to the WeChat API\./, + ); + expect(result.stdout).toMatch(/re-run 'nemoclaw onboard' and select WeChat/); + }); + it("does not warn about messaging when a non-messaging preset is selected", () => { const result = runPolicyAdd("y"); diff --git a/test/policy-tiers.test.ts b/test/policy-tiers.test.ts index dd9ce82eb6b..de2508bb8c8 100644 --- a/test/policy-tiers.test.ts +++ b/test/policy-tiers.test.ts @@ -138,11 +138,12 @@ describe("tiers", () => { } }); - it("does not include messaging presets (slack, discord, telegram)", () => { + it("does not include messaging presets (slack, discord, telegram, wechat)", () => { 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"); }); }); @@ -159,11 +160,12 @@ describe("tiers", () => { } }); - it("includes messaging presets (slack, discord, telegram)", () => { + it("includes messaging presets (slack, discord, telegram, wechat)", () => { 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"); }); it("includes productivity presets (jira, outlook)", () => { diff --git a/test/sandbox-build-context.test.ts b/test/sandbox-build-context.test.ts index c2c0929d0cf..1e3e1d200a4 100644 --- a/test/sandbox-build-context.test.ts +++ b/test/sandbox-build-context.test.ts @@ -56,6 +56,7 @@ describe("sandbox build context staging", () => { expect(fs.existsSync(path.join(buildCtx, "scripts", "generate-openclaw-config.py"))).toBe( true, ); + expect(fs.existsSync(path.join(buildCtx, "scripts", "seed-wechat-accounts.py"))).toBe(true); expect(fs.existsSync(path.join(buildCtx, "scripts", "lib", "sandbox-init.sh"))).toBe(true); expect(fs.existsSync(path.join(buildCtx, "scripts", "setup.sh"))).toBe(false); } finally { diff --git a/test/sandbox-provisioning.test.ts b/test/sandbox-provisioning.test.ts index 0d2ced975dc..2668f96a065 100644 --- a/test/sandbox-provisioning.test.ts +++ b/test/sandbox-provisioning.test.ts @@ -267,6 +267,8 @@ describe("sandbox provisioning: copied OpenClaw helper permissions (#2861)", () path.join(localBin, "nemoclaw-codex-acp"), path.join(localLib, "sandbox-init.sh"), path.join(localLib, "generate-openclaw-config.py"), + path.join(localLib, "seed-wechat-accounts.py"), + path.join(localLib, "ws-proxy-fix.js"), pluginFile, nestedPluginFile, ]; diff --git a/test/security-sandbox-tar-traversal.test.ts b/test/security-sandbox-tar-traversal.test.ts index 7d3586aef49..50cada5e0ec 100644 --- a/test/security-sandbox-tar-traversal.test.ts +++ b/test/security-sandbox-tar-traversal.test.ts @@ -438,6 +438,83 @@ describe("Fix: safeTarExtract blocks malicious archives and extracts safe ones", } }); + it("allows whitelisted npm symlinks baked into base image (extensions/openclaw-weixin/node_modules/openclaw)", async () => { + const { safeTarExtract } = await loadSandboxState(); + const workDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-audit-whitelist-extract-")); + try { + const targetDir = path.join(workDir, "backup"); + fs.mkdirSync(targetDir, { recursive: true }); + + // The WeChat plugin install symlinks `node_modules/openclaw` to the + // global npm install. Target escapes both the archive and /sandbox/, + // so it would be rejected without the whitelist. + const tar = buildTar([ + { + path: "extensions/openclaw-weixin/node_modules/openclaw", + type: "2", + linkTarget: "/usr/local/lib/node_modules/openclaw", + }, + ]); + + const result = safeTarExtract(tar, targetDir); + expect(result.success).toBe(true); + } finally { + fs.rmSync(workDir, { recursive: true, force: true }); + } + }); + + it("rejects whitelisted source path when the symlink target is tampered", async () => { + // The path matches AUDIT_SYMLINK_WHITELIST, but the linkTarget points to + // /etc/passwd instead of the expected /usr/local/lib/node_modules/openclaw. + // Source-only matching would let a compromised sandbox repoint a known npm + // symlink at arbitrary host paths; the post-extraction audit must compare + // both fields. + const { safeTarExtract } = await loadSandboxState(); + const workDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-audit-target-tampered-")); + try { + const targetDir = path.join(workDir, "backup"); + fs.mkdirSync(targetDir, { recursive: true }); + + const tar = buildTar([ + { + path: "extensions/openclaw-weixin/node_modules/openclaw", + type: "2", + linkTarget: "/etc/passwd", + }, + ]); + + const result = safeTarExtract(tar, targetDir); + expect(result.success).toBe(false); + expect(result.error).toContain("symlink"); + } finally { + fs.rmSync(workDir, { recursive: true, force: true }); + } + }); + + it("still rejects an absolute /usr/local symlink at a non-whitelisted path", async () => { + const { safeTarExtract } = await loadSandboxState(); + const workDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-audit-whitelist-block-")); + try { + const targetDir = path.join(workDir, "backup"); + fs.mkdirSync(targetDir, { recursive: true }); + + // Same target, but the symlink path is NOT in the whitelist. + const tar = buildTar([ + { + path: "workspace/sneaky-openclaw", + type: "2", + linkTarget: "/usr/local/lib/node_modules/openclaw", + }, + ]); + + const result = safeTarExtract(tar, targetDir); + expect(result.success).toBe(false); + expect(result.error).toContain("symlink"); + } finally { + fs.rmSync(workDir, { recursive: true, force: true }); + } + }); + it("regression #2317: blocks path traversal within allowed prefix (/sandbox/.openclaw-data/../../etc/passwd)", async () => { const { safeTarExtract } = await loadSandboxState(); const workDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-2317-traversal-")); diff --git a/test/seed-wechat-accounts.test.ts b/test/seed-wechat-accounts.test.ts new file mode 100644 index 00000000000..7c48a6849f1 --- /dev/null +++ b/test/seed-wechat-accounts.test.ts @@ -0,0 +1,321 @@ +// @ts-nocheck +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Functional tests for scripts/seed-wechat-accounts.py. +// Runs the actual Python script with controlled env vars + a temp HOME and +// asserts on the on-disk state it leaves behind. Mirrors the spawn-and-read +// pattern from generate-openclaw-config.test.ts. + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; + +const SCRIPT_PATH = path.join(import.meta.dirname, "..", "scripts", "seed-wechat-accounts.py"); + +const PLACEHOLDER = "openshell:resolve:env:WECHAT_BOT_TOKEN"; + +let tmpDir: string; + +function configB64(payload: Record): string { + return Buffer.from(JSON.stringify(payload)).toString("base64"); +} + +function channelsB64(channels: string[]): string { + return Buffer.from(JSON.stringify(channels)).toString("base64"); +} + +function runSeed(envOverrides: Record = {}) { + const env: Record = { + PATH: process.env.PATH || "/usr/bin:/bin", + HOME: tmpDir, + // Default to wechat-in-active-channels so existing tests exercise the + // openclaw.json-patching path. Tests that simulate `channels stop wechat` + // override this with `channelsB64([])` (or any list excluding wechat). + NEMOCLAW_MESSAGING_CHANNELS_B64: channelsB64(["wechat"]), + ...envOverrides, + }; + return spawnSync("python3", [SCRIPT_PATH], { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + env, + timeout: 10_000, + }); +} + +function writeOpenclawConfig(extra: Record = {}) { + const cfgDir = path.join(tmpDir, ".openclaw"); + fs.mkdirSync(cfgDir, { recursive: true }); + const cfgPath = path.join(cfgDir, "openclaw.json"); + const baseCfg = { gateway: { port: 1 }, channels: {}, ...extra }; + fs.writeFileSync(cfgPath, JSON.stringify(baseCfg, null, 2) + "\n"); + return cfgPath; +} + +function readJson(p: string): any { + return JSON.parse(fs.readFileSync(p, "utf-8")); +} + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-seed-wechat-test-")); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe("seed-wechat-accounts.py: gating", () => { + it("no-ops silently when NEMOCLAW_WECHAT_CONFIG_B64 is unset", () => { + // The script now runs unconditionally from generate-openclaw-config.py + // on every build, so the "no host-side QR login was performed" path is + // the common case and must stay quiet — no stderr noise, no on-disk + // state under the plugin state dir. + const result = runSeed(); + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + const pluginDir = path.join(tmpDir, ".openclaw", "openclaw-weixin"); + expect(fs.existsSync(pluginDir)).toBe(false); + }); + + it("no-ops silently when accountId is missing from the config payload", () => { + // baseUrl + userId without accountId would leave the upstream plugin + // unable to pick a filename. Bail without writing — quietly, since this + // is reachable in non-WeChat onboards too. + const result = runSeed({ + NEMOCLAW_WECHAT_CONFIG_B64: configB64({ baseUrl: "https://x", userId: "u" }), + }); + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + const pluginDir = path.join(tmpDir, ".openclaw", "openclaw-weixin"); + expect(fs.existsSync(pluginDir)).toBe(false); + }); +}); + +describe("seed-wechat-accounts.py: per-account state files", () => { + it("writes accounts.json index and per-account file with placeholder token", () => { + writeOpenclawConfig(); + const result = runSeed({ + NEMOCLAW_WECHAT_CONFIG_B64: configB64({ + accountId: "primary", + baseUrl: "https://ilinkai.wechat.com", + userId: "user-42", + }), + }); + expect(result.status).toBe(0); + + const pluginDir = path.join(tmpDir, ".openclaw", "openclaw-weixin"); + const index = readJson(path.join(pluginDir, "accounts.json")); + expect(index).toEqual(["primary"]); + + const account = readJson(path.join(pluginDir, "accounts", "primary.json")); + expect(account.token).toBe(PLACEHOLDER); + expect(account.baseUrl).toBe("https://ilinkai.wechat.com"); + expect(account.userId).toBe("user-42"); + // savedAt must be a parseable ISO timestamp (the upstream plugin reads it). + expect(Number.isNaN(Date.parse(account.savedAt))).toBe(false); + }); + + it("omits baseUrl and userId when they are absent in the config", () => { + writeOpenclawConfig(); + const result = runSeed({ + NEMOCLAW_WECHAT_CONFIG_B64: configB64({ accountId: "primary" }), + }); + expect(result.status).toBe(0); + + const account = readJson( + path.join(tmpDir, ".openclaw", "openclaw-weixin", "accounts", "primary.json"), + ); + expect(account.token).toBe(PLACEHOLDER); + expect("baseUrl" in account).toBe(false); + expect("userId" in account).toBe(false); + }); + + it("appends to an existing accounts.json instead of overwriting", () => { + // Append-only invariant: a prior seed (or upstream-plugin save) must not + // be clobbered when a second accountId is registered. + writeOpenclawConfig(); + const pluginDir = path.join(tmpDir, ".openclaw", "openclaw-weixin"); + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync(path.join(pluginDir, "accounts.json"), JSON.stringify(["old"]) + "\n"); + + const result = runSeed({ + NEMOCLAW_WECHAT_CONFIG_B64: configB64({ accountId: "new-one" }), + }); + expect(result.status).toBe(0); + + const index = readJson(path.join(pluginDir, "accounts.json")); + expect(index).toEqual(["old", "new-one"]); + }); + + it("does not duplicate an accountId already present in the index", () => { + writeOpenclawConfig(); + const pluginDir = path.join(tmpDir, ".openclaw", "openclaw-weixin"); + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync(path.join(pluginDir, "accounts.json"), JSON.stringify(["primary"]) + "\n"); + + const result = runSeed({ + NEMOCLAW_WECHAT_CONFIG_B64: configB64({ accountId: "primary" }), + }); + expect(result.status).toBe(0); + + const index = readJson(path.join(pluginDir, "accounts.json")); + expect(index).toEqual(["primary"]); + }); + + it("respects OPENCLAW_STATE_DIR as the state-dir override", () => { + const altState = path.join(tmpDir, "alt-state"); + fs.mkdirSync(altState, { recursive: true }); + fs.writeFileSync( + path.join(altState, "openclaw.json"), + JSON.stringify({ channels: {} }, null, 2) + "\n", + ); + + const result = runSeed({ + NEMOCLAW_WECHAT_CONFIG_B64: configB64({ accountId: "primary" }), + OPENCLAW_STATE_DIR: altState, + }); + expect(result.status).toBe(0); + + expect(fs.existsSync(path.join(altState, "openclaw-weixin", "accounts.json"))).toBe(true); + expect(fs.existsSync(path.join(tmpDir, ".openclaw", "openclaw-weixin"))).toBe(false); + }); +}); + +describe("seed-wechat-accounts.py: openclaw.json patching (channels.openclaw-weixin)", () => { + it("registers channels.openclaw-weixin.accounts..enabled=true", () => { + // Without enabled=true the upstream plugin's auth/accounts.ts treats the + // account as disabled and the bridge no-ops. This is the load-bearing + // bit of the post-install patch. + writeOpenclawConfig(); + const result = runSeed({ + NEMOCLAW_WECHAT_CONFIG_B64: configB64({ accountId: "primary" }), + }); + expect(result.status).toBe(0); + + const cfg = readJson(path.join(tmpDir, ".openclaw", "openclaw.json")); + expect(cfg.channels["openclaw-weixin"].accounts.primary.enabled).toBe(true); + }); + + it("writes a channelConfigUpdatedAt in JS Date.toISOString() shape (ms + 'Z')", () => { + // The upstream plugin compares this string with values it produces via + // Date.toISOString(). A Python isoformat() with offset would diverge. + writeOpenclawConfig(); + runSeed({ + NEMOCLAW_WECHAT_CONFIG_B64: configB64({ accountId: "primary" }), + }); + + const cfg = readJson(path.join(tmpDir, ".openclaw", "openclaw.json")); + const updatedAt = cfg.channels["openclaw-weixin"].channelConfigUpdatedAt; + expect(updatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); + }); + + it("preserves existing unrelated keys in openclaw.json", () => { + // The patch must merge into the existing config — clobbering gateway or + // other channels would break everything else generate-openclaw-config.py + // wrote moments earlier. + writeOpenclawConfig({ + gateway: { port: 9999, marker: "keep-me" }, + channels: { telegram: { accounts: { default: { enabled: true } } } }, + }); + runSeed({ + NEMOCLAW_WECHAT_CONFIG_B64: configB64({ accountId: "primary" }), + }); + + const cfg = readJson(path.join(tmpDir, ".openclaw", "openclaw.json")); + expect(cfg.gateway).toEqual({ port: 9999, marker: "keep-me" }); + expect(cfg.channels.telegram.accounts.default.enabled).toBe(true); + expect(cfg.channels["openclaw-weixin"].accounts.primary.enabled).toBe(true); + }); + + it("bails (and warns) when openclaw.json is missing — does not invent a config", () => { + // generate-openclaw-config.py runs first and is responsible for producing + // openclaw.json. If it failed silently, we'd rather print a warning than + // create a half-formed file from this script's narrow vantage point. + const result = runSeed({ + NEMOCLAW_WECHAT_CONFIG_B64: configB64({ accountId: "primary" }), + }); + expect(result.status).toBe(0); + expect(result.stderr).toContain("not found; cannot register channel"); + expect(fs.existsSync(path.join(tmpDir, ".openclaw", "openclaw.json"))).toBe(false); + + // Per-account state files must still have been written (they sit in the + // plugin's own state dir, not openclaw.json). + const pluginDir = path.join(tmpDir, ".openclaw", "openclaw-weixin"); + expect(fs.existsSync(path.join(pluginDir, "accounts.json"))).toBe(true); + }); + + it("survives a corrupted openclaw.json without crashing", () => { + const cfgPath = path.join(tmpDir, ".openclaw", "openclaw.json"); + fs.mkdirSync(path.dirname(cfgPath), { recursive: true }); + fs.writeFileSync(cfgPath, "{not valid json"); + const result = runSeed({ + NEMOCLAW_WECHAT_CONFIG_B64: configB64({ accountId: "primary" }), + }); + expect(result.status).toBe(0); + expect(result.stderr).toContain("could not parse"); + // Original (broken) file is left intact for a human to inspect. + expect(fs.readFileSync(cfgPath, "utf-8")).toBe("{not valid json"); + }); +}); + +describe("seed-wechat-accounts.py: stopped-channel preservation", () => { + // When NEMOCLAW_MESSAGING_CHANNELS_B64 omits wechat (operator ran + // `channels stop wechat` before rebuild) we still want the per-account + // state files on disk so a later `channels start wechat` rebuild can + // revive the bridge without a fresh QR scan. The openclaw.json patch is + // what we suppress — without channels.openclaw-weixin.accounts..enabled + // the upstream plugin treats the account as inactive and the bridge + // no-ops, even though the placeholder token + baseUrl/userId are present + // in the accounts file. + + it("writes account state files but skips openclaw.json patch when wechat is not in active channels", () => { + writeOpenclawConfig({ gateway: { port: 7777 } }); + const result = runSeed({ + NEMOCLAW_MESSAGING_CHANNELS_B64: channelsB64(["telegram"]), + NEMOCLAW_WECHAT_CONFIG_B64: configB64({ + accountId: "primary", + baseUrl: "https://ilinkai.wechat.com", + userId: "wxid-42", + }), + }); + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toContain("wechat not in active channels"); + + // Per-account files survive — ready for the next `channels start`. + const account = readJson( + path.join(tmpDir, ".openclaw", "openclaw-weixin", "accounts", "primary.json"), + ); + expect(account.token).toBe(PLACEHOLDER); + expect(account.baseUrl).toBe("https://ilinkai.wechat.com"); + expect(account.userId).toBe("wxid-42"); + const index = readJson(path.join(tmpDir, ".openclaw", "openclaw-weixin", "accounts.json")); + expect(index).toEqual(["primary"]); + + // openclaw.json must not have the channel block, but the unrelated + // gateway key the test seeded earlier must survive untouched. + const cfg = readJson(path.join(tmpDir, ".openclaw", "openclaw.json")); + expect(cfg.channels?.["openclaw-weixin"]).toBeUndefined(); + expect(cfg.gateway).toEqual({ port: 7777 }); + }); + + it("treats an empty channel list as 'wechat stopped'", () => { + // Defensive: a malformed/empty NEMOCLAW_MESSAGING_CHANNELS_B64 must + // not silently re-enable wechat. Account state still gets written for + // recovery, the channel block does not. + writeOpenclawConfig(); + const result = runSeed({ + NEMOCLAW_MESSAGING_CHANNELS_B64: channelsB64([]), + NEMOCLAW_WECHAT_CONFIG_B64: configB64({ accountId: "primary" }), + }); + expect(result.status).toBe(0); + + expect( + fs.existsSync(path.join(tmpDir, ".openclaw", "openclaw-weixin", "accounts", "primary.json")), + ).toBe(true); + const cfg = readJson(path.join(tmpDir, ".openclaw", "openclaw.json")); + expect(cfg.channels?.["openclaw-weixin"]).toBeUndefined(); + }); +}); diff --git a/test/snapshot.test.ts b/test/snapshot.test.ts index f98b0514126..f4c2ec8b8c0 100644 --- a/test/snapshot.test.ts +++ b/test/snapshot.test.ts @@ -577,6 +577,177 @@ process.exit(0); } }); + it("accepts whitelisted npm symlinks under extensions/ during pre-backup audit", () => { + const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-audit-whitelist-")); + const oldPath = process.env.PATH; + const oldOpenshell = process.env.NEMOCLAW_OPENSHELL_BIN; + try { + const binDir = path.join(fixture, "bin"); + const openclawDir = path.join(fixture, "sandbox-root", ".openclaw"); + const existingDirs = ["agents", "extensions", "workspace"]; + fs.mkdirSync(binDir, { recursive: true }); + for (const d of existingDirs) fs.mkdirSync(path.join(openclawDir, d), { recursive: true }); + + const auditLines = [ + "l\t/sandbox/.openclaw/extensions/openclaw-weixin/node_modules/.bin/qrcode-terminal\t../qrcode-terminal/bin/qrcode-terminal.js", + "l\t/sandbox/.openclaw/extensions/openclaw-weixin/node_modules/openclaw\t/usr/local/lib/node_modules/openclaw", + ].join("\n"); + + const openshell = writeFakeOpenshell(binDir); + writeExecutable( + path.join(binDir, "ssh"), + `#!/usr/bin/env node +const { spawnSync } = require("node:child_process"); +const fs = require("node:fs"); +const cmd = process.argv[process.argv.length - 1] || ""; +const existingDirs = ${JSON.stringify(existingDirs)}; +if (cmd.includes("[ -d ")) { + process.stdout.write(existingDirs.join("\\n") + "\\n"); + process.exit(0); +} +if (cmd.includes("find ")) { + process.stdout.write(${JSON.stringify(auditLines)} + "\\n"); + process.exit(0); +} +if (cmd.includes("tar -cf -")) { + const r = spawnSync("tar", ["-cf", "-", "-C", ${JSON.stringify(openclawDir)}, ...existingDirs], { + stdio: ["ignore", "pipe", "pipe"], + }); + if (r.stdout) fs.writeSync(1, r.stdout); + process.exit(r.status || 0); +} +process.exit(0); +`, + ); + + writeOpenClawRegistry("alpha"); + process.env.NEMOCLAW_OPENSHELL_BIN = openshell; + process.env.PATH = `${binDir}${path.delimiter}${oldPath || ""}`; + + const backup = sandboxState.backupSandboxState("alpha"); + expect(backup.success).toBe(true); + expect(backup.backedUpDirs).toEqual(existingDirs); + expect(backup.error).toBeUndefined(); + } finally { + if (oldOpenshell === undefined) { + delete process.env.NEMOCLAW_OPENSHELL_BIN; + } else { + process.env.NEMOCLAW_OPENSHELL_BIN = oldOpenshell; + } + process.env.PATH = oldPath; + fs.rmSync(fixture, { recursive: true, force: true }); + } + }); + + it("still rejects non-whitelisted symlinks alongside whitelisted ones", () => { + const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-audit-mixed-")); + const oldPath = process.env.PATH; + const oldOpenshell = process.env.NEMOCLAW_OPENSHELL_BIN; + try { + const binDir = path.join(fixture, "bin"); + const openclawDir = path.join(fixture, "sandbox-root", ".openclaw"); + const existingDirs = ["extensions", "workspace"]; + fs.mkdirSync(binDir, { recursive: true }); + for (const d of existingDirs) fs.mkdirSync(path.join(openclawDir, d), { recursive: true }); + + const auditLines = [ + "l\t/sandbox/.openclaw/extensions/openclaw-weixin/node_modules/openclaw\t/usr/local/lib/node_modules/openclaw", + "l\t/sandbox/.openclaw/workspace/leak\t/etc/passwd", + ].join("\n"); + + const openshell = writeFakeOpenshell(binDir); + writeExecutable( + path.join(binDir, "ssh"), + `#!/usr/bin/env node +const cmd = process.argv[process.argv.length - 1] || ""; +const existingDirs = ${JSON.stringify(existingDirs)}; +if (cmd.includes("[ -d ")) { + process.stdout.write(existingDirs.join("\\n") + "\\n"); + process.exit(0); +} +if (cmd.includes("find ")) { + process.stdout.write(${JSON.stringify(auditLines)} + "\\n"); + process.exit(0); +} +process.exit(0); +`, + ); + + writeOpenClawRegistry("alpha"); + process.env.NEMOCLAW_OPENSHELL_BIN = openshell; + process.env.PATH = `${binDir}${path.delimiter}${oldPath || ""}`; + + const backup = sandboxState.backupSandboxState("alpha"); + expect(backup.success).toBe(false); + expect(backup.error).toMatch(/workspace\/leak/); + expect(backup.error).not.toMatch(/openclaw-weixin/); + } finally { + if (oldOpenshell === undefined) { + delete process.env.NEMOCLAW_OPENSHELL_BIN; + } else { + process.env.NEMOCLAW_OPENSHELL_BIN = oldOpenshell; + } + process.env.PATH = oldPath; + fs.rmSync(fixture, { recursive: true, force: true }); + } + }); + + it("rejects whitelisted-path symlinks with a tampered target", () => { + // Source path matches the whitelist, but linkTarget points to /etc/passwd + // instead of the expected /usr/local/lib/node_modules/openclaw. The audit + // must compare both fields and reject — source-only matching would let a + // compromised agent repoint these symlinks at arbitrary host paths. + const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-audit-target-tampered-")); + const oldPath = process.env.PATH; + const oldOpenshell = process.env.NEMOCLAW_OPENSHELL_BIN; + try { + const binDir = path.join(fixture, "bin"); + const openclawDir = path.join(fixture, "sandbox-root", ".openclaw"); + const existingDirs = ["extensions"]; + fs.mkdirSync(binDir, { recursive: true }); + for (const d of existingDirs) fs.mkdirSync(path.join(openclawDir, d), { recursive: true }); + + const auditLines = [ + "l\t/sandbox/.openclaw/extensions/openclaw-weixin/node_modules/openclaw\t/etc/passwd", + ].join("\n"); + + const openshell = writeFakeOpenshell(binDir); + writeExecutable( + path.join(binDir, "ssh"), + `#!/usr/bin/env node +const cmd = process.argv[process.argv.length - 1] || ""; +const existingDirs = ${JSON.stringify(existingDirs)}; +if (cmd.includes("[ -d ")) { + process.stdout.write(existingDirs.join("\\n") + "\\n"); + process.exit(0); +} +if (cmd.includes("find ")) { + process.stdout.write(${JSON.stringify(auditLines)} + "\\n"); + process.exit(0); +} +process.exit(0); +`, + ); + + writeOpenClawRegistry("alpha"); + process.env.NEMOCLAW_OPENSHELL_BIN = openshell; + process.env.PATH = `${binDir}${path.delimiter}${oldPath || ""}`; + + const backup = sandboxState.backupSandboxState("alpha"); + expect(backup.success).toBe(false); + expect(backup.error).toMatch(/openclaw-weixin/); + expect(backup.error).toMatch(/\/etc\/passwd/); + } finally { + if (oldOpenshell === undefined) { + delete process.env.NEMOCLAW_OPENSHELL_BIN; + } else { + process.env.NEMOCLAW_OPENSHELL_BIN = oldOpenshell; + } + process.env.PATH = oldPath; + fs.rmSync(fixture, { recursive: true, force: true }); + } + }); + it("marks non-attributed directories failed when they are missing from partial extraction", () => { const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-missing-partial-")); const oldPath = process.env.PATH; diff --git a/test/wechat-diagnostics.test.ts b/test/wechat-diagnostics.test.ts new file mode 100644 index 00000000000..630d4541963 --- /dev/null +++ b/test/wechat-diagnostics.test.ts @@ -0,0 +1,385 @@ +// @ts-nocheck +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Unit tests for nemoclaw-blueprint/scripts/wechat-diagnostics.js. +// +// The script is a self-contained IIFE that mutates process.stderr.write, +// http.request, http.get, https.request, and https.get globally on require — +// so each test runs in an isolated child Node process. The harness writes a +// small driver script per case that requires the diagnostics module, drives +// it (HTTP request, stderr write, etc.), and emits structured JSON we can +// assert on. + +import { describe, it, expect } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; + +const DIAGNOSTICS_PATH = path.join( + import.meta.dirname, + "..", + "nemoclaw-blueprint", + "scripts", + "wechat-diagnostics.js", +); + +function runDriver(driverBody: string, env: Record = {}) { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-wechat-diag-")); + const driverPath = path.join(tmpDir, "driver.js"); + fs.writeFileSync(driverPath, driverBody); + try { + return spawnSync(process.execPath, [driverPath], { + encoding: "utf-8", + env: { + PATH: process.env.PATH || "/usr/bin:/bin", + DIAGNOSTICS_PATH, + ...env, + }, + timeout: 5_000, + }); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +} + +describe("wechat-diagnostics: install gating", () => { + it("is idempotent — requiring twice does not double-wrap process.stderr.write", () => { + // The module guards on process.__nemoclawWechatDiagnosticsInstalled so a + // second require is a no-op. Without the guard each preload of the + // sandbox boot script (gateway + agent + bridge) would chain-wrap stderr. + const driver = ` + const before = process.stderr.write; + require(process.env.DIAGNOSTICS_PATH); + const afterFirst = process.stderr.write; + require(process.env.DIAGNOSTICS_PATH); + const afterSecond = process.stderr.write; + // First require must replace stderr.write; second must leave it alone. + console.log(JSON.stringify({ + firstReplaced: before !== afterFirst, + secondReplaced: afterFirst !== afterSecond, + flagSet: process.__nemoclawWechatDiagnosticsInstalled === true, + })); + `; + const result = runDriver(driver); + expect(result.status).toBe(0); + const out = JSON.parse(result.stdout.trim()); + expect(out.firstReplaced).toBe(true); + expect(out.secondReplaced).toBe(false); + expect(out.flagSet).toBe(true); + }); +}); + +describe("wechat-diagnostics: provider-ready signal", () => { + it("emits [wechat] provider ready once iLink answers a 2xx on /ilink/bot", async () => { + // The diagnostics module wraps http.request and listens for the response + // event. It only emits "provider ready" when (a) the host matches + // *.weixin.qq.com, (b) the path starts with /ilink/bot, and (c) the + // status is 2xx — the conjunction is what makes the signal reliable. + const driver = ` + const http = require('http'); + const server = http.createServer((req, res) => { + if (req.url.startsWith('/ilink/bot')) { + res.writeHead(200); + res.end('ok'); + } else { + res.writeHead(404); + res.end(); + } + }); + server.listen(0, '127.0.0.1', () => { + const port = server.address().port; + // Bypass DNS for the fake WeChat hostnames by overriding + // createConnection — every request goes to the in-process server + // regardless of the hostname set on opts (which is what the + // diagnostics module reads to decide whether to log). + const net = require('net'); + const createConnection = () => net.connect(port, '127.0.0.1'); + // Hostname matching is by suffix on .weixin.qq.com — we get there by + // setting the Host header but connecting to localhost. The wrapper + // reads opts.hostname/host directly, so we pass it that way. + require(process.env.DIAGNOSTICS_PATH); + const req = http.request({ + hostname: 'ilink-42.weixin.qq.com', + port, + path: '/ilink/bot/cgi-bin/getme', + method: 'GET', + createConnection, + }, (res) => { + res.resume(); + res.on('end', () => server.close()); + }); + req.end(); + }); + `; + const result = runDriver(driver, { WECHAT_ACCOUNT_ID: "ilink-bot-42" }); + expect(result.status).toBe(0); + expect(result.stderr).toContain("[wechat] [ilink-bot-42] provider ready"); + }); + + it("does NOT emit provider ready when path is outside /ilink/bot", async () => { + const driver = ` + const http = require('http'); + const server = http.createServer((req, res) => { res.writeHead(200); res.end('ok'); }); + server.listen(0, '127.0.0.1', () => { + const port = server.address().port; + // Bypass DNS for the fake WeChat hostnames by overriding + // createConnection — every request goes to the in-process server + // regardless of the hostname set on opts (which is what the + // diagnostics module reads to decide whether to log). + const net = require('net'); + const createConnection = () => net.connect(port, '127.0.0.1'); + require(process.env.DIAGNOSTICS_PATH); + const req = http.request({ + hostname: 'foo.weixin.qq.com', + port, + path: '/some/other/api', + createConnection, + }, (res) => { + res.resume(); + res.on('end', () => server.close()); + }); + req.end(); + }); + `; + const result = runDriver(driver); + expect(result.status).toBe(0); + expect(result.stderr).not.toContain("provider ready"); + }); + + it("does NOT emit provider ready for non-WeChat hosts even on /ilink/bot", async () => { + // Defense in depth: a path collision on an unrelated host shouldn't + // produce a false positive. + const driver = ` + const http = require('http'); + const server = http.createServer((req, res) => { res.writeHead(200); res.end('ok'); }); + server.listen(0, '127.0.0.1', () => { + const port = server.address().port; + // Bypass DNS for the fake WeChat hostnames by overriding + // createConnection — every request goes to the in-process server + // regardless of the hostname set on opts (which is what the + // diagnostics module reads to decide whether to log). + const net = require('net'); + const createConnection = () => net.connect(port, '127.0.0.1'); + require(process.env.DIAGNOSTICS_PATH); + const req = http.request({ + hostname: 'evil.example.com', + port, + path: '/ilink/bot/cgi-bin/x', + createConnection, + }, (res) => { + res.resume(); + res.on('end', () => server.close()); + }); + req.end(); + }); + `; + const result = runDriver(driver); + expect(result.status).toBe(0); + expect(result.stderr).not.toContain("provider ready"); + }); + + it("does NOT emit provider ready on a 4xx response", async () => { + const driver = ` + const http = require('http'); + const server = http.createServer((req, res) => { res.writeHead(403); res.end('forbidden'); }); + server.listen(0, '127.0.0.1', () => { + const port = server.address().port; + // Bypass DNS for the fake WeChat hostnames by overriding + // createConnection — every request goes to the in-process server + // regardless of the hostname set on opts (which is what the + // diagnostics module reads to decide whether to log). + const net = require('net'); + const createConnection = () => net.connect(port, '127.0.0.1'); + require(process.env.DIAGNOSTICS_PATH); + const req = http.request({ + hostname: 'a.weixin.qq.com', + port, + path: '/ilink/bot/cgi-bin/getme', + createConnection, + }, (res) => { + res.resume(); + res.on('end', () => server.close()); + }); + req.end(); + }); + `; + const result = runDriver(driver); + expect(result.status).toBe(0); + expect(result.stderr).not.toContain("provider ready"); + }); + + it("only emits provider ready once even if multiple matching responses arrive", async () => { + // readyLogged guards against repeat emission so operators get one clean + // "provider ready" line, not a per-request stream. + const driver = ` + const http = require('http'); + const server = http.createServer((req, res) => { res.writeHead(200); res.end('ok'); }); + server.listen(0, '127.0.0.1', () => { + const port = server.address().port; + // Bypass DNS for the fake WeChat hostnames by overriding + // createConnection — every request goes to the in-process server + // regardless of the hostname set on opts (which is what the + // diagnostics module reads to decide whether to log). + const net = require('net'); + const createConnection = () => net.connect(port, '127.0.0.1'); + require(process.env.DIAGNOSTICS_PATH); + let pending = 3; + for (let i = 0; i < 3; i++) { + const req = http.request({ + hostname: 'a.weixin.qq.com', + port, + path: '/ilink/bot/cgi-bin/x' + i, + createConnection, + }, (res) => { + res.resume(); + res.on('end', () => { if (--pending === 0) server.close(); }); + }); + req.end(); + } + }); + `; + const result = runDriver(driver); + expect(result.status).toBe(0); + const matches = result.stderr.match(/provider ready/g) || []; + expect(matches.length).toBe(1); + }); + + it("uses 'default' as account id when WECHAT_ACCOUNT_ID is unset", async () => { + const driver = ` + const http = require('http'); + const server = http.createServer((req, res) => { res.writeHead(200); res.end('ok'); }); + server.listen(0, '127.0.0.1', () => { + const port = server.address().port; + // Bypass DNS for the fake WeChat hostnames by overriding + // createConnection — every request goes to the in-process server + // regardless of the hostname set on opts (which is what the + // diagnostics module reads to decide whether to log). + const net = require('net'); + const createConnection = () => net.connect(port, '127.0.0.1'); + delete process.env.WECHAT_ACCOUNT_ID; + require(process.env.DIAGNOSTICS_PATH); + const req = http.request({ + hostname: 'a.weixin.qq.com', + port, + path: '/ilink/bot/cgi-bin/x', + createConnection, + }, (res) => { + res.resume(); + res.on('end', () => server.close()); + }); + req.end(); + }); + `; + const result = runDriver(driver); + expect(result.status).toBe(0); + expect(result.stderr).toContain("[wechat] [default] provider ready"); + }); + + it("uses 'default' when WECHAT_ACCOUNT_ID is whitespace-only", async () => { + const driver = ` + const http = require('http'); + const server = http.createServer((req, res) => { res.writeHead(200); res.end('ok'); }); + server.listen(0, '127.0.0.1', () => { + const port = server.address().port; + // Bypass DNS for the fake WeChat hostnames by overriding + // createConnection — every request goes to the in-process server + // regardless of the hostname set on opts (which is what the + // diagnostics module reads to decide whether to log). + const net = require('net'); + const createConnection = () => net.connect(port, '127.0.0.1'); + require(process.env.DIAGNOSTICS_PATH); + const req = http.request({ + hostname: 'a.weixin.qq.com', + port, + path: '/ilink/bot/cgi-bin/x', + createConnection, + }, (res) => { + res.resume(); + res.on('end', () => server.close()); + }); + req.end(); + }); + `; + const result = runDriver(driver, { WECHAT_ACCOUNT_ID: " " }); + expect(result.status).toBe(0); + expect(result.stderr).toContain("[wechat] [default] provider ready"); + }); +}); + +describe("wechat-diagnostics: inference-error annotation", () => { + it("redacts bot_token query params and 'token: ...' patterns in emitted error lines", () => { + // This is the core safety property: the diagnostics line is a free-form + // string built from whatever the agent process logged, which means it + // can contain credential-shaped substrings. The sanitize() pass MUST + // strip them before re-emitting. + const driver = ` + require(process.env.DIAGNOSTICS_PATH); + // Trigger the providerStarted=true path via the regex on stderr.write. + process.stderr.write('[wechat] [primary] starting provider\\n'); + // Now emit an inference error containing both a URL token and a JSON + // token shape. + process.stderr.write( + 'LLM request failed: GET https://ilink.weixin.qq.com/api?bot_token=secret-abc-123&user=x\\n' + + ' body: {"bot_token":"hunter2","data":{}}\\n' + ); + `; + const result = runDriver(driver, { WECHAT_ACCOUNT_ID: "primary" }); + expect(result.status).toBe(0); + // Original line passes through stderr (the wrapper calls original first), + // but the diagnostic-emitted annotation must be redacted. + const annotation = result.stderr + .split(/\r?\n/) + .find((line) => line.includes("agent turn failed after provider startup")); + expect(annotation).toBeTruthy(); + expect(annotation).toContain("bot_token="); + expect(annotation).not.toContain("secret-abc-123"); + expect(annotation).not.toContain("hunter2"); + }); + + it("does not annotate when an LLM error precedes any 'starting provider' marker", () => { + // Rationale: if the bridge never started, the failure is "channel never + // came up", which other diagnostics already cover. The annotation is + // specifically for the "channel up, inference broken" delta. + const driver = ` + require(process.env.DIAGNOSTICS_PATH); + process.stderr.write('LLM request failed: timeout\\n'); + `; + const result = runDriver(driver); + expect(result.status).toBe(0); + expect(result.stderr).not.toContain("agent turn failed after provider startup"); + }); + + it("emits the annotation only once across multiple inference errors", () => { + const driver = ` + require(process.env.DIAGNOSTICS_PATH); + process.stderr.write('[wechat] [primary] starting provider\\n'); + process.stderr.write('LLM request failed: first\\n'); + process.stderr.write('LLM request failed: second\\n'); + process.stderr.write('FailoverError: third\\n'); + `; + const result = runDriver(driver, { WECHAT_ACCOUNT_ID: "primary" }); + expect(result.status).toBe(0); + const matches = result.stderr.match(/agent turn failed after provider startup/g) || []; + expect(matches.length).toBe(1); + }); + + it("truncates the annotated error line to 600 chars to keep stderr readable", () => { + const driver = ` + require(process.env.DIAGNOSTICS_PATH); + process.stderr.write('[wechat] [p] starting provider\\n'); + process.stderr.write('LLM request failed: ' + 'A'.repeat(2000) + '\\n'); + `; + const result = runDriver(driver); + expect(result.status).toBe(0); + const annotation = result.stderr + .split(/\r?\n/) + .find((line) => line.includes("agent turn failed after provider startup")); + expect(annotation).toBeTruthy(); + // Slice happens after 'inference error: ' prefix; the captured tail + // (600 chars max) should be far shorter than the 2000 'A's we emitted. + const tail = annotation.split("inference error: ")[1] ?? ""; + expect(tail.length).toBeLessThanOrEqual(600); + }); +});