diff --git a/.github/workflows/e2e-script.yaml b/.github/workflows/e2e-script.yaml index db07747be0e..028cb8f2748 100644 --- a/.github/workflows/e2e-script.yaml +++ b/.github/workflows/e2e-script.yaml @@ -62,6 +62,20 @@ on: required: false BRAVE_API_KEY: required: false + TELEGRAM_BOT_TOKEN_REAL: + required: false + TELEGRAM_CHAT_ID_E2E: + required: false + DISCORD_BOT_TOKEN_REAL: + required: false + DISCORD_CHANNEL_ID_E2E: + required: false + SLACK_BOT_TOKEN_REAL: + required: false + SLACK_APP_TOKEN_REAL: + required: false + SLACK_CHANNEL_ID_E2E: + required: false permissions: contents: read @@ -157,3 +171,10 @@ jobs: BRAVE_API_KEY: ${{ inputs.brave_api_key && secrets.BRAVE_API_KEY || '' }} GITHUB_TOKEN: ${{ inputs.github_token && github.token || '' }} NVIDIA_API_KEY: ${{ inputs.nvidia_api_key && secrets.NVIDIA_API_KEY || '' }} + TELEGRAM_BOT_TOKEN_REAL: ${{ secrets.TELEGRAM_BOT_TOKEN_REAL }} + TELEGRAM_CHAT_ID_E2E: ${{ secrets.TELEGRAM_CHAT_ID_E2E }} + DISCORD_BOT_TOKEN_REAL: ${{ secrets.DISCORD_BOT_TOKEN_REAL }} + DISCORD_CHANNEL_ID_E2E: ${{ secrets.DISCORD_CHANNEL_ID_E2E }} + SLACK_BOT_TOKEN_REAL: ${{ secrets.SLACK_BOT_TOKEN_REAL }} + SLACK_APP_TOKEN_REAL: ${{ secrets.SLACK_APP_TOKEN_REAL }} + SLACK_CHANNEL_ID_E2E: ${{ secrets.SLACK_CHANNEL_ID_E2E }} diff --git a/.github/workflows/nightly-e2e.yaml b/.github/workflows/nightly-e2e.yaml index efca3afc5da..40dc74fc8a1 100644 --- a/.github/workflows/nightly-e2e.yaml +++ b/.github/workflows/nightly-e2e.yaml @@ -274,6 +274,13 @@ jobs: secrets: NVIDIA_API_KEY: ${{ secrets.NVIDIA_API_KEY }} BRAVE_API_KEY: ${{ secrets.BRAVE_API_KEY }} + TELEGRAM_BOT_TOKEN_REAL: ${{ secrets.TELEGRAM_BOT_TOKEN_REAL }} + TELEGRAM_CHAT_ID_E2E: ${{ secrets.TELEGRAM_CHAT_ID_E2E }} + DISCORD_BOT_TOKEN_REAL: ${{ secrets.DISCORD_BOT_TOKEN_REAL }} + DISCORD_CHANNEL_ID_E2E: ${{ secrets.DISCORD_CHANNEL_ID_E2E }} + SLACK_BOT_TOKEN_REAL: ${{ secrets.SLACK_BOT_TOKEN_REAL }} + SLACK_APP_TOKEN_REAL: ${{ secrets.SLACK_APP_TOKEN_REAL }} + SLACK_CHANNEL_ID_E2E: ${{ secrets.SLACK_CHANNEL_ID_E2E }} openclaw-slack-pairing-e2e: if: >- github.repository == 'NVIDIA/NemoClaw' && (github.event_name != 'workflow_dispatch' || diff --git a/Dockerfile b/Dockerfile index 1bd9054ea15..5cdd539b4fb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -395,11 +395,13 @@ 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/openclaw-build-messaging-plugins.py /usr/local/lib/nemoclaw/openclaw-build-messaging-plugins.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/openclaw-build-messaging-plugins.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 \ @@ -543,7 +545,7 @@ USER sandbox RUN NEMOCLAW_OPENCLAW_MANAGED_PROXY=0 python3 /usr/local/lib/nemoclaw/generate-openclaw-config.py # hadolint ignore=DL3059,DL4006 -RUN openclaw doctor --fix --non-interactive +RUN python3 /usr/local/lib/nemoclaw/openclaw-build-messaging-plugins.py # Lock down npm: no further registry traffic in this image. Everything past # this point must resolve from local sources only. diff --git a/scripts/generate-openclaw-config.py b/scripts/generate-openclaw-config.py index fdef5e53d8b..6c56b5eeaa1 100755 --- a/scripts/generate-openclaw-config.py +++ b/scripts/generate-openclaw-config.py @@ -566,11 +566,9 @@ def _placeholder(channel: str, env_key: str) -> str: for ch in msg_channels: if ch == "whatsapp": _ch_cfg[ch] = { + "enabled": True, "accounts": { - "default": { - "enabled": True, - "healthMonitor": {"enabled": False}, - } + "default": {"enabled": True, "healthMonitor": {"enabled": False}} } } continue @@ -585,7 +583,6 @@ def _placeholder(channel: str, env_key: str) -> str: account["appToken"] = _placeholder(ch, "SLACK_APP_TOKEN") if ch == "telegram": account["proxy"] = proxy_url - if ch == "telegram": account["groupPolicy"] = "open" if ch in _allowed_ids and _allowed_ids[ch]: account["dmPolicy"] = "allowlist" @@ -611,7 +608,7 @@ def _placeholder(channel: str, env_key: str) -> str: channel_id: dict(slack_channel_config) for channel_id in _slack_allowed_channels } - _ch_cfg[ch] = {**({"enabled": True} if ch == "slack" else {}), "accounts": {"default": account}} + _ch_cfg[ch] = {"enabled": True, "accounts": {"default": account}} # WeChat (openclaw-weixin) is NOT added to channels.* here in build # contexts where the plugin has not been installed yet — writing it upfront @@ -716,7 +713,9 @@ def _placeholder(channel: str, env_key: str) -> str: # registered an accountId under channels.openclaw-weixin.accounts. "openclaw-weixin": {"enabled": True}, } - plugin_entries.update({"slack": {"enabled": True}} if "slack" in _ch_cfg else {}) + plugin_entries.update( + {ch: {"enabled": True} for ch in ("discord", "slack", "telegram", "whatsapp") if ch in _ch_cfg} + ) _bundled_provider_plugins = { "amazon-bedrock": {"amazon-bedrock", "bedrock"}, "amazon-bedrock-mantle": {"amazon-bedrock-mantle"}, diff --git a/scripts/openclaw-build-messaging-plugins.py b/scripts/openclaw-build-messaging-plugins.py new file mode 100755 index 00000000000..73dfd7ba38c --- /dev/null +++ b/scripts/openclaw-build-messaging-plugins.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Install OpenClaw messaging plugins that match the bundled OpenClaw version. + +OpenClaw's doctor repair uses the official catalog's unversioned plugin specs. +That can drift to a newer external messaging plugin than the host OpenClaw +runtime. NemoClaw pins the runtime with OPENCLAW_VERSION, so build-time channel +activation must pin external messaging plugins to that same version. +""" + +from __future__ import annotations + +import argparse +import base64 +import json +import os +import subprocess +import sys +from typing import Iterable + + +DEFAULT_CHANNELS_B64 = "W10=" + +EXTERNAL_CHANNEL_PACKAGES = { + "discord": "@openclaw/discord", + "slack": "@openclaw/slack", + "whatsapp": "@openclaw/whatsapp", +} + +DOCTOR_ENV_BY_CHANNEL = { + "telegram": { + "TELEGRAM_BOT_TOKEN": "openshell:resolve:env:TELEGRAM_BOT_TOKEN", + }, + "discord": { + "DISCORD_BOT_TOKEN": "openshell:resolve:env:DISCORD_BOT_TOKEN", + }, + "slack": { + "SLACK_BOT_TOKEN": "xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN", + "SLACK_APP_TOKEN": "xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN", + }, +} + + +class BuildMessagingPluginError(RuntimeError): + """Raised for configuration errors that should fail the image build.""" + + +def decode_channels(raw: str) -> list[str]: + try: + decoded = base64.b64decode(raw, validate=True) + parsed = json.loads(decoded.decode("utf-8")) + except Exception as exc: # noqa: BLE001 - keep the build error actionable. + raise BuildMessagingPluginError( + "NEMOCLAW_MESSAGING_CHANNELS_B64 must be base64-encoded JSON array" + ) from exc + + if not isinstance(parsed, list): + raise BuildMessagingPluginError( + "NEMOCLAW_MESSAGING_CHANNELS_B64 must decode to a JSON array" + ) + + channels: list[str] = [] + seen: set[str] = set() + for item in parsed: + if not isinstance(item, str): + raise BuildMessagingPluginError( + "NEMOCLAW_MESSAGING_CHANNELS_B64 may contain only string channel names" + ) + channel = item.strip().lower() + if not channel or channel in seen: + continue + seen.add(channel) + channels.append(channel) + return channels + + +def require_openclaw_version(channels: Iterable[str], env: dict[str, str]) -> str: + needs_external_install = any(channel in EXTERNAL_CHANNEL_PACKAGES for channel in channels) + version = (env.get("OPENCLAW_VERSION") or "").strip() + if needs_external_install and not version: + raise BuildMessagingPluginError( + "OPENCLAW_VERSION is required when external messaging channels are enabled" + ) + return version + + +def plugin_specs(channels: Iterable[str], openclaw_version: str) -> list[str]: + specs: list[str] = [] + for channel in channels: + package_name = EXTERNAL_CHANNEL_PACKAGES.get(channel) + if package_name: + specs.append(f"{package_name}@{openclaw_version}") + return specs + + +def doctor_env_overrides(channels: Iterable[str]) -> dict[str, str]: + overrides: dict[str, str] = {} + for channel in channels: + overrides.update(DOCTOR_ENV_BY_CHANNEL.get(channel, {})) + return overrides + + +def run_command(args: list[str], *, env: dict[str, str] | None = None) -> None: + print("+ " + " ".join(args), flush=True) + subprocess.run(args, check=True, env=env) + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "--dry-run", + action="store_true", + help="Print the derived plugin specs and doctor env overrides as JSON.", + ) + args = parser.parse_args(argv) + + raw_channels = os.environ.get("NEMOCLAW_MESSAGING_CHANNELS_B64", DEFAULT_CHANNELS_B64) + channels = decode_channels(raw_channels or DEFAULT_CHANNELS_B64) + openclaw_version = require_openclaw_version(channels, os.environ) + specs = plugin_specs(channels, openclaw_version) + env_overrides = doctor_env_overrides(channels) + + if args.dry_run: + print( + json.dumps( + { + "channels": channels, + "doctorEnv": env_overrides, + "installSpecs": specs, + "openclawVersion": openclaw_version, + }, + indent=2, + sort_keys=True, + ) + ) + return 0 + + for spec in specs: + run_command(["openclaw", "plugins", "install", spec]) + + doctor_env = os.environ.copy() + doctor_env.update(env_overrides) + run_command(["openclaw", "doctor", "--fix", "--non-interactive"], env=doctor_env) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main(sys.argv[1:])) + except BuildMessagingPluginError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + raise SystemExit(2) diff --git a/src/lib/sandbox/build-context.ts b/src/lib/sandbox/build-context.ts index a19ac70e03b..17e0febf8e4 100644 --- a/src/lib/sandbox/build-context.ts +++ b/src/lib/sandbox/build-context.ts @@ -133,6 +133,10 @@ function stageOptimizedSandboxBuildContext( path.join(rootDir, "scripts", "generate-openclaw-config.py"), path.join(stagedScriptsDir, "generate-openclaw-config.py"), ); + fs.copyFileSync( + path.join(rootDir, "scripts", "openclaw-build-messaging-plugins.py"), + path.join(stagedScriptsDir, "openclaw-build-messaging-plugins.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. diff --git a/test/e2e-script-workflow.test.ts b/test/e2e-script-workflow.test.ts index cacf36d1db5..d937caa9c41 100644 --- a/test/e2e-script-workflow.test.ts +++ b/test/e2e-script-workflow.test.ts @@ -32,13 +32,27 @@ describe("E2E reusable workflow contract", () => { it("passes only named secrets to reusable nightly jobs", () => { const reusableJobs = reusableNightlyJobs(nightlyWorkflow); + const defaultSecrets = { + NVIDIA_API_KEY: "${{ secrets.NVIDIA_API_KEY }}", + BRAVE_API_KEY: "${{ secrets.BRAVE_API_KEY }}", + }; + const messagingLiveSecrets = { + TELEGRAM_BOT_TOKEN_REAL: "${{ secrets.TELEGRAM_BOT_TOKEN_REAL }}", + TELEGRAM_CHAT_ID_E2E: "${{ secrets.TELEGRAM_CHAT_ID_E2E }}", + DISCORD_BOT_TOKEN_REAL: "${{ secrets.DISCORD_BOT_TOKEN_REAL }}", + DISCORD_CHANNEL_ID_E2E: "${{ secrets.DISCORD_CHANNEL_ID_E2E }}", + SLACK_BOT_TOKEN_REAL: "${{ secrets.SLACK_BOT_TOKEN_REAL }}", + SLACK_APP_TOKEN_REAL: "${{ secrets.SLACK_APP_TOKEN_REAL }}", + SLACK_CHANNEL_ID_E2E: "${{ secrets.SLACK_CHANNEL_ID_E2E }}", + }; expect(reusableJobs.length).toBeGreaterThan(20); for (const [name, job] of reusableJobs) { - expect(job.secrets, name).toEqual({ - NVIDIA_API_KEY: "${{ secrets.NVIDIA_API_KEY }}", - BRAVE_API_KEY: "${{ secrets.BRAVE_API_KEY }}", - }); + const expectedSecrets = + name === "messaging-providers-e2e" + ? { ...defaultSecrets, ...messagingLiveSecrets } + : defaultSecrets; + expect(job.secrets, name).toEqual(expectedSecrets); } }); diff --git a/test/e2e/lib/discord-rest-policy-proof.sh b/test/e2e/lib/discord-rest-policy-proof.sh index bd66147584a..6a1527faee7 100755 --- a/test/e2e/lib/discord-rest-policy-proof.sh +++ b/test/e2e/lib/discord-rest-policy-proof.sh @@ -20,6 +20,15 @@ cleanup_fake_discord_rest_api() { fi } +cleanup_fake_discord_message_api() { + if [ -n "${FAKE_DISCORD_MESSAGE_API_CONTAINER:-}" ]; then + docker rm -f "$FAKE_DISCORD_MESSAGE_API_CONTAINER" >/dev/null 2>&1 || true + fi + if [ -n "${FAKE_DISCORD_MESSAGE_API_DIR:-}" ]; then + rm -rf "$FAKE_DISCORD_MESSAGE_API_DIR" 2>/dev/null || true + fi +} + start_fake_discord_rest_api() { if ! command -v openssl >/dev/null 2>&1; then echo "openssl is required for fake Discord REST TLS cert generation" >&2 @@ -87,6 +96,54 @@ start_fake_discord_rest_api() { return 1 } +start_fake_discord_message_api() { + local token="$1" + mkdir -p "$REPO/.tmp" + FAKE_DISCORD_MESSAGE_API_DIR="$(mktemp -d "$REPO/.tmp/fake-discord-message.XXXXXX")" + FAKE_DISCORD_MESSAGE_API_PORT_FILE="$FAKE_DISCORD_MESSAGE_API_DIR/port" + FAKE_DISCORD_MESSAGE_API_CAPTURE_FILE="$FAKE_DISCORD_MESSAGE_API_DIR/capture.jsonl" + FAKE_DISCORD_MESSAGE_API_CONTAINER="nemoclaw-fake-discord-message-$$-$RANDOM" + FAKE_DISCORD_MESSAGE_API_HOST="host.docker.internal" + : >"$FAKE_DISCORD_MESSAGE_API_CAPTURE_FILE" + + if ! docker run -d --rm \ + --name "$FAKE_DISCORD_MESSAGE_API_CONTAINER" \ + -p 0:8080 \ + -e FAKE_DISCORD_MESSAGE_API_PORT=8080 \ + -e FAKE_DISCORD_MESSAGE_API_EXPECTED_TOKEN="$token" \ + -e FAKE_DISCORD_MESSAGE_API_PORT_FILE=/tmp/fake-discord-message/port \ + -e FAKE_DISCORD_MESSAGE_API_CAPTURE_FILE=/tmp/fake-discord-message/capture.jsonl \ + -v "$FAKE_DISCORD_MESSAGE_API_DIR:/tmp/fake-discord-message" \ + -v "$REPO/test/e2e/lib:/opt/nemoclaw-e2e:ro" \ + node:22-bookworm-slim \ + node /opt/nemoclaw-e2e/fake-discord-message-api.cjs \ + >"$FAKE_DISCORD_MESSAGE_API_DIR/container.id" 2>"$FAKE_DISCORD_MESSAGE_API_DIR/server.log"; then + cat "$FAKE_DISCORD_MESSAGE_API_DIR/server.log" >&2 || true + return 1 + fi + append_exit_trap_for_fake_discord_rest_api cleanup_fake_discord_message_api + + for _ in $(seq 1 50); do + if [ -s "$FAKE_DISCORD_MESSAGE_API_PORT_FILE" ]; then + local published_port + published_port="$(docker port "$FAKE_DISCORD_MESSAGE_API_CONTAINER" 8080/tcp 2>/dev/null | head -1 | sed 's/.*://')" + if [ -n "$published_port" ]; then + export FAKE_DISCORD_MESSAGE_API_PORT + FAKE_DISCORD_MESSAGE_API_PORT="$published_port" + return 0 + fi + fi + if ! docker inspect "$FAKE_DISCORD_MESSAGE_API_CONTAINER" >/dev/null 2>&1; then + docker logs "$FAKE_DISCORD_MESSAGE_API_CONTAINER" >&2 || true + cat "$FAKE_DISCORD_MESSAGE_API_DIR/server.log" >&2 || true + return 1 + fi + sleep 0.1 + done + cat "$FAKE_DISCORD_MESSAGE_API_DIR/server.log" >&2 || true + return 1 +} + apply_fake_discord_rest_policy() { local sandbox_name="$1" local port="$2" @@ -125,6 +182,250 @@ EOF_POLICY } } +fake_discord_message_api_allowed_ip_options() { + printf '%s' 'allowed-ip=10.0.0.0/8,allowed-ip=172.16.0.0/12,allowed-ip=192.168.0.0/16' +} + +apply_fake_discord_message_api_policy() { + local sandbox_name="$1" + local port="$2" + local host="${FAKE_DISCORD_MESSAGE_API_HOST:-host.openshell.internal}" + local allowed_ip_options + allowed_ip_options="$(fake_discord_message_api_allowed_ip_options)" + openshell policy update "$sandbox_name" \ + --add-endpoint "${host}:${port}:read-write:rest:enforce:request-body-credential-rewrite,${allowed_ip_options}" \ + --add-allow "${host}:${port}:GET:/**" \ + --add-allow "${host}:${port}:POST:/**" \ + --binary /usr/local/bin/node \ + --binary /usr/bin/node \ + --wait +} + +run_fake_discord_plugin_send_proof() { + local port="$1" + local channel_id="$2" + local message="$3" + local host="${FAKE_DISCORD_MESSAGE_API_HOST:-host.openshell.internal}" + local message_b64 + message_b64=$(printf '%s' "$message" | base64 | tr -d '\n') + + sandbox_exec_stdin "FAKE_DISCORD_MESSAGE_API_HOST='$host' FAKE_DISCORD_MESSAGE_API_PORT='$port' FAKE_DISCORD_MESSAGE_CHANNEL_ID='$channel_id' FAKE_DISCORD_MESSAGE_TEXT_B64='$message_b64' node --preserve-symlinks --input-type=module - 2>&1" <<'NODE' +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import http from "node:http"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +function fail(message) { + console.error(message); + process.exit(1); +} + +function decodeBase64(value) { + return Buffer.from(value || "", "base64").toString("utf8"); +} + +function addPathWalk(candidates, seen, start) { + if (!start) return; + let current = path.resolve(start); + for (let depth = 0; depth < 8; depth += 1) { + if (!seen.has(current)) { + seen.add(current); + candidates.push(current); + } + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } +} + +function resolveDiscordSendApiPath() { + const require = createRequire(import.meta.url); + const candidates = []; + const seen = new Set(); + const add = (candidate) => { + if (candidate && !seen.has(candidate)) { + seen.add(candidate); + candidates.push(candidate); + } + }; + + for (const base of [process.cwd(), "/sandbox", "/usr/local/lib/node_modules", "/tmp/npm-global/lib/node_modules"]) { + try { + add(path.join(path.dirname(require.resolve("@openclaw/discord/package.json", { paths: [base] })), "dist/runtime-api.send.js")); + } catch {} + try { + add(path.join(path.dirname(require.resolve("openclaw/package.json", { paths: [base] })), "dist/extensions/discord/runtime-api.send.js")); + } catch {} + } + + try { + const globalRoot = execFileSync("npm", ["root", "-g"], { encoding: "utf8" }).trim(); + add(path.join(globalRoot, "@openclaw/discord/dist/runtime-api.send.js")); + add(path.join(globalRoot, "openclaw/dist/extensions/discord/runtime-api.send.js")); + } catch {} + + try { + const openclawBin = execFileSync("sh", ["-lc", "command -v openclaw || true"], { encoding: "utf8" }).trim(); + if (openclawBin) { + const realBin = execFileSync("readlink", ["-f", openclawBin], { encoding: "utf8" }).trim(); + const walk = []; + const walkSeen = new Set(); + addPathWalk(walk, walkSeen, path.dirname(realBin)); + for (const root of walk) { + add(path.join(root, "node_modules/@openclaw/discord/dist/runtime-api.send.js")); + add(path.join(root, "dist/extensions/discord/runtime-api.send.js")); + } + } + } catch {} + + try { + const searchRoots = ["/usr/local", "/tmp/npm-global", "/sandbox"].filter((root) => fs.existsSync(root)); + if (searchRoots.length) { + const discovered = execFileSync("find", [ + ...searchRoots, + "(", + "-path", + "*/node_modules/@openclaw/discord/dist/runtime-api.send.js", + "-o", + "-path", + "*/node_modules/openclaw/dist/extensions/discord/runtime-api.send.js", + ")", + "-print", + "-quit", + ], { encoding: "utf8" }).trim(); + add(discovered); + } + } catch {} + + for (const candidate of candidates) { + if (candidate && fs.existsSync(candidate)) return candidate; + } + return null; +} + +function requestFakeDiscord(method, apiPath, body, token) { + const payload = body === undefined ? "" : JSON.stringify(body.body ?? body); + const options = { + hostname: process.env.FAKE_DISCORD_MESSAGE_API_HOST || "host.openshell.internal", + port: Number(process.env.FAKE_DISCORD_MESSAGE_API_PORT), + path: `/api/v10${apiPath}`, + method, + headers: { + Authorization: `Bot ${token}`, + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(payload), + "User-Agent": "nemoclaw-openclaw-discord-plugin-e2e", + }, + }; + return new Promise((resolve, reject) => { + const req = http.request(options, (res) => { + let responseBody = ""; + res.on("data", (chunk) => { + responseBody += chunk; + }); + res.on("end", () => { + let parsed = {}; + try { + parsed = responseBody ? JSON.parse(responseBody) : {}; + } catch (error) { + reject(new Error(`invalid JSON from fake Discord: ${error.message}: ${responseBody}`)); + return; + } + if (res.statusCode < 200 || res.statusCode >= 300) { + const err = new Error(`fake Discord returned HTTP ${res.statusCode}`); + err.status = res.statusCode; + err.rawError = parsed; + reject(err); + return; + } + resolve(parsed); + }); + }); + req.on("error", reject); + req.setTimeout(30000, () => { + req.destroy(new Error("fake Discord message API timed out")); + }); + if (payload) req.write(payload); + req.end(); + }); +} + +const sendApiPath = resolveDiscordSendApiPath(); +if (!sendApiPath) fail("could not find installed OpenClaw Discord runtime-api.send.js"); + +const { sendMessageDiscord } = await import(pathToFileURL(sendApiPath).href); +if (typeof sendMessageDiscord !== "function") fail("installed Discord runtime API does not export sendMessageDiscord"); + +const cfg = JSON.parse(fs.readFileSync("/sandbox/.openclaw/openclaw.json", "utf8")); +const account = cfg.channels?.discord?.accounts?.default; +if (!account?.token) fail("missing channels.discord.accounts.default.token in openclaw.json"); + +const channelId = process.env.FAKE_DISCORD_MESSAGE_CHANNEL_ID || "420000000000000123"; +const text = decodeBase64(process.env.FAKE_DISCORD_MESSAGE_TEXT_B64); +const token = account.token; +const rest = { + get: (apiPath) => requestFakeDiscord("GET", apiPath, undefined, token), + post: (apiPath, data) => requestFakeDiscord("POST", apiPath, data, token), + patch: (apiPath, data) => requestFakeDiscord("PATCH", apiPath, data, token), + put: (apiPath, data) => requestFakeDiscord("PUT", apiPath, data, token), + delete: (apiPath, data) => requestFakeDiscord("DELETE", apiPath, data, token), +}; + +const result = await sendMessageDiscord(`channel:${channelId}`, text, { + cfg, + accountId: "default", + rest, +}); + +console.log(JSON.stringify({ + ok: true, + proof: "openclaw-discord-runtime-send", + channelId: result.channelId ?? channelId, + messageId: result.messageId ?? result.platformMessageIds?.[0] ?? null, +})); +NODE +} + +check_fake_discord_message_capture() { + local expected_channel="$1" + local expected_text="$2" + node - "$FAKE_DISCORD_MESSAGE_API_CAPTURE_FILE" "$expected_channel" "$expected_text" <<'NODE' +const fs = require("fs"); +const [file, expectedChannel, expectedText] = process.argv.slice(2); +const rows = fs + .readFileSync(file, "utf8") + .trim() + .split(/\n+/) + .filter(Boolean) + .map((line) => JSON.parse(line)) + .filter((row) => row.event === "request" && row.method === "POST" && row.path.endsWith("/messages")); +const last = rows.at(-1); +if (!last) { + console.log("NO_MESSAGE_REQUEST"); + process.exit(2); +} +if (last.tokenMatchesExpected !== true) { + console.log("BAD_TOKEN_REWRITE"); + process.exit(3); +} +if (last.tokenLooksPlaceholder) { + console.log("PLACEHOLDER_LEAK"); + process.exit(4); +} +if (last.channelId !== expectedChannel) { + console.log(`BAD_CHANNEL ${last.channelId}`); + process.exit(5); +} +if (last.content !== expectedText) { + console.log(`BAD_TEXT ${last.content}`); + process.exit(6); +} +console.log("OK"); +NODE +} + run_fake_discord_rest_node_request() { local port="$1" local path="$2" diff --git a/test/e2e/lib/fake-discord-message-api.cjs b/test/e2e/lib/fake-discord-message-api.cjs new file mode 100755 index 00000000000..a99475e48fa --- /dev/null +++ b/test/e2e/lib/fake-discord-message-api.cjs @@ -0,0 +1,157 @@ +#!/usr/bin/env node +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +"use strict"; + +const fs = require("fs"); +const http = require("http"); + +const host = process.env.FAKE_DISCORD_MESSAGE_API_HOST || "0.0.0.0"; +const rawPort = process.env.FAKE_DISCORD_MESSAGE_API_PORT || "0"; +const port = Number(rawPort); +const portFile = process.env.FAKE_DISCORD_MESSAGE_API_PORT_FILE || ""; +const captureFile = process.env.FAKE_DISCORD_MESSAGE_API_CAPTURE_FILE || ""; +const expectedToken = process.env.FAKE_DISCORD_MESSAGE_API_EXPECTED_TOKEN || ""; +const MAX_BODY_BYTES = 1024 * 1024; + +if (!Number.isInteger(port) || port < 0 || port > 65535) { + console.error(`FAKE_DISCORD_MESSAGE_API_PORT must be an integer between 0 and 65535 (received: ${rawPort})`); + process.exit(2); +} + +if (!expectedToken) { + console.error("FAKE_DISCORD_MESSAGE_API_EXPECTED_TOKEN is required"); + process.exit(2); +} + +function record(event) { + if (!captureFile) return; + fs.appendFileSync(captureFile, `${JSON.stringify({ at: Date.now(), ...event })}\n`); +} + +function tokenFromAuthorization(value) { + const raw = String(value || ""); + if (raw.length < 4 || raw.slice(0, 3).toLowerCase() !== "bot") return raw; + const next = raw.charCodeAt(3); + if (next !== 0x20 && next !== 0x09) return raw; + let index = 4; + while (index < raw.length) { + const code = raw.charCodeAt(index); + if (code !== 0x20 && code !== 0x09) break; + index += 1; + } + return raw.slice(index); +} + +function tokenLooksPlaceholder(value) { + return typeof value === "string" && value.includes("openshell:resolve:env:"); +} + +function writeJson(res, status, body) { + res.writeHead(status, { "content-type": "application/json" }); + res.end(JSON.stringify(body)); +} + +function parseJson(body) { + try { + return JSON.parse(body || "{}"); + } catch { + return {}; + } +} + +const server = http.createServer((req, res) => { + const chunks = []; + let bodyBytes = 0; + let bodyTooLarge = false; + req.on("data", (chunk) => { + if (bodyTooLarge) return; + bodyBytes += chunk.length; + if (bodyBytes > MAX_BODY_BYTES) { + bodyTooLarge = true; + record({ event: "request-too-large", method: req.method, path: req.url || "/", bodyBytes }); + writeJson(res, 413, { message: "payload too large", code: 413 }); + req.destroy(); + return; + } + chunks.push(chunk); + }); + + req.on("end", () => { + if (bodyTooLarge) return; + const body = Buffer.concat(chunks).toString("utf8"); + const url = new URL(req.url || "/", "http://fake-discord.local"); + const token = tokenFromAuthorization(req.headers.authorization); + const tokenMatchesExpected = token === expectedToken; + const messageMatch = /^\/api\/v10\/channels\/([^/]+)\/messages$/.exec(url.pathname); + const channelMatch = /^\/api\/v10\/channels\/([^/]+)$/.exec(url.pathname); + const parsed = parseJson(body); + const content = typeof parsed.content === "string" ? parsed.content : ""; + + record({ + event: "request", + method: req.method, + path: url.pathname, + tokenMatchesExpected, + tokenLooksPlaceholder: tokenLooksPlaceholder(token), + authorizationPresent: Boolean(req.headers.authorization), + authorizationRedacted: true, + bodyRedacted: true, + channelId: messageMatch?.[1] || channelMatch?.[1] || "", + content, + contentLength: content.length, + }); + + if (!tokenMatchesExpected) { + writeJson(res, 401, { message: "401: Unauthorized", code: 0 }); + return; + } + + if (req.method === "GET" && channelMatch) { + writeJson(res, 200, { + id: channelMatch[1], + type: 0, + name: "nemoclaw-e2e", + }); + return; + } + + if (req.method === "POST" && messageMatch) { + writeJson(res, 200, { + id: "420000000000000001", + channel_id: messageMatch[1], + content, + timestamp: new Date().toISOString(), + author: { + id: "420000000000000000", + username: "NemoClaw E2E", + bot: true, + }, + }); + return; + } + + writeJson(res, 404, { message: "Unknown Endpoint", code: 10001 }); + }); +}); + +server.on("error", (error) => { + record({ event: "server_error", error: error.message }); + console.error(error.stack || error.message); +}); + +server.listen(port, host, () => { + const address = server.address(); + if (portFile) { + fs.writeFileSync(portFile, `${address.port}\n`, { mode: 0o600 }); + } + record({ event: "listening", host, port: address.port }); +}); + +for (const signal of ["SIGTERM", "SIGINT"]) { + process.on(signal, () => { + server.close(() => process.exit(0)); + setTimeout(() => process.exit(0), 1000).unref(); + }); +} diff --git a/test/e2e/lib/fake-telegram-api.cjs b/test/e2e/lib/fake-telegram-api.cjs new file mode 100755 index 00000000000..022756010af --- /dev/null +++ b/test/e2e/lib/fake-telegram-api.cjs @@ -0,0 +1,156 @@ +#!/usr/bin/env node +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +"use strict"; + +const fs = require("fs"); +const http = require("http"); + +const host = process.env.FAKE_TELEGRAM_API_HOST || "0.0.0.0"; +const rawPort = process.env.FAKE_TELEGRAM_API_PORT || "0"; +const port = Number(rawPort); +const portFile = process.env.FAKE_TELEGRAM_API_PORT_FILE || ""; +const captureFile = process.env.FAKE_TELEGRAM_API_CAPTURE_FILE || ""; +const expectedToken = process.env.FAKE_TELEGRAM_API_EXPECTED_TOKEN || ""; +const MAX_BODY_BYTES = 1024 * 1024; + +if (!Number.isInteger(port) || port < 0 || port > 65535) { + console.error(`FAKE_TELEGRAM_API_PORT must be an integer between 0 and 65535 (received: ${rawPort})`); + process.exit(2); +} + +if (!expectedToken) { + console.error("FAKE_TELEGRAM_API_EXPECTED_TOKEN is required"); + process.exit(2); +} + +function record(event) { + if (!captureFile) return; + fs.appendFileSync(captureFile, `${JSON.stringify({ at: Date.now(), ...event })}\n`); +} + +function tokenLooksPlaceholder(value) { + return typeof value === "string" && value.includes("openshell:resolve:env:"); +} + +function readFields(req, body) { + const contentType = String(req.headers["content-type"] || ""); + if (contentType.includes("application/json")) { + try { + return JSON.parse(body || "{}"); + } catch { + return {}; + } + } + const params = new URLSearchParams(body); + return Object.fromEntries(params.entries()); +} + +function writeJson(res, status, body) { + res.writeHead(status, { "content-type": "application/json" }); + res.end(JSON.stringify(body)); +} + +const server = http.createServer((req, res) => { + const chunks = []; + let bodyBytes = 0; + let bodyTooLarge = false; + req.on("data", (chunk) => { + if (bodyTooLarge) return; + bodyBytes += chunk.length; + if (bodyBytes > MAX_BODY_BYTES) { + bodyTooLarge = true; + record({ event: "request-too-large", method: req.method, path: req.url || "/", bodyBytes }); + writeJson(res, 413, { ok: false, error_code: 413, description: "payload too large" }); + req.destroy(); + return; + } + chunks.push(chunk); + }); + + req.on("end", () => { + if (bodyTooLarge) return; + const body = Buffer.concat(chunks).toString("utf8"); + const url = new URL(req.url || "/", "http://fake-telegram.local"); + const match = /^\/bot([^/]+)\/([^/?]+)$/.exec(url.pathname); + const token = match?.[1] || ""; + const endpoint = match?.[2] || ""; + const fields = readFields(req, body); + const tokenMatchesExpected = token === expectedToken; + + record({ + event: "request", + method: req.method, + path: url.pathname, + endpoint, + tokenMatchesExpected, + tokenLooksPlaceholder: tokenLooksPlaceholder(token), + tokenRedacted: true, + chatId: fields.chat_id ? String(fields.chat_id) : "", + text: fields.text ? String(fields.text) : "", + textLength: fields.text ? String(fields.text).length : 0, + }); + + if (!match) { + writeJson(res, 404, { ok: false, error_code: 404, description: "not found" }); + return; + } + + if (!tokenMatchesExpected) { + writeJson(res, 401, { ok: false, error_code: 401, description: "Unauthorized" }); + return; + } + + if (endpoint === "getMe") { + writeJson(res, 200, { + ok: true, + result: { + id: 420000001, + is_bot: true, + first_name: "NemoClaw E2E", + username: "nemoclaw_e2e_bot", + }, + }); + return; + } + + if (endpoint === "sendMessage") { + writeJson(res, 200, { + ok: true, + result: { + message_id: 4201, + date: Math.floor(Date.now() / 1000), + chat: { + id: Number(fields.chat_id) || String(fields.chat_id || ""), + type: "private", + }, + text: String(fields.text || ""), + }, + }); + return; + } + + writeJson(res, 200, { ok: true, result: true }); + }); +}); + +server.on("error", (error) => { + record({ event: "server_error", error: error.message }); + console.error(error.stack || error.message); +}); + +server.listen(port, host, () => { + const address = server.address(); + if (portFile) { + fs.writeFileSync(portFile, `${address.port}\n`, { mode: 0o600 }); + } + record({ event: "listening", host, port: address.port }); +}); + +for (const signal of ["SIGTERM", "SIGINT"]) { + process.on(signal, () => { + server.close(() => process.exit(0)); + setTimeout(() => process.exit(0), 1000).unref(); + }); +} diff --git a/test/e2e/lib/slack-api-proof.sh b/test/e2e/lib/slack-api-proof.sh index 3d922857c35..19db535d2fd 100755 --- a/test/e2e/lib/slack-api-proof.sh +++ b/test/e2e/lib/slack-api-proof.sh @@ -172,43 +172,64 @@ function fail(message) { process.exit(1); } -function resolveOpenClawRoot() { - const candidates = []; +function resolveOpenClawSlackApiLocation() { + const externalCandidates = []; + const coreCandidates = []; const seen = new Set(); const require = createRequire(import.meta.url); - const addCandidate = (candidate) => { + const addExternalCandidate = (candidate) => { if (!candidate) return; const normalized = path.resolve(candidate); if (!seen.has(normalized)) { seen.add(normalized); - candidates.push(normalized); + externalCandidates.push(normalized); + } + }; + const addCoreCandidate = (candidate) => { + if (!candidate) return; + const normalized = path.resolve(candidate); + if (!seen.has(normalized)) { + seen.add(normalized); + coreCandidates.push(normalized); } }; const addPathWalk = (start) => { if (!start) return; let current = path.resolve(start); for (let depth = 0; depth < 8; depth += 1) { - addCandidate(current); - if (path.basename(current) === "openclaw") addCandidate(current); + addExternalCandidate(path.join(current, "node_modules/@openclaw/slack")); + addCoreCandidate(current); + if (path.basename(current) === "openclaw") addCoreCandidate(current); const parent = path.dirname(current); if (parent === current) break; current = parent; } }; - addCandidate(process.env.OPENCLAW_PACKAGE_ROOT); + if (process.env.OPENCLAW_SLACK_PACKAGE_ROOT) { + addExternalCandidate(process.env.OPENCLAW_SLACK_PACKAGE_ROOT); + } + addCoreCandidate(process.env.OPENCLAW_PACKAGE_ROOT); for (const base of [process.cwd(), "/sandbox", "/usr/local/lib/node_modules", "/tmp/npm-global/lib/node_modules"]) { try { - addCandidate(path.dirname(require.resolve("openclaw/package.json", { paths: [base] }))); + addExternalCandidate(path.dirname(require.resolve("@openclaw/slack/package.json", { paths: [base] }))); + } catch {} + try { + addCoreCandidate(path.dirname(require.resolve("openclaw/package.json", { paths: [base] }))); } catch {} } try { const globalRoot = execFileSync("npm", ["root", "-g"], { encoding: "utf8" }).trim(); - if (globalRoot) addCandidate(path.join(globalRoot, "openclaw")); + if (globalRoot) { + addExternalCandidate(path.join(globalRoot, "@openclaw/slack")); + addCoreCandidate(path.join(globalRoot, "openclaw")); + } + } catch {} + try { + addExternalCandidate(path.dirname(require.resolve("@openclaw/slack/package.json"))); } catch {} try { - const require = createRequire(import.meta.url); - addCandidate(path.dirname(require.resolve("openclaw/package.json"))); + addCoreCandidate(path.dirname(require.resolve("openclaw/package.json"))); } catch {} try { const openclawBin = execFileSync("sh", ["-lc", "command -v openclaw || true"], { encoding: "utf8" }).trim(); @@ -220,19 +241,49 @@ function resolveOpenClawRoot() { try { const searchRoots = ["/usr/local", "/tmp/npm-global", "/sandbox"].filter((root) => fs.existsSync(root)); const discovered = searchRoots.length - ? execFileSync("find", [...searchRoots, "-path", "*/dist/extensions/slack/test-api.js", "-print", "-quit"], { + ? execFileSync("find", [ + ...searchRoots, + "(", + "-path", + "*/node_modules/@openclaw/slack/dist/test-api.js", + "-o", + "-path", + "*/node_modules/openclaw/dist/extensions/slack/test-api.js", + ")", + "-print", + "-quit", + ], { encoding: "utf8", }).trim() : ""; - if (discovered) addCandidate(path.resolve(discovered, "../../../..")); + if (discovered.endsWith("/node_modules/@openclaw/slack/dist/test-api.js")) { + addExternalCandidate(path.resolve(discovered, "../..")); + } else if (discovered) { + addCoreCandidate(path.resolve(discovered, "../../../..")); + } } catch {} - addCandidate("/usr/local/lib/node_modules/openclaw"); - addCandidate("/tmp/npm-global/lib/node_modules/openclaw"); + addExternalCandidate("/usr/local/lib/node_modules/@openclaw/slack"); + addExternalCandidate("/tmp/npm-global/lib/node_modules/@openclaw/slack"); + addCoreCandidate("/usr/local/lib/node_modules/openclaw"); + addCoreCandidate("/tmp/npm-global/lib/node_modules/openclaw"); + + const openclawRoot = coreCandidates.find((candidate) => + fs.existsSync(path.join(candidate, "package.json")) && + fs.existsSync(path.join(candidate, "dist/plugin-sdk/temp-path.js")) + ); - for (const candidate of candidates) { + for (const candidate of externalCandidates) { + const testApiPath = path.join(candidate, "dist/test-api.js"); + if (fs.existsSync(testApiPath)) { + console.error(`OpenClaw Slack external test API root: ${candidate}`); + if (openclawRoot) console.error(`OpenClaw Slack external peer OpenClaw root: ${openclawRoot}`); + return { kind: "external", root: candidate, testApiPath, openclawRoot }; + } + } + for (const candidate of coreCandidates) { if (fs.existsSync(path.join(candidate, "dist/extensions/slack/test-api.js"))) { - console.error(`OpenClaw Slack test API root: ${candidate}`); - return candidate; + console.error(`OpenClaw Slack core test API root: ${candidate}`); + return { kind: "core", root: candidate }; } } return null; @@ -316,6 +367,28 @@ function createOpenClawSlackProofRoot(openclawRoot) { return proofRoot; } +function linkNodeModulesEntries(nodeModulesRoot, sourceNodeModules, skip = new Set()) { + if (!fs.existsSync(sourceNodeModules)) return; + for (const entry of fs.readdirSync(sourceNodeModules)) { + const sourceEntry = path.join(sourceNodeModules, entry); + const destEntry = path.join(nodeModulesRoot, entry); + if (entry.startsWith("@") && fs.statSync(sourceEntry).isDirectory()) { + fs.mkdirSync(destEntry, { recursive: true }); + for (const scopedEntry of fs.readdirSync(sourceEntry)) { + const key = `${entry}/${scopedEntry}`; + if (skip.has(key)) continue; + const sourceScopedEntry = path.join(sourceEntry, scopedEntry); + const destScopedEntry = path.join(destEntry, scopedEntry); + if (!fs.existsSync(destScopedEntry)) { + fs.symlinkSync(sourceScopedEntry, destScopedEntry, "dir"); + } + } + } else if (!skip.has(entry) && !fs.existsSync(destEntry)) { + fs.symlinkSync(sourceEntry, destEntry, "dir"); + } + } +} + function resolveSlackTestApiImport(testApiSource, exportName) { const escapedExportName = exportName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const patterns = [ @@ -327,9 +400,27 @@ function resolveSlackTestApiImport(testApiSource, exportName) { return match[1]; } -async function importOpenClawSlackProofApi(openclawRoot) { - const proofRoot = createOpenClawSlackProofRoot(openclawRoot); - const slackDir = path.join(proofRoot, "dist/extensions/slack"); +function createExternalOpenClawSlackProofRoot(location) { + if (!location.openclawRoot) return location.root; + + const proofWorkspace = fs.mkdtempSync("/tmp/openclaw-slack-external-proof-"); + const nodeModulesRoot = path.join(proofWorkspace, "node_modules"); + const openclawScopeRoot = path.join(nodeModulesRoot, "@openclaw"); + fs.mkdirSync(openclawScopeRoot, { recursive: true }); + + const slackProofRoot = path.join(openclawScopeRoot, "slack"); + fs.symlinkSync(location.root, slackProofRoot, "dir"); + fs.symlinkSync(location.openclawRoot, path.join(nodeModulesRoot, "openclaw"), "dir"); + + linkNodeModulesEntries(nodeModulesRoot, path.resolve(location.root, "../.."), new Set(["openclaw", "@openclaw/slack"])); + linkNodeModulesEntries(nodeModulesRoot, path.join(location.root, "node_modules"), new Set(["openclaw", "@openclaw/slack"])); + linkNodeModulesEntries(nodeModulesRoot, path.dirname(location.openclawRoot), new Set(["openclaw", "@openclaw/slack"])); + linkNodeModulesEntries(nodeModulesRoot, path.join(location.openclawRoot, "node_modules"), new Set(["openclaw", "@openclaw/slack"])); + + return slackProofRoot; +} + +async function importSlackProofModulesFromDir(slackDir) { const testApiSource = fs.readFileSync(path.join(slackDir, "test-api.js"), "utf8"); const helperPath = resolveSlackTestApiImport(testApiSource, "createInboundSlackTestContext"); const preparePath = resolveSlackTestApiImport(testApiSource, "prepareSlackMessage"); @@ -346,6 +437,16 @@ async function importOpenClawSlackProofApi(openclawRoot) { }; } +async function importOpenClawSlackProofApi(location) { + if (location.kind === "external") { + const proofRoot = createExternalOpenClawSlackProofRoot(location); + return importSlackProofModulesFromDir(path.join(proofRoot, "dist")); + } + + const proofRoot = createOpenClawSlackProofRoot(location.root); + return importSlackProofModulesFromDir(path.join(proofRoot, "dist/extensions/slack")); +} + function postForm(pathname, fields, authorization) { const body = new URLSearchParams(fields).toString(); const options = { @@ -432,9 +533,16 @@ async function postChannelProofMessage() { return response.body; } -async function runOpenClawPrivateProof(openclawRoot) { - const slackApi = await importOpenClawSlackProofApi(openclawRoot); +async function runOpenClawPrivateProof(location) { + const slackApi = await importOpenClawSlackProofApi(location); const { createInboundSlackTestContext, prepareSlackMessage, sendMessageSlack } = slackApi; + if ( + typeof createInboundSlackTestContext !== "function" || + typeof prepareSlackMessage !== "function" || + typeof sendMessageSlack !== "function" + ) { + fail("installed OpenClaw Slack test API does not expose the required proof helpers"); + } const appClient = { assistant: { threads: { @@ -559,17 +667,16 @@ async function runHermeticSlackProof() { }; } -const openclawRoot = resolveOpenClawRoot(); +const slackApiLocation = resolveOpenClawSlackApiLocation(); let result; -if (openclawRoot) { +if (slackApiLocation) { try { - result = await runOpenClawPrivateProof(openclawRoot); + result = await runOpenClawPrivateProof(slackApiLocation); } catch (error) { - console.error(`[slack-proof] OpenClaw Slack helper unavailable (${error.message}); using NemoClaw hermetic proof`); - result = await runHermeticSlackProof(); + fail(`[slack-proof] OpenClaw Slack helper failed: ${error.stack || error.message || String(error)}`); } } else { - result = await runHermeticSlackProof(); + fail("[slack-proof] could not find installed OpenClaw Slack proof helper"); } console.log( diff --git a/test/e2e/lib/telegram-api-proof.sh b/test/e2e/lib/telegram-api-proof.sh new file mode 100755 index 00000000000..9e845b4d76f --- /dev/null +++ b/test/e2e/lib/telegram-api-proof.sh @@ -0,0 +1,311 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Shared hermetic Telegram Bot API helpers for OpenClaw messaging E2E checks. + +append_exit_trap_for_fake_telegram_api() { + local command="$1" + local existing + existing="$(trap -p EXIT | sed "s/^trap -- '//;s/' EXIT$//")" + trap ''"${existing:+$existing; }$command"'' EXIT +} + +cleanup_fake_telegram_api() { + if [ -n "${FAKE_TELEGRAM_API_CONTAINER:-}" ]; then + docker rm -f "$FAKE_TELEGRAM_API_CONTAINER" >/dev/null 2>&1 || true + fi + if [ -n "${FAKE_TELEGRAM_API_DIR:-}" ]; then + rm -rf "$FAKE_TELEGRAM_API_DIR" 2>/dev/null || true + fi +} + +start_fake_telegram_api() { + local token="$1" + mkdir -p "$REPO/.tmp" + FAKE_TELEGRAM_API_DIR="$(mktemp -d "$REPO/.tmp/fake-telegram.XXXXXX")" + FAKE_TELEGRAM_API_PORT_FILE="$FAKE_TELEGRAM_API_DIR/port" + FAKE_TELEGRAM_API_CAPTURE_FILE="$FAKE_TELEGRAM_API_DIR/capture.jsonl" + FAKE_TELEGRAM_API_CONTAINER="nemoclaw-fake-telegram-$$-$RANDOM" + FAKE_TELEGRAM_API_HOST="host.docker.internal" + : >"$FAKE_TELEGRAM_API_CAPTURE_FILE" + + if ! docker run -d --rm \ + --name "$FAKE_TELEGRAM_API_CONTAINER" \ + -p 0:8080 \ + -e FAKE_TELEGRAM_API_PORT=8080 \ + -e FAKE_TELEGRAM_API_EXPECTED_TOKEN="$token" \ + -e FAKE_TELEGRAM_API_PORT_FILE=/tmp/fake-telegram/port \ + -e FAKE_TELEGRAM_API_CAPTURE_FILE=/tmp/fake-telegram/capture.jsonl \ + -v "$FAKE_TELEGRAM_API_DIR:/tmp/fake-telegram" \ + -v "$REPO/test/e2e/lib:/opt/nemoclaw-e2e:ro" \ + node:22-bookworm-slim \ + node /opt/nemoclaw-e2e/fake-telegram-api.cjs \ + >"$FAKE_TELEGRAM_API_DIR/container.id" 2>"$FAKE_TELEGRAM_API_DIR/server.log"; then + cat "$FAKE_TELEGRAM_API_DIR/server.log" >&2 || true + return 1 + fi + append_exit_trap_for_fake_telegram_api cleanup_fake_telegram_api + + for _ in $(seq 1 50); do + if [ -s "$FAKE_TELEGRAM_API_PORT_FILE" ]; then + local published_port + published_port="$(docker port "$FAKE_TELEGRAM_API_CONTAINER" 8080/tcp 2>/dev/null | head -1 | sed 's/.*://')" + if [ -n "$published_port" ]; then + export FAKE_TELEGRAM_API_PORT + FAKE_TELEGRAM_API_PORT="$published_port" + return 0 + fi + fi + if ! docker inspect "$FAKE_TELEGRAM_API_CONTAINER" >/dev/null 2>&1; then + docker logs "$FAKE_TELEGRAM_API_CONTAINER" >&2 || true + cat "$FAKE_TELEGRAM_API_DIR/server.log" >&2 || true + return 1 + fi + sleep 0.1 + done + cat "$FAKE_TELEGRAM_API_DIR/server.log" >&2 || true + return 1 +} + +fake_telegram_api_allowed_ip_options() { + printf '%s' 'allowed-ip=10.0.0.0/8,allowed-ip=172.16.0.0/12,allowed-ip=192.168.0.0/16' +} + +apply_fake_telegram_api_policy() { + local sandbox_name="$1" + local port="$2" + local host="${FAKE_TELEGRAM_API_HOST:-host.openshell.internal}" + local allowed_ip_options + allowed_ip_options="$(fake_telegram_api_allowed_ip_options)" + openshell policy update "$sandbox_name" \ + --add-endpoint "${host}:${port}:read-write:rest:enforce:request-body-credential-rewrite,${allowed_ip_options}" \ + --add-allow "${host}:${port}:GET:/**" \ + --add-allow "${host}:${port}:POST:/**" \ + --binary /usr/local/bin/node \ + --binary /usr/bin/node \ + --wait +} + +run_openclaw_telegram_mock_send() { + local port="$1" + local target="$2" + local message="$3" + local host="${FAKE_TELEGRAM_API_HOST:-host.openshell.internal}" + local target_b64 message_b64 + target_b64=$(printf '%s' "$target" | base64 | tr -d '\n') + message_b64=$(printf '%s' "$message" | base64 | tr -d '\n') + + sandbox_exec_stdin "FAKE_TELEGRAM_API_HOST='$host' FAKE_TELEGRAM_API_PORT='$port' OPENCLAW_MESSAGE_TARGET_B64='$target_b64' OPENCLAW_MESSAGE_TEXT_B64='$message_b64' node --preserve-symlinks --input-type=module - 2>&1" <<'NODE' +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import http from "node:http"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +function decodeBase64(value) { + return Buffer.from(value || "", "base64").toString("utf8"); +} + +function addPathWalk(candidates, seen, start) { + if (!start) return; + let current = path.resolve(start); + for (let depth = 0; depth < 8; depth += 1) { + if (!seen.has(current)) { + seen.add(current); + candidates.push(path.join(current, "node_modules/openclaw/dist/extensions/telegram/test-api.js")); + candidates.push(path.join(current, "dist/extensions/telegram/test-api.js")); + } + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } +} + +function resolveTelegramTestApiPath() { + const require = createRequire(import.meta.url); + const candidates = []; + const seen = new Set(); + const add = (candidate) => { + if (candidate && !seen.has(candidate)) { + seen.add(candidate); + candidates.push(candidate); + } + }; + + for (const base of [process.cwd(), "/sandbox", "/usr/local/lib/node_modules", "/tmp/npm-global/lib/node_modules"]) { + try { + add(path.join(path.dirname(require.resolve("openclaw/package.json", { paths: [base] })), "dist/extensions/telegram/test-api.js")); + } catch {} + } + + try { + const globalRoot = execFileSync("npm", ["root", "-g"], { encoding: "utf8" }).trim(); + add(path.join(globalRoot, "openclaw/dist/extensions/telegram/test-api.js")); + } catch {} + + try { + const openclawBin = execFileSync("sh", ["-lc", "command -v openclaw || true"], { encoding: "utf8" }).trim(); + if (openclawBin) { + const realBin = execFileSync("readlink", ["-f", openclawBin], { encoding: "utf8" }).trim(); + addPathWalk(candidates, seen, path.dirname(realBin)); + } + } catch {} + + try { + const searchRoots = ["/usr/local", "/tmp/npm-global", "/sandbox"].filter((root) => fs.existsSync(root)); + if (searchRoots.length) { + const discovered = execFileSync("find", [ + ...searchRoots, + "-path", + "*/node_modules/openclaw/dist/extensions/telegram/test-api.js", + "-print", + "-quit", + ], { encoding: "utf8" }).trim(); + add(discovered); + } + } catch {} + + for (const candidate of candidates) { + if (candidate && fs.existsSync(candidate)) return candidate; + } + return null; +} + +function requestFakeTelegram(endpoint, fields, token) { + const payload = JSON.stringify(fields); + const options = { + hostname: process.env.FAKE_TELEGRAM_API_HOST || "host.openshell.internal", + port: Number(process.env.FAKE_TELEGRAM_API_PORT), + path: `/bot${token}/${endpoint}`, + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(payload), + "User-Agent": "nemoclaw-openclaw-telegram-plugin-e2e", + }, + }; + return new Promise((resolve, reject) => { + const req = http.request(options, (res) => { + let responseBody = ""; + res.on("data", (chunk) => { + responseBody += chunk; + }); + res.on("end", () => { + let parsed = {}; + try { + parsed = responseBody ? JSON.parse(responseBody) : {}; + } catch (error) { + reject(new Error(`invalid JSON from fake Telegram: ${error.message}: ${responseBody}`)); + return; + } + if (res.statusCode < 200 || res.statusCode >= 300 || parsed.ok !== true) { + reject(new Error(`fake Telegram ${endpoint} failed: HTTP ${res.statusCode} ${JSON.stringify(parsed)}`)); + return; + } + resolve(parsed.result); + }); + }); + req.on("error", reject); + req.setTimeout(30000, () => { + req.destroy(new Error("fake Telegram message API timed out")); + }); + req.write(payload); + req.end(); + }); +} + +async function main() { + const testApiPath = resolveTelegramTestApiPath(); + if (!testApiPath) throw new Error("could not find installed OpenClaw Telegram test-api.js"); + + const { sendMessageTelegram } = await import(pathToFileURL(testApiPath).href); + if (typeof sendMessageTelegram !== "function") { + throw new Error("installed Telegram test API does not export sendMessageTelegram"); + } + + const cfg = JSON.parse(fs.readFileSync("/sandbox/.openclaw/openclaw.json", "utf8")); + const account = cfg.channels?.telegram?.accounts?.default; + if (!account?.botToken) throw new Error("missing channels.telegram.accounts.default.botToken in openclaw.json"); + + const target = decodeBase64(process.env.OPENCLAW_MESSAGE_TARGET_B64); + const text = decodeBase64(process.env.OPENCLAW_MESSAGE_TEXT_B64); + const token = account.botToken; + const api = { + sendMessage: (chatId, body, params = {}) => requestFakeTelegram("sendMessage", { + chat_id: chatId, + text: body, + ...params, + }, token), + }; + + const result = await sendMessageTelegram(target, text, { + cfg, + token, + accountId: "default", + api, + }); + + console.log(JSON.stringify({ + ok: true, + proof: "openclaw-telegram-runtime-send", + chatId: result.chatId ?? target, + messageId: result.messageId ?? null, + })); +} + +main() + .then(() => { + console.log("__OPENCLAW_MESSAGE_SEND_EXIT__:0"); + }) + .catch((error) => { + console.error(error.stack || error.message || String(error)); + console.log("__OPENCLAW_MESSAGE_SEND_EXIT__:1"); + process.exit(1); + }); +NODE +} + +check_fake_telegram_capture_send() { + local expected_token="$1" + local expected_chat="$2" + local expected_text="$3" + node - "$FAKE_TELEGRAM_API_CAPTURE_FILE" "$expected_token" "$expected_chat" "$expected_text" <<'NODE' +const fs = require("fs"); +const [file, expectedToken, expectedChat, expectedText] = process.argv.slice(2); +const rows = fs + .readFileSync(file, "utf8") + .trim() + .split(/\n+/) + .filter(Boolean) + .map((line) => JSON.parse(line)) + .filter((row) => row.event === "request" && row.endpoint === "sendMessage"); +const last = rows.at(-1); +if (!last) { + console.log("NO_SEND_MESSAGE"); + process.exit(2); +} +if (last.tokenMatchesExpected !== true) { + console.log("BAD_TOKEN_REWRITE"); + process.exit(3); +} +if (last.tokenLooksPlaceholder) { + console.log("PLACEHOLDER_LEAK"); + process.exit(4); +} +if (String(last.chatId) !== String(expectedChat)) { + console.log(`BAD_CHAT ${last.chatId}`); + process.exit(5); +} +if (last.text !== expectedText) { + console.log(`BAD_TEXT ${last.text}`); + process.exit(6); +} +if (!expectedToken) { + console.log("MISSING_EXPECTED_TOKEN"); + process.exit(7); +} +console.log("OK"); +NODE +} diff --git a/test/e2e/test-messaging-providers.sh b/test/e2e/test-messaging-providers.sh index ad0899ef159..b7fc334a94d 100755 --- a/test/e2e/test-messaging-providers.sh +++ b/test/e2e/test-messaging-providers.sh @@ -19,17 +19,19 @@ # 3. Credential isolation — real tokens never appear in sandbox env, # process list, or filesystem # 4. Config patching — openclaw.json channels use placeholder values -# 5. Telegram diagnostics — startup/credential breadcrumbs stay sanitized -# 6. Network reachability — Node.js can reach messaging APIs through proxy -# 7. Native Discord gateway path — WebSocket L7 path is tested hermetically -# 8. L7 proxy rewriting — placeholder is rewritten to real token at egress -# 9. WhatsApp QR-only parity — channel add/rebuild applies policy, bakes +# 5. OpenClaw runtime discovery — channels list as installed/configured +# 6. Telegram diagnostics — startup/credential breadcrumbs stay sanitized +# 7. Network reachability — Node.js can reach messaging APIs through proxy +# 8. Native Discord gateway path — WebSocket L7 path is tested hermetically +# 9. L7 proxy rewriting — placeholder is rewritten to real token at egress +# 10. WhatsApp QR-only parity — channel add/rebuild applies policy, bakes # openclaw.json, creates no providers, and leaks no token placeholders # # Uses fake tokens by default (no external accounts needed). With fake tokens, -# the API returns 401 — proving the full chain worked (request reached the -# real API with the token rewritten). Optional real tokens enable a bonus -# round-trip phase. +# the live API probes return 401/404 — proving the full chain worked (request +# reached the real API with the token rewritten). The OpenClaw plugin-send phase +# then sends messages to host-side fake provider APIs when complete real +# credentials/targets are not configured. # # Prerequisites: # - Docker running @@ -45,8 +47,10 @@ # TELEGRAM_BOT_TOKEN — defaults to fake token # DISCORD_BOT_TOKEN — defaults to fake token # TELEGRAM_ALLOWED_IDS — comma-separated Telegram user IDs for DM allowlisting -# TELEGRAM_BOT_TOKEN_REAL — optional: enables Phase 6 real round-trip -# DISCORD_BOT_TOKEN_REAL — optional: enables Phase 6 real round-trip +# TELEGRAM_BOT_TOKEN_REAL — optional: enables Phase 6 real OpenClaw send +# DISCORD_BOT_TOKEN_REAL — optional: enables Phase 6 real OpenClaw send +# SLACK_BOT_TOKEN_REAL — optional: enables Phase 6 real OpenClaw send +# SLACK_APP_TOKEN_REAL — optional paired Slack app token for real Slack run # SLACK_BOT_TOKEN — defaults to fake token (xoxb-fake-...) # SLACK_APP_TOKEN — defaults to fake token (xapp-fake-...) # SLACK_ALLOWED_USERS — comma-separated Slack user IDs for DM and channel @mention allowlisting @@ -61,7 +65,9 @@ # WHATSAPP_TOKEN / WHATSAPP_BOT_TOKEN / WHATSAPP_SESSION_SECRET # — overwritten with fake decoys to prove NemoClaw ignores host-side # WhatsApp credential-shaped env vars -# TELEGRAM_CHAT_ID_E2E — optional: enables sendMessage test +# TELEGRAM_CHAT_ID_E2E — optional: target for real Telegram send +# DISCORD_CHANNEL_ID_E2E — optional: target for real Discord send +# SLACK_CHANNEL_ID_E2E — optional: target for real Slack send # NEMOCLAW_OPENSHELL_BIN — optional OpenShell binary under test # NEMOCLAW_FRESH=1 — auto-set to discard interrupted onboard sessions # @@ -150,15 +156,237 @@ registry_array_contains() { printf '%s' "$value" | grep -Fq "\"${item}\"" } +assert_openclaw_config_activation() { + local assertion_id="$1" + local channel="$2" + local label="$3" + local channel_enabled plugin_enabled + + channel_enabled=$(printf '%s\n' "$channel_json" | CHANNEL="$channel" python3 -c ' +import json +import os +import sys +try: + channels = json.load(sys.stdin) + entry = channels.get(os.environ["CHANNEL"], {}) + print("true" if isinstance(entry, dict) and entry.get("enabled") is True else "false") +except Exception: + print("error") +' 2>/dev/null || true) + plugin_enabled=$(printf '%s\n' "$plugin_entries_json" | CHANNEL="$channel" python3 -c ' +import json +import os +import sys +try: + entries = json.load(sys.stdin) + entry = entries.get(os.environ["CHANNEL"], {}) + print("true" if isinstance(entry, dict) and entry.get("enabled") is True else "false") +except Exception: + print("error") +' 2>/dev/null || true) + + if [ "$channel_enabled" = "true" ] && [ "$plugin_enabled" = "true" ]; then + pass "${assertion_id}: ${label} channel and plugin are explicitly enabled in openclaw.json" + else + fail "${assertion_id}: ${label} OpenClaw activation missing (channels.${channel}.enabled=${channel_enabled}, plugins.entries.${channel}.enabled=${plugin_enabled})" + fi +} + +summarize_openclaw_config_activation() { + CHANNEL_JSON="$channel_json" PLUGIN_ENTRIES_JSON="$plugin_entries_json" python3 -c ' +import json +import os + +channels_to_check = ("telegram", "discord", "slack", "whatsapp") +try: + channels = json.loads(os.environ.get("CHANNEL_JSON", "{}")) + entries = json.loads(os.environ.get("PLUGIN_ENTRIES_JSON", "{}")) +except json.JSONDecodeError as exc: + print("parse_error=%s" % exc.msg) + raise SystemExit(0) + +summary = [] +for channel in channels_to_check: + channel_entry = channels.get(channel, {}) + plugin_entry = entries.get(channel, {}) + summary.append( + "%s:channel=%s,plugin=%s" + % ( + channel, + isinstance(channel_entry, dict) and channel_entry.get("enabled") is True, + isinstance(plugin_entry, dict) and plugin_entry.get("enabled") is True, + ) + ) +print("; ".join(summary)) +' 2>/dev/null || printf 'unavailable' +} + +summarize_openclaw_runtime_channels() { + printf '%s\n' "$openclaw_channels_list_json" | python3 -c ' +import json +import sys + +channels_to_check = ("telegram", "discord", "slack", "whatsapp") +try: + data = json.load(sys.stdin) +except json.JSONDecodeError as exc: + print("parse_error=%s" % exc.msg) + raise SystemExit(0) + +chat = data.get("chat") if isinstance(data, dict) else None +if not isinstance(chat, dict): + print("missing_chat") + raise SystemExit(0) + +summary = [] +for channel in channels_to_check: + entry = chat.get(channel) + if not isinstance(entry, dict): + summary.append("%s:missing" % channel) + continue + accounts = entry.get("accounts") + if isinstance(accounts, list): + account_ids = [str(item) for item in accounts if isinstance(item, str)] + else: + account_ids = ["<%s>" % type(accounts).__name__] + summary.append( + "%s:installed=%s,origin=%s,accounts=%s" + % (channel, entry.get("installed"), entry.get("origin"), ",".join(account_ids)) + ) +print("; ".join(summary)) +' 2>/dev/null || printf 'unavailable' +} + +assert_openclaw_runtime_channel() { + local assertion_id="$1" + local channel="$2" + local label="$3" + local expected_account="${4:-default}" + local runtime_state + + runtime_state=$(printf '%s\n' "$openclaw_channels_list_json" | CHANNEL="$channel" ACCOUNT="$expected_account" python3 -c ' +import json +import os +import sys + +channel = os.environ["CHANNEL"] +expected = os.environ.get("ACCOUNT", "") +try: + data = json.load(sys.stdin) +except json.JSONDecodeError as exc: + print("error invalid_json=%s" % exc.msg) + raise SystemExit(0) + +if not isinstance(data, dict): + print("no top_level_type=%s" % type(data).__name__) + raise SystemExit(0) + +chat = data.get("chat") +if not isinstance(chat, dict): + print("no missing_chat") + raise SystemExit(0) + +entry = chat.get(channel) +if not isinstance(entry, dict): + print("no missing_channel") + raise SystemExit(0) + +# OpenClaw `channels list --all --json` currently reports configured account +# ids as a list of strings, for example: {"chat":{"slack":{"accounts":["default"]}}}. +# Keep this strict so schema drift fails loudly instead of hiding a discovery +# regression behind a permissive compatibility parser. +accounts = entry.get("accounts") +if not isinstance(accounts, list) or any(not isinstance(item, str) for item in accounts): + print( + "no installed=%s origin=%s accounts_shape=%s" + % (entry.get("installed"), entry.get("origin"), type(accounts).__name__) + ) + raise SystemExit(0) + +installed = entry.get("installed") is True +configured = entry.get("origin") == "configured" +account_ok = not expected or expected in accounts +if installed and configured and account_ok: + print("yes") +else: + print( + "no installed=%s origin=%s accounts=%s" + % (entry.get("installed"), entry.get("origin"), accounts) + ) +' 2>/dev/null || true) + + if [ "$runtime_state" = "yes" ]; then + pass "${assertion_id}: OpenClaw channels list reports ${label} installed and configured" + else + fail "${assertion_id}: OpenClaw channels list did not report ${label} installed/configured (${runtime_state}; summary=${openclaw_channels_summary:-unavailable})" + fi +} + +assert_openclaw_runtime_channel_installed() { + local assertion_id="$1" + local channel="$2" + local label="$3" + local runtime_state + + runtime_state=$(printf '%s\n' "$openclaw_channels_list_json" | CHANNEL="$channel" python3 -c ' +import json +import os +import sys + +channel = os.environ["CHANNEL"] +try: + data = json.load(sys.stdin) +except json.JSONDecodeError as exc: + print("error invalid_json=%s" % exc.msg) + raise SystemExit(0) + +chat = data.get("chat") if isinstance(data, dict) else None +if not isinstance(chat, dict): + print("no missing_chat") + raise SystemExit(0) + +entry = chat.get(channel) +if not isinstance(entry, dict): + print("no missing_channel") + raise SystemExit(0) + +accounts = entry.get("accounts") +if not isinstance(accounts, list) or any(not isinstance(item, str) for item in accounts): + print( + "no installed=%s origin=%s accounts_shape=%s" + % (entry.get("installed"), entry.get("origin"), type(accounts).__name__) + ) + raise SystemExit(0) + +installed = entry.get("installed") is True +origin_ok = entry.get("origin") in ("available", "configured") +if installed and origin_ok: + print("yes") +else: + print( + "no installed=%s origin=%s accounts=%s" + % (entry.get("installed"), entry.get("origin"), accounts) + ) +' 2>/dev/null || true) + + if [ "$runtime_state" = "yes" ]; then + pass "${assertion_id}: OpenClaw channels list reports ${label} plugin installed" + else + fail "${assertion_id}: OpenClaw channels list did not report ${label} plugin installed (${runtime_state}; summary=${openclaw_channels_summary:-unavailable})" + fi +} + # shellcheck source=test/e2e/lib/sandbox-teardown.sh . "$(dirname "${BASH_SOURCE[0]}")/lib/sandbox-teardown.sh" register_sandbox_for_teardown "$SANDBOX_NAME" -# Default to fake tokens if not provided -TELEGRAM_TOKEN="${TELEGRAM_BOT_TOKEN:-test-fake-telegram-token-e2e}" -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}" +# Default to hermetic fake tokens, but let repository live-message secrets win +# when they are available. The workflow always provides fake env_json values so +# the _REAL variables must take precedence here. +TELEGRAM_TOKEN="${TELEGRAM_BOT_TOKEN_REAL:-${TELEGRAM_BOT_TOKEN:-test-fake-telegram-token-e2e}}" +DISCORD_TOKEN="${DISCORD_BOT_TOKEN_REAL:-${DISCORD_BOT_TOKEN:-test-fake-discord-token-e2e}}" +SLACK_TOKEN="${SLACK_BOT_TOKEN_REAL:-${SLACK_BOT_TOKEN:-xoxb-fake-slack-token-e2e}}" +SLACK_APP="${SLACK_APP_TOKEN_REAL:-${SLACK_APP_TOKEN:-xapp-fake-slack-app-token-e2e}}" TELEGRAM_IDS="${TELEGRAM_ALLOWED_IDS:-123456789,987654321}" SLACK_IDS="${SLACK_ALLOWED_USERS-U0AR85ATALW,U09E2ESLACK}" # WeChat: pre-seeding WECHAT_BOT_TOKEN + the per-account metadata env vars lets @@ -232,12 +460,43 @@ sandbox_exec() { echo "$result" } +run_openclaw_message_send() { + local channel="$1" + local target="$2" + local message="$3" + local channel_b64 target_b64 message_b64 + channel_b64=$(printf '%s' "$channel" | base64 | tr -d '\n') + target_b64=$(printf '%s' "$target" | base64 | tr -d '\n') + message_b64=$(printf '%s' "$message" | base64 | tr -d '\n') + + sandbox_exec_stdin "OPENCLAW_MESSAGE_CHANNEL_B64='$channel_b64' OPENCLAW_MESSAGE_TARGET_B64='$target_b64' OPENCLAW_MESSAGE_TEXT_B64='$message_b64' bash -s" <<'SH' +decode_b64() { + printf '%s' "$1" | base64 -d +} + +channel="$(decode_b64 "$OPENCLAW_MESSAGE_CHANNEL_B64")" +target="$(decode_b64 "$OPENCLAW_MESSAGE_TARGET_B64")" +message="$(decode_b64 "$OPENCLAW_MESSAGE_TEXT_B64")" + +set +e +OPENCLAW_NO_COLOR=1 openclaw message send --channel "$channel" --target "$target" --message "$message" --json +rc=$? +echo "__OPENCLAW_MESSAGE_SEND_EXIT__:$rc" +SH +} + +openclaw_message_send_exit_code() { + awk -F: '/^__OPENCLAW_MESSAGE_SEND_EXIT__:/ { code = $2 } END { if (code != "") print code }' +} + # shellcheck source=test/e2e/lib/discord-gateway-proof.sh . "$(dirname "${BASH_SOURCE[0]}")/lib/discord-gateway-proof.sh" # shellcheck source=test/e2e/lib/discord-rest-policy-proof.sh . "$(dirname "${BASH_SOURCE[0]}")/lib/discord-rest-policy-proof.sh" # shellcheck source=test/e2e/lib/slack-api-proof.sh . "$(dirname "${BASH_SOURCE[0]}")/lib/slack-api-proof.sh" +# shellcheck source=test/e2e/lib/telegram-api-proof.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib/telegram-api-proof.sh" # ══════════════════════════════════════════════════════════════════ # Phase 0: Prerequisites @@ -861,11 +1120,39 @@ try: except Exception as e: print(json.dumps({'error': str(e)})) \"" 2>/dev/null || true) +plugin_entries_json=$(sandbox_exec "python3 -c \" +import json +try: + cfg = json.load(open('/sandbox/.openclaw/openclaw.json')) + entries = cfg.get('plugins', {}).get('entries', {}) + print(json.dumps(entries)) +except Exception as e: + print(json.dumps({'error': str(e)})) +\"" 2>/dev/null || true) if [ -z "$channel_json" ] || echo "$channel_json" | grep -q '"error"'; then fail "M6: Could not read openclaw.json channels (${channel_json:0:200})" else - info "Channel config: ${channel_json:0:300}" + info "OpenClaw channel activation summary: $(summarize_openclaw_config_activation)" + + assert_openclaw_config_activation "M6a" "telegram" "Telegram" + assert_openclaw_config_activation "M6b" "discord" "Discord" + assert_openclaw_config_activation "M6c" "slack" "Slack" + assert_openclaw_config_activation "M6d" "whatsapp" "WhatsApp" + + # This live nightly check intentionally uses OpenClaw's real runtime surface: + # config activation alone is not enough if the CLI still treats the channel as + # unavailable. Log only derived state; raw channel JSON may grow token/session + # fields in future OpenClaw releases. + openclaw_channels_list_json=$(sandbox_exec "timeout 45 openclaw channels list --all --json --no-color 2>/dev/null" 2>/dev/null || true) + openclaw_channels_summary="$(summarize_openclaw_runtime_channels)" + info "OpenClaw channels list summary: ${openclaw_channels_summary}" + assert_openclaw_runtime_channel "M6e" "telegram" "Telegram" "default" + assert_openclaw_runtime_channel "M6f" "discord" "Discord" "default" + assert_openclaw_runtime_channel "M6g" "slack" "Slack" "default" + # WhatsApp has no host-side token provider; before QR pairing OpenClaw can + # prove only that the external plugin is installed and loadable. + assert_openclaw_runtime_channel_installed "M6h" "whatsapp" "WhatsApp" # M6: Telegram channel exists with a bot token # Note: non-root sandboxes cannot patch openclaw.json (chmod 444, root-owned). @@ -2071,6 +2358,7 @@ info "Running Slack channel @mention allowlist proof through installed OpenClaw. sl_channel_proof="" sl_allowed_user="${SLACK_IDS%%,*}" sl_allowed_user="${sl_allowed_user//[[:space:]]/}" +slack_openclaw_plugin_mock_send_ok=0 if [ "$fake_slack_ready" = "1" ] && [ -n "$sl_allowed_user" ]; then sl_channel_proof=$(run_fake_slack_channel_mention_proof "$FAKE_SLACK_API_PORT" "$sl_allowed_user" "U999DENIED" || true) fi @@ -2091,6 +2379,26 @@ if echo "$sl_channel_proof" | grep -q '"ok":true' \ else fail "M-S17b: fake Slack did not capture expected channel reply metadata: ${sl_message_capture:0:300}" fi + sl_proof_kind=$(printf '%s\n' "$sl_channel_proof" | python3 -c ' +import json +import sys +for line in sys.stdin: + line = line.strip() + if not line.startswith("{"): + continue + try: + value = json.loads(line) + except Exception: + continue + print(value.get("proof", "")) + break +' 2>/dev/null || true) + if [ "$sl_proof_kind" = "openclaw-private-helper" ] && [ "$sl_message_capture" = "OK" ]; then + slack_openclaw_plugin_mock_send_ok=1 + pass "M-S17c: installed OpenClaw Slack send helper drove the host-side fake Slack message" + else + fail "M-S17c: Slack proof did not use the installed OpenClaw Slack send helper (proof=${sl_proof_kind:-missing})" + fi elif [ "$fake_slack_ready" != "1" ]; then skip "M-S17: fake Slack API was not ready" elif [ -z "$sl_allowed_user" ]; then @@ -2100,11 +2408,11 @@ else fi # ══════════════════════════════════════════════════════════════════ -# Phase 6: Real API Round-Trip (Optional) +# Phase 6: OpenClaw Plugin Sends # ══════════════════════════════════════════════════════════════════ -section "Phase 6: Real API Round-Trip (Optional)" +section "Phase 6: OpenClaw Plugin Sends" -if [ -n "${TELEGRAM_BOT_TOKEN_REAL:-}" ]; then +if [ -n "${TELEGRAM_BOT_TOKEN_REAL:-}" ] && [ -n "${TELEGRAM_CHAT_ID_E2E:-}" ]; then info "Real Telegram token available — testing live round-trip" # M18: Telegram getMe with real token should return 200 + bot info @@ -2119,53 +2427,118 @@ if [ -n "${TELEGRAM_BOT_TOKEN_REAL:-}" ]; then fail "M18: Expected Telegram getMe 200 with real token, got: $tg_status" fi - # M19: sendMessage if chat ID is available - if [ -n "${TELEGRAM_CHAT_ID_E2E:-}" ]; then - info "Sending test message to chat ${TELEGRAM_CHAT_ID_E2E}..." - send_result=$(sandbox_exec "node -e \" -const https = require('https'); -const token = process.env.TELEGRAM_BOT_TOKEN || ''; -const chatId = '${TELEGRAM_CHAT_ID_E2E}'; -const msg = 'NemoClaw E2E test ' + new Date().toISOString(); -const data = JSON.stringify({ chat_id: chatId, text: msg }); -const options = { - hostname: 'api.telegram.org', - path: '/bot' + token + '/sendMessage', - method: 'POST', - headers: { 'Content-Type': 'application/json', 'Content-Length': data.length }, -}; -const req = https.request(options, (res) => { - let body = ''; - res.on('data', (d) => body += d); - res.on('end', () => console.log(res.statusCode + ' ' + body.slice(0, 300))); -}); -req.on('error', (e) => console.log('ERROR: ' + e.message)); -req.setTimeout(30000, () => { req.destroy(); console.log('TIMEOUT'); }); -req.write(data); -req.end(); -\"" 2>/dev/null || true) + # M19: real send through OpenClaw's message CLI/plugin path. + info "Sending Telegram test message through OpenClaw plugin to chat ${TELEGRAM_CHAT_ID_E2E}..." + send_result=$(run_openclaw_message_send \ + "telegram" \ + "${TELEGRAM_CHAT_ID_E2E}" \ + "NemoClaw OpenClaw Telegram plugin E2E $(date -u +%Y-%m-%dT%H:%M:%SZ)" || true) + send_exit=$(printf '%s\n' "$send_result" | openclaw_message_send_exit_code) - if echo "$send_result" | grep -q "^200"; then - pass "M19: Telegram sendMessage succeeded" + if [ "$send_exit" = "0" ]; then + pass "M19: Telegram openclaw message send succeeded through plugin" + else + fail "M19: Telegram openclaw message send failed: ${send_result:0:300}" + fi +else + telegram_mock_chat_id="${TELEGRAM_CHAT_ID_E2E:-42424242}" + telegram_mock_text="NemoClaw OpenClaw Telegram plugin mock E2E" + info "Complete real Telegram credentials are not available — using host-side fake Telegram Bot API" + if start_fake_telegram_api "$TELEGRAM_TOKEN"; then + pass "M18: Host-side fake Telegram Bot API started for OpenClaw plugin send" + if apply_fake_telegram_api_policy "$SANDBOX_NAME" "$FAKE_TELEGRAM_API_PORT" >/tmp/nemoclaw-fake-telegram-policy.log 2>&1; then + pass "M18a: Applied REST policy for host-side fake Telegram Bot API" + tg_mock_send_result=$(run_openclaw_telegram_mock_send "$FAKE_TELEGRAM_API_PORT" "$telegram_mock_chat_id" "$telegram_mock_text" || true) + tg_mock_send_exit=$(printf '%s\n' "$tg_mock_send_result" | openclaw_message_send_exit_code) + tg_mock_capture=$(check_fake_telegram_capture_send "$TELEGRAM_TOKEN" "$telegram_mock_chat_id" "$telegram_mock_text" || true) + + if [ "$tg_mock_send_exit" = "0" ] && [ "$tg_mock_capture" = "OK" ]; then + pass "M19: Telegram installed OpenClaw send helper posted through host mock" + elif [ "$tg_mock_send_exit" != "0" ]; then + fail "M19: Telegram OpenClaw mock helper send failed: ${tg_mock_send_result:0:300}" + else + fail "M19: Fake Telegram did not capture the expected rewritten message: ${tg_mock_capture:0:300}" + fi else - fail "M19: Telegram sendMessage failed: ${send_result:0:200}" + fail "M18a: Failed to apply fake Telegram policy: $(tail -20 /tmp/nemoclaw-fake-telegram-policy.log 2>/dev/null | tr '\n' ' ' | cut -c1-300)" + fail "M19: Telegram OpenClaw mock message send could not run without fake Telegram policy" fi else - skip "M19: TELEGRAM_CHAT_ID_E2E not set — skipping sendMessage test" + fail "M18: Could not start host-side fake Telegram Bot API" + fail "M19: Telegram OpenClaw mock message send could not run without fake Telegram" fi -else - skip "M18: TELEGRAM_BOT_TOKEN_REAL not set — skipping real Telegram round-trip" - skip "M19: TELEGRAM_BOT_TOKEN_REAL not set — skipping sendMessage test" fi -if [ -n "${DISCORD_BOT_TOKEN_REAL:-}" ]; then +if [ -n "${DISCORD_BOT_TOKEN_REAL:-}" ] && [ -n "${DISCORD_CHANNEL_ID_E2E:-}" ]; then if [ "$dc_status" = "200" ]; then pass "M20: Discord users/@me returned 200 with real token" else fail "M20: Expected Discord users/@me 200 with real token, got: $dc_status" fi + + info "Sending Discord test message through OpenClaw plugin to channel ${DISCORD_CHANNEL_ID_E2E}..." + dc_send_result=$(run_openclaw_message_send \ + "discord" \ + "channel:${DISCORD_CHANNEL_ID_E2E}" \ + "NemoClaw OpenClaw Discord plugin E2E $(date -u +%Y-%m-%dT%H:%M:%SZ)" || true) + dc_send_exit=$(printf '%s\n' "$dc_send_result" | openclaw_message_send_exit_code) + + if [ "$dc_send_exit" = "0" ]; then + pass "M21: Discord openclaw message send succeeded through plugin" + else + fail "M21: Discord openclaw message send failed: ${dc_send_result:0:300}" + fi else - skip "M20: DISCORD_BOT_TOKEN_REAL not set — skipping real Discord round-trip" + discord_mock_channel_id="${DISCORD_CHANNEL_ID_E2E:-420000000000000123}" + discord_mock_text="NemoClaw OpenClaw Discord plugin mock E2E" + info "Complete real Discord credentials are not available — using host-side fake Discord message API" + if start_fake_discord_message_api "$DISCORD_TOKEN"; then + pass "M20: Host-side fake Discord message API started for OpenClaw plugin send" + if apply_fake_discord_message_api_policy "$SANDBOX_NAME" "$FAKE_DISCORD_MESSAGE_API_PORT" >/tmp/nemoclaw-fake-discord-message-policy.log 2>&1; then + pass "M20a: Applied REST policy for host-side fake Discord message API" + dc_mock_send_result=$(run_fake_discord_plugin_send_proof "$FAKE_DISCORD_MESSAGE_API_PORT" "$discord_mock_channel_id" "$discord_mock_text" || true) + dc_mock_capture=$(check_fake_discord_message_capture "$discord_mock_channel_id" "$discord_mock_text" || true) + + if echo "$dc_mock_send_result" | grep -q '"ok":true' && [ "$dc_mock_capture" = "OK" ]; then + pass "M21: Discord installed OpenClaw send helper posted through host mock" + elif ! echo "$dc_mock_send_result" | grep -q '"ok":true'; then + fail "M21: Discord OpenClaw mock message send failed: ${dc_mock_send_result:0:500}" + else + fail "M21: Fake Discord did not capture the expected rewritten message: ${dc_mock_capture:0:300}" + fi + else + fail "M20a: Failed to apply fake Discord message policy: $(tail -20 /tmp/nemoclaw-fake-discord-message-policy.log 2>/dev/null | tr '\n' ' ' | cut -c1-300)" + fail "M21: Discord OpenClaw mock message send could not run without fake Discord policy" + fi + else + fail "M20: Could not start host-side fake Discord message API" + fail "M21: Discord OpenClaw mock message send could not run without fake Discord" + fi +fi + +if [ -n "${SLACK_BOT_TOKEN_REAL:-}" ] && [ -n "${SLACK_CHANNEL_ID_E2E:-}" ]; then + pass "M22: Complete real Slack credentials are available for live OpenClaw send" + info "Sending Slack test message through OpenClaw plugin to channel ${SLACK_CHANNEL_ID_E2E}..." + sl_send_result=$(run_openclaw_message_send \ + "slack" \ + "channel:${SLACK_CHANNEL_ID_E2E}" \ + "NemoClaw OpenClaw Slack plugin E2E $(date -u +%Y-%m-%dT%H:%M:%SZ)" || true) + sl_send_exit=$(printf '%s\n' "$sl_send_result" | openclaw_message_send_exit_code) + + if [ "$sl_send_exit" = "0" ]; then + pass "M23: Slack openclaw message send succeeded through plugin" + else + fail "M23: Slack openclaw message send failed: ${sl_send_result:0:300}" + fi +else + info "Complete real Slack credentials are not available — requiring installed OpenClaw Slack helper proof against host fake Slack" + if [ "$slack_openclaw_plugin_mock_send_ok" = "1" ]; then + pass "M22: Slack host mock accepted the OpenShell-rewritten bot token" + pass "M23: Slack installed OpenClaw send helper posted through host mock" + else + fail "M22: Slack host mock did not prove OpenShell-rewritten bot token through installed OpenClaw helper" + fail "M23: Slack installed OpenClaw send helper did not post through host mock" + fi fi # ══════════════════════════════════════════════════════════════════ diff --git a/test/generate-openclaw-config.test.ts b/test/generate-openclaw-config.test.ts index b2a2b0cfab2..f383d91ec4c 100644 --- a/test/generate-openclaw-config.test.ts +++ b/test/generate-openclaw-config.test.ts @@ -232,6 +232,8 @@ describe("generate-openclaw-config.py: config generation", () => { const channels = Buffer.from(JSON.stringify(["whatsapp"])).toString("base64"); const config = runConfigScript({ NEMOCLAW_MESSAGING_CHANNELS_B64: channels }); expect(config.channels.whatsapp).toBeDefined(); + expect(config.channels.whatsapp.enabled).toBe(true); + expect(config.plugins.entries.whatsapp).toEqual({ enabled: true }); const account = config.channels.whatsapp.accounts.default; expect(account.enabled).toBe(true); expect(account.healthMonitor).toEqual({ enabled: false }); @@ -243,9 +245,13 @@ describe("generate-openclaw-config.py: config generation", () => { it("keeps WhatsApp config alongside token-based channels in the same run", () => { const channels = Buffer.from(JSON.stringify(["telegram", "whatsapp"])).toString("base64"); const config = runConfigScript({ NEMOCLAW_MESSAGING_CHANNELS_B64: channels }); + expect(config.channels.telegram.enabled).toBe(true); + expect(config.plugins.entries.telegram).toEqual({ enabled: true }); expect(config.channels.telegram.accounts.default.botToken).toBe( "openshell:resolve:env:TELEGRAM_BOT_TOKEN", ); + expect(config.channels.whatsapp.enabled).toBe(true); + expect(config.plugins.entries.whatsapp).toEqual({ enabled: true }); expect(config.channels.whatsapp.accounts.default.enabled).toBe(true); expect(config.channels.whatsapp.accounts.default.botToken).toBeUndefined(); }); @@ -461,6 +467,10 @@ describe("generate-openclaw-config.py: config generation", () => { proxyUrl: "http://10.200.0.1:3128", loopbackMode: "proxy", }); + expect(config.channels.telegram.enabled).toBe(true); + expect(config.plugins.entries.telegram).toEqual({ enabled: true }); + expect(config.channels.discord.enabled).toBe(true); + expect(config.plugins.entries.discord).toEqual({ enabled: true }); expect(config.channels.telegram.accounts.default.botToken).toBe( "openshell:resolve:env:TELEGRAM_BOT_TOKEN", ); diff --git a/test/openclaw-build-messaging-plugins.test.ts b/test/openclaw-build-messaging-plugins.test.ts new file mode 100644 index 00000000000..05ffc1a4505 --- /dev/null +++ b/test/openclaw-build-messaging-plugins.test.ts @@ -0,0 +1,153 @@ +// @ts-nocheck +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Functional tests for scripts/openclaw-build-messaging-plugins.py. + +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 SCRIPT_PATH = path.join( + import.meta.dirname, + "..", + "scripts", + "openclaw-build-messaging-plugins.py", +); + +function channelsB64(channels: string[]): string { + return Buffer.from(JSON.stringify(channels)).toString("base64"); +} + +function runDryRun(envOverrides: Record = {}) { + return spawnSync("python3", [SCRIPT_PATH, "--dry-run"], { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + env: { + PATH: process.env.PATH || "/usr/bin:/bin", + ...envOverrides, + }, + timeout: 10_000, + }); +} + +function parseDryRun(envOverrides: Record = {}) { + const result = runDryRun(envOverrides); + expect(result.status, result.stderr).toBe(0); + return JSON.parse(result.stdout); +} + +describe("openclaw-build-messaging-plugins.py", () => { + it("pins selected external messaging plugins to OPENCLAW_VERSION", () => { + const payload = parseDryRun({ + OPENCLAW_VERSION: "2026.5.22", + NEMOCLAW_MESSAGING_CHANNELS_B64: channelsB64([ + "telegram", + "discord", + "slack", + "whatsapp", + ]), + }); + + expect(payload.installSpecs).toEqual([ + "@openclaw/discord@2026.5.22", + "@openclaw/slack@2026.5.22", + "@openclaw/whatsapp@2026.5.22", + ]); + expect(payload.doctorEnv).toEqual({ + DISCORD_BOT_TOKEN: "openshell:resolve:env:DISCORD_BOT_TOKEN", + SLACK_APP_TOKEN: "xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN", + SLACK_BOT_TOKEN: "xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN", + TELEGRAM_BOT_TOKEN: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", + }); + }); + + it("does not inject placeholder token env vars for unselected channels", () => { + const payload = parseDryRun({ + OPENCLAW_VERSION: "2026.5.22", + NEMOCLAW_MESSAGING_CHANNELS_B64: channelsB64(["discord", "discord"]), + }); + + expect(payload.channels).toEqual(["discord"]); + expect(payload.installSpecs).toEqual(["@openclaw/discord@2026.5.22"]); + expect(payload.doctorEnv).toEqual({ + DISCORD_BOT_TOKEN: "openshell:resolve:env:DISCORD_BOT_TOKEN", + }); + }); + + it("does not require OPENCLAW_VERSION when no external messaging plugin is selected", () => { + const payload = parseDryRun({ + NEMOCLAW_MESSAGING_CHANNELS_B64: channelsB64(["telegram"]), + }); + + expect(payload.installSpecs).toEqual([]); + expect(payload.doctorEnv).toEqual({ + TELEGRAM_BOT_TOKEN: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", + }); + }); + + it("fails fast on malformed channel payloads", () => { + const result = runDryRun({ + OPENCLAW_VERSION: "2026.5.22", + NEMOCLAW_MESSAGING_CHANNELS_B64: "not-base64-json", + }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("NEMOCLAW_MESSAGING_CHANNELS_B64"); + }); + + it("runs pinned installs before doctor and limits doctor env injection to the doctor command", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-message-plugins-")); + const tracePath = path.join(tmp, "openclaw.trace"); + const fakeOpenclaw = path.join(tmp, "openclaw"); + fs.writeFileSync( + fakeOpenclaw, + [ + "#!/bin/sh", + "printf '%s|%s|%s|%s|%s|%s|%s\\n' \"$1\" \"$2\" \"$3\" \"$4\" \"${TELEGRAM_BOT_TOKEN:-}\" \"${DISCORD_BOT_TOKEN:-}\" \"${SLACK_BOT_TOKEN:-}\" >> \"$OPENCLAW_TRACE\"", + "exit 0", + "", + ].join("\n"), + { mode: 0o755 }, + ); + + try { + const result = spawnSync("python3", [SCRIPT_PATH], { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + env: { + PATH: `${tmp}:${process.env.PATH || "/usr/bin:/bin"}`, + OPENCLAW_TRACE: tracePath, + OPENCLAW_VERSION: "2026.5.22", + NEMOCLAW_MESSAGING_CHANNELS_B64: channelsB64([ + "telegram", + "discord", + "slack", + "whatsapp", + ]), + }, + timeout: 10_000, + }); + + expect(result.status, result.stderr).toBe(0); + expect(fs.readFileSync(tracePath, "utf-8").trim().split("\n")).toEqual([ + "plugins|install|@openclaw/discord@2026.5.22||||", + "plugins|install|@openclaw/slack@2026.5.22||||", + "plugins|install|@openclaw/whatsapp@2026.5.22||||", + [ + "doctor", + "--fix", + "--non-interactive", + "", + "openshell:resolve:env:TELEGRAM_BOT_TOKEN", + "openshell:resolve:env:DISCORD_BOT_TOKEN", + "xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN", + ].join("|"), + ]); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); +}); diff --git a/test/sandbox-build-context.test.ts b/test/sandbox-build-context.test.ts index a31a21fe917..cfbf5f54397 100644 --- a/test/sandbox-build-context.test.ts +++ b/test/sandbox-build-context.test.ts @@ -74,6 +74,7 @@ describe("sandbox build context staging", () => { writeFixture(path.join("scripts", "codex-acp-wrapper.sh")); writeFixture(path.join("scripts", "lib", "sandbox-init.sh")); writeFixture(path.join("scripts", "generate-openclaw-config.py")); + writeFixture(path.join("scripts", "openclaw-build-messaging-plugins.py")); writeFixture(path.join("scripts", "seed-wechat-accounts.py")); writeFixture(path.join("scripts", "patch-openclaw-tool-catalog.js")); writeFixture(path.join("scripts", "patch-openclaw-chat-send.js")); @@ -246,6 +247,9 @@ 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", "openclaw-build-messaging-plugins.py")), + ).toBe(true); expect(fs.existsSync(path.join(buildCtx, "scripts", "seed-wechat-accounts.py"))).toBe(true); expect(fs.existsSync(path.join(buildCtx, "scripts", "patch-openclaw-tool-catalog.js"))).toBe( true, diff --git a/test/sandbox-provisioning.test.ts b/test/sandbox-provisioning.test.ts index e0ab6b2afa8..e6dbcb1121d 100644 --- a/test/sandbox-provisioning.test.ts +++ b/test/sandbox-provisioning.test.ts @@ -723,6 +723,7 @@ 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, "openclaw-build-messaging-plugins.py"), path.join(localLib, "seed-wechat-accounts.py"), path.join(localLib, "ws-proxy-fix.js"), pluginFile, @@ -752,11 +753,15 @@ describe("sandbox provisioning: copied OpenClaw helper permissions (#2861)", () const generatorMode = ( fs.statSync(path.join(localLib, "generate-openclaw-config.py")).mode & 0o777 ).toString(8); + const messagingPluginMode = ( + fs.statSync(path.join(localLib, "openclaw-build-messaging-plugins.py")).mode & 0o777 + ).toString(8); const pluginDirMode = (fs.statSync(pluginDir).mode & 0o777).toString(8); const pluginMode = (fs.statSync(pluginFile).mode & 0o777).toString(8); const nestedPluginDirMode = (fs.statSync(nestedPluginDir).mode & 0o777).toString(8); const nestedPluginMode = (fs.statSync(nestedPluginFile).mode & 0o777).toString(8); expect(generatorMode).toBe("755"); + expect(messagingPluginMode).toBe("755"); expect(pluginDirMode).toBe("755"); expect(pluginMode).toBe("644"); expect(nestedPluginDirMode).toBe("755"); diff --git a/test/validate-e2e-coverage.test.ts b/test/validate-e2e-coverage.test.ts index 49175ee0ea3..5f8947fb559 100644 --- a/test/validate-e2e-coverage.test.ts +++ b/test/validate-e2e-coverage.test.ts @@ -244,4 +244,46 @@ describe("nightly E2E workflow validation", () => { `Invalid jobs: ${invalid.join(", ")}`, ).toEqual([]); }); + + it("messaging providers nightly can receive optional live message secrets", () => { + const expectedSecretNames = [ + "TELEGRAM_BOT_TOKEN_REAL", + "TELEGRAM_CHAT_ID_E2E", + "DISCORD_BOT_TOKEN_REAL", + "DISCORD_CHANNEL_ID_E2E", + "SLACK_BOT_TOKEN_REAL", + "SLACK_APP_TOKEN_REAL", + "SLACK_CHANNEL_ID_E2E", + ]; + + const reusableSecrets = (reusableRunner.on as Record) + .workflow_call as Record; + const reusableSecretDefs = reusableSecrets.secrets as Record; + const runnerJobs = reusableRunner.jobs as Record; + const runStepEnv = getStepEnv(runnerJobs.run, "Run E2E script") ?? {}; + const jobs = workflow.jobs as Record; + const messagingJob = jobs["messaging-providers-e2e"] as Record; + const messagingSecrets = messagingJob.secrets as Record; + const missing: string[] = []; + + for (const name of expectedSecretNames) { + if (!reusableSecretDefs[name]) { + missing.push(`workflow_call.secrets.${name}`); + } + if (runStepEnv[name] !== `\${{ secrets.${name} }}`) { + missing.push(`e2e-script Run E2E script env.${name}`); + } + if (messagingSecrets[name] !== `\${{ secrets.${name} }}`) { + missing.push(`nightly messaging-providers-e2e secrets.${name}`); + } + } + + expect( + missing, + `messaging-providers-e2e must pass optional live-message credentials and ` + + `targets through the reusable runner so Phase 6 can exercise ` + + `openclaw message send when repository secrets are configured. ` + + `Missing: ${missing.join(", ")}`, + ).toEqual([]); + }); });