From fdd2cc2f6accf5462bc9e6315ee5be832b0b136f Mon Sep 17 00:00:00 2001 From: "Angnuo Li (from Dev Box)" Date: Tue, 13 Oct 2026 13:49:03 +0800 Subject: [PATCH] Upgrade default OpenClaw to 2026.10.2 Align Node.js engines and installer/CI gates, migrate the bundled Weixin SDK integration, rebuild its runtime from maintained sources, and protect existing Node installations during rollback. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/pr-build.yml | 18 +- .github/workflows/pr-security-check.yml | 4 +- .github/workflows/release.yml | 10 +- .prettierignore | 2 +- CLAUDE.md | 6 +- CONTRIBUTING.md | 2 +- README.md | 10 +- README.zh-CN.md | 10 +- build.ps1 | 11 +- deployer/config.py | 4 +- deployer/openclaw_version.py | 13 +- deployer/windows_setup.py | 76 ++++++- desktop/package.json | 3 +- desktop/scripts/stage-weixin-runtime.mjs | 77 +++++++ desktop/scripts/stage-weixin-runtime.test.mjs | 207 ++++++++++++++++++ plugins/openclaw-weixin/README.md | 5 + plugins/openclaw-weixin/README.zh_CN.md | 5 + plugins/openclaw-weixin/package.json | 14 +- plugins/openclaw-weixin/src/auth/accounts.ts | 27 ++- .../openclaw-weixin/src/auth/authorization.ts | 25 +++ plugins/openclaw-weixin/src/auth/pairing.ts | 2 +- plugins/openclaw-weixin/src/channel.ts | 2 +- plugins/openclaw-weixin/src/compat.ts | 2 +- .../src/messaging/process-message.ts | 49 ++--- plugins/openclaw-weixin/src/util/logger.ts | 2 +- scripts/windows/setup-dependencies.ps1 | 18 +- tests/test_openclaw_version.py | 49 +++-- tests/test_runtime_gates.py | 89 +++++++- tests/test_windows_setup_upgrade.py | 131 +++++++++-- 29 files changed, 719 insertions(+), 154 deletions(-) create mode 100644 desktop/scripts/stage-weixin-runtime.mjs create mode 100644 desktop/scripts/stage-weixin-runtime.test.mjs create mode 100644 plugins/openclaw-weixin/src/auth/authorization.ts diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 15e4658..9bf2736 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -42,7 +42,7 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - # Toolchain (Node 22) is pre-installed on the self-hosted runner. + # A supported Node.js toolchain is pre-installed on the self-hosted runner. # actions/setup-node is unreliable under the NetworkService account # (HKLM/tool-cache permission issues), so just verify the version. - name: Verify pre-installed Node.js @@ -51,10 +51,9 @@ jobs: $node = (& node --version).TrimStart('v') $parsed = [version]$node $supported = - ($parsed.Major -eq 22 -and $parsed -ge [version]'22.22.3') -or - ($parsed.Major -eq 24 -and $parsed -ge [version]'24.15.0') -or - ($parsed.Major -ge 25 -and $parsed -ge [version]'25.9.0') - if (-not $supported) { throw "Node $node is unsupported by OpenClaw 2026.7.1-1" } + ($parsed.Major -eq 24 -and $parsed -ge [version]'24.16.0') -or + ($parsed.Major -ge 26 -and $parsed -ge [version]'26.1.0') + if (-not $supported) { throw "Node $node is unsupported by OpenClaw 2026.10.2" } Write-Host "node $node" - name: Install dependencies (all packages) @@ -135,7 +134,7 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - # Toolchain (Node 22, .NET 9 SDK, Python 3.12+) is pre-installed on + # Toolchain (supported Node.js, .NET 9 SDK, Python 3.12+) is pre-installed on # the self-hosted runner. We only verify versions here; installing # via actions/setup-* is unreliable under the NetworkService account # (HKLM/tool-cache permission issues). @@ -145,10 +144,9 @@ jobs: $node = (& node --version).TrimStart('v') $parsed = [version]$node $supported = - ($parsed.Major -eq 22 -and $parsed -ge [version]'22.22.3') -or - ($parsed.Major -eq 24 -and $parsed -ge [version]'24.15.0') -or - ($parsed.Major -ge 25 -and $parsed -ge [version]'25.9.0') - if (-not $supported) { throw "Node $node is unsupported by OpenClaw 2026.7.1-1" } + ($parsed.Major -eq 24 -and $parsed -ge [version]'24.16.0') -or + ($parsed.Major -ge 26 -and $parsed -ge [version]'26.1.0') + if (-not $supported) { throw "Node $node is unsupported by OpenClaw 2026.10.2" } Write-Host "node $node" $dotnet = & dotnet --version if ([version]($dotnet -split '-')[0] -lt [version]'9.0') { throw "dotnet $dotnet < 9.0" } diff --git a/.github/workflows/pr-security-check.yml b/.github/workflows/pr-security-check.yml index 1d3b30f..84a28a1 100644 --- a/.github/workflows/pr-security-check.yml +++ b/.github/workflows/pr-security-check.yml @@ -74,10 +74,10 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - name: Setup Node.js 22 + - name: Setup Node.js 24 uses: actions/setup-node@v4 with: - node-version: "22" + node-version: "24.16.0" # Install dependencies - name: Install root dependencies diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ae3f89a..3ac1b81 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -76,10 +76,10 @@ jobs: # (reliable on hosted runners, unlike on the self-hosted NetworkService # account). build.ps1 then installs its own npm/pip deps and locates NSIS # from electron-builder's cache. - - name: Setup Node.js 22 + - name: Setup Node.js 24 uses: actions/setup-node@v4 with: - node-version: "22" + node-version: "24.16.0" - name: Setup .NET 9 SDK uses: actions/setup-dotnet@v4 @@ -95,7 +95,11 @@ jobs: run: | $ErrorActionPreference = 'Stop' $node = (& node --version).TrimStart('v') - if ([version]$node -lt [version]'22.0') { throw "Node $node < 22" } + $parsed = [version]$node + $supported = + ($parsed.Major -eq 24 -and $parsed -ge [version]'24.16.0') -or + ($parsed.Major -ge 26 -and $parsed -ge [version]'26.1.0') + if (-not $supported) { throw "Node $node is unsupported by OpenClaw 2026.10.2" } Write-Host "node $node" $dotnet = & dotnet --version if ([version]($dotnet -split '-')[0] -lt [version]'9.0') { throw "dotnet $dotnet < 9.0" } diff --git a/.prettierignore b/.prettierignore index bd9e7cf..8e26cfe 100644 --- a/.prettierignore +++ b/.prettierignore @@ -51,7 +51,7 @@ skills/**/_meta.json # Studio backend test fixtures (if any get added later) desktop/studio-backend/data/ -# Mirrored third-party plugin source; runtime comes from the pinned release archive. +# Mirrored third-party plugin source with targeted MicroClaw SDK compatibility patches. plugins/openclaw-weixin/ # Top-level legal / generated text diff --git a/CLAUDE.md b/CLAUDE.md index 89b1a6a..38ca101 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,6 +42,7 @@ cd desktop npm run test # run all main-process tests (vitest, node env) npm run test:watch # watch mode npm run test:coverage # with coverage +npm run test:weixin-runtime # offline staging + emitted Weixin compatibility tests cd desktop/renderer npm run test # run all renderer tests (vitest, jsdom env) @@ -137,7 +138,8 @@ Custom skills installed to `~/.openclaw/skills/`. Each skill has `SKILL.md` (met - `.env` (gitignored): `MODEL_API_KEY`, `MODEL_BASE_URL`, `MODEL_NAME`, `BRAVE_API_KEY` - `openclaw.json`: Auto-generated by installer — gateway port, skill allowlists, model config -- Node.js installed via the official signed `.msi` to `%ProgramFiles%\nodejs\` (per-machine, UAC-elevated; override with `OPENCLAW_NODE_DIR`); existing system Node ≥22.16 at a standard path is reused as-is; OpenClaw state dir at `%APPDATA%/openclaw` +- OpenClaw's default `stable` channel is pinned in `deployer/openclaw_version.py`; keep the legacy dependency helper, CI gates, and runtime tests aligned when upgrading it. +- Node.js installed via the official signed `.msi` to `%ProgramFiles%\nodejs\` (per-machine, UAC-elevated; override with `OPENCLAW_NODE_DIR`); an existing standard-path Node satisfying `>=24.16.0 <25 || >=26.1.0` is reused; OpenClaw state dir at `%APPDATA%/openclaw` ## Key Constants (desktop/src/constants.ts) @@ -149,6 +151,6 @@ Custom skills installed to `~/.openclaw/skills/`. Each skill has `SKILL.md` (met ## Prerequisites -- Node.js 22+ (build.ps1 auto-detects `%ProgramFiles%\nodejs\`, the per-user `%LocalAppData%\Programs\nodejs\`, or the legacy `~/.openclaw-node/`) +- Node.js 24.16+ (24.x) or 26.1+ (build.ps1 auto-detects `%ProgramFiles%\nodejs\`, the per-user `%LocalAppData%\Programs\nodejs\`, or the legacy `~/.openclaw-node/`) - Python 3.10+ (for installer and PyInstaller packaging) - Windows 10/11 (Electron apps are Windows-only builds) \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 896d2d1..69899e5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -46,7 +46,7 @@ contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additio ### Development Setup 1. Clone the repository -2. Install Node.js 22+ +2. Install Node.js 24.16+ (24.x) or 26.1+ 3. Install dependencies: ```bash cd desktop && npm install diff --git a/README.md b/README.md index c524292..fd88019 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ MicroClaw targets **Windows 10/11**. For most users, the only thing you need to Build the installer from source, then run it: -> **Build prerequisites** (only needed to run `build.ps1`): **Node.js 22+** and **Python 3.10+**. Install the Python build dependencies with `pip install -r requirements.txt` (this includes PyInstaller, used to package the installer exe). End users running the packaged `MicroClawInstaller.exe` do **not** need Python. +> **Build prerequisites** (only needed to run `build.ps1`): **Node.js 24.16+ (24.x) or 26.1+** and **Python 3.10+**. Install the Python build dependencies with `pip install -r requirements.txt` (this includes PyInstaller, used to package the installer exe). End users running the packaged `MicroClawInstaller.exe` do **not** need Python. ```powershell .\build.ps1 # produces dist/MicroClawInstaller/MicroClawInstaller.exe @@ -124,14 +124,16 @@ Build the installer from source, then run it: The installer handles the Windows-side setup in a single run: - Git for Windows (PortableGit → `~/.openclaw-git`) -- Node.js 22+ via the official signed `.msi` (per-machine install to `%ProgramFiles%\nodejs\`, UAC-elevated; an existing system Node ≥22.16 at that path is reused as-is) -- OpenClaw Gateway (`npm install -g openclaw`) +- Node.js 24.x via the official signed `.msi` (per-machine install to `%ProgramFiles%\nodejs\`, UAC-elevated; an existing standard-path Node satisfying `>=24.16.0 <25 || >=26.1.0` is reused) +- OpenClaw Gateway, pinned to **2026.10.2** on the default `stable` channel (`npm install -g openclaw@2026.10.2`) - Configures the npm registry mirror and V8 compile cache - Installs the MicroClaw desktop client, managed skills, AppContainer sandbox, WeChat plugin - Adds Windows Defender exclusions, creates desktop shortcuts (including one-click Uninstall) After install, launch **MicroClaw** from the Start menu or the desktop shortcut. The desktop app auto-starts the Gateway. +To upgrade an existing installation, rerun the MicroClaw installer. It preserves the transactional backup, validation, and rollback flow; the legacy dependency helper refuses in-place OpenClaw upgrades. Old Node 22/23/25 configurations are advanced to a supported line. Upgrade rollback keeps the updated, pre-existing Node directory instead of deleting it and any restored OpenClaw package it contains. `stable` is an exact version pin, not npm's moving `latest` tag. + ### 2. Add your model credentials On first launch, the desktop app opens a SetupWizard only when model configuration is missing. The required user input is your model connection: @@ -276,7 +278,7 @@ This script sequentially: ### Prerequisites -- Node.js 22+ +- Node.js 24.16+ (24.x) or 26.1+ - Python 3.10+ — install build deps with `pip install -r requirements.txt` (includes PyInstaller) - .NET 9 SDK (for the AppContainer launcher) - npm dependencies installed (`cd desktop && npm install`) diff --git a/README.zh-CN.md b/README.zh-CN.md index cdd830f..2201d53 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -60,7 +60,7 @@ MicroClaw 仅支持 **Windows 10/11**。对大多数用户来说,真正需要 从源码构建安装器后运行: -> **构建前置依赖**(仅运行 `build.ps1` 时需要):**Node.js 22+** 与 **Python 3.10+**。使用 `pip install -r requirements.txt` 安装 Python 构建依赖(其中包含用于打包安装器 exe 的 PyInstaller)。终端用户运行已打包的 `MicroClawInstaller.exe` **无需** 安装 Python。 +> **构建前置依赖**(仅运行 `build.ps1` 时需要):**Node.js 24.16+(24.x)或 26.1+** 与 **Python 3.10+**。使用 `pip install -r requirements.txt` 安装 Python 构建依赖(其中包含用于打包安装器 exe 的 PyInstaller)。终端用户运行已打包的 `MicroClawInstaller.exe` **无需** 安装 Python。 ```powershell .\build.ps1 # 生成 dist/MicroClawInstaller/MicroClawInstaller.exe @@ -70,14 +70,16 @@ MicroClaw 仅支持 **Windows 10/11**。对大多数用户来说,真正需要 安装器会在一次运行中完成 Windows 侧的主要准备工作: - Git for Windows(PortableGit → `~/.openclaw-git`) -- Node.js 22+,通过官方签名 `.msi` 以 per-machine 方式安装到 `%ProgramFiles%\nodejs\`(UAC 提权;若该路径已存在 ≥22.16 的系统 Node 则直接复用) -- OpenClaw Gateway(`npm install -g openclaw`) +- Node.js 24.x,通过官方签名 `.msi` 以 per-machine 方式安装到 `%ProgramFiles%\nodejs\`(UAC 提权;标准路径下已有满足 `>=24.16.0 <25 || >=26.1.0` 的 Node 则直接复用) +- OpenClaw Gateway,默认 `stable` 通道固定为 **2026.10.2**(`npm install -g openclaw@2026.10.2`) - 配置 npm 镜像源与 V8 编译缓存 - 安装 MicroClaw 桌面客户端、托管技能、AppContainer 沙箱、微信插件 - 添加 Windows Defender 排除项,创建桌面快捷方式(含一键卸载) 安装完成后,从开始菜单或桌面快捷方式启动 **MicroClaw**。桌面应用会自动拉起 Gateway。 +升级已有安装时,请重新运行 MicroClaw 安装器,继续使用事务备份、验证和失败回滚流程;旧版依赖脚本会拒绝直接覆盖升级 OpenClaw。旧配置中的 Node 22/23/25 会迁移到受支持的版本线。升级回滚会保留更新后的既有 Node 目录,避免连同已恢复的 OpenClaw 包一起删除。`stable` 是精确版本锁定,不会随 npm 的 `latest` 自动变化。 + ### 2. 只补上你的模型配置 首次启动时,只有在模型尚未配置的情况下才会进入 SetupWizard。这里真正需要用户填写的内容只有: @@ -191,7 +193,7 @@ npm run dev ### 前置条件 -- Node.js 22+ +- Node.js 24.16+(24.x)或 26.1+ - Python 3.10+ —— 通过 `pip install -r requirements.txt` 安装构建依赖(已包含 PyInstaller) - .NET 9 SDK(用于构建 AppContainer 启动器) - npm 依赖已安装(`cd desktop && npm install`) diff --git a/build.ps1 b/build.ps1 index 4345f15..745f10a 100644 --- a/build.ps1 +++ b/build.ps1 @@ -10,7 +10,7 @@ $env:PYTHONIOENCODING = 'utf-8' $root = Split-Path -Parent $MyInvocation.MyCommand.Path -# Prefer Node 22 from the standard per-user MSI install location, falling back +# Prefer Node from the standard MSI install locations, falling back # to the legacy zip-extract path and finally the system Node. $nodeCandidates = @( "$env:ProgramFiles\nodejs", @@ -42,7 +42,7 @@ if (-not $nodeFound) { Write-Host " ERROR: node.exe not found in any of:" -ForegroundColor Red foreach ($candidate in $nodeCandidates) { Write-Host " - $candidate" -ForegroundColor Red } Write-Host " - PATH (Get-Command node.exe)" -ForegroundColor Red - Write-Host " Install Node.js 22+ (https://nodejs.org/) and re-run build.ps1." -ForegroundColor Red + Write-Host " Install Node.js 24.16+ (24.x) or 26.1+ (https://nodejs.org/) and re-run build.ps1." -ForegroundColor Red exit 1 } @@ -247,6 +247,13 @@ try { } } +# Compile the maintained source over the verified upstream runtime so SDK +# compatibility changes reach both TypeScript and precompiled plugin entry points. +& node "$root\desktop\scripts\stage-weixin-runtime.mjs" $weixinStage +if ($LASTEXITCODE -ne 0) { + throw "Weixin SDK compatibility staging failed" +} + # The staged package is runtime-only. Removing devDependencies avoids pulling a # second OpenClaw toolchain if the staged package is inspected by npm tooling. $runtimePackage = Get-Content "$weixinStage\package.json" -Raw | ConvertFrom-Json diff --git a/deployer/config.py b/deployer/config.py index 13e8421..de1efef 100644 --- a/deployer/config.py +++ b/deployer/config.py @@ -10,6 +10,8 @@ from pathlib import Path from typing import Any +from deployer.openclaw_version import NODE_DEFAULT_MAJOR + def _get_app_dir() -> Path: """Return the directory where the exe (or script) lives.""" @@ -57,7 +59,7 @@ def _load_dotenv() -> None: DEFAULT_CONFIG: dict[str, Any] = { "node": { - "version": "22", + "version": NODE_DEFAULT_MAJOR, }, "openclaw": { "install_method": "npm", # npm | source diff --git a/deployer/openclaw_version.py b/deployer/openclaw_version.py index a667ef3..a9114c2 100644 --- a/deployer/openclaw_version.py +++ b/deployer/openclaw_version.py @@ -4,8 +4,10 @@ import re -OPENCLAW_TARGET_VERSION = "2026.7.1-1" -NODE_FALLBACK_VERSION = "22.22.3" +OPENCLAW_TARGET_VERSION = "2026.10.2" +NODE_DEFAULT_MAJOR = "24" +NODE_FALLBACK_VERSION = "24.16.0" +NODE_ENGINE_RANGE = ">=24.16.0 <25 || >=26.1.0" _NODE_VERSION_RE = re.compile(r"^v?(?P\d+)\.(?P\d+)\.(?P\d+)$") _OPENCLAW_VERSION_RE = re.compile(r"openclaw@(?P\S+)") @@ -25,10 +27,9 @@ def is_supported_node_version(value: str) -> bool: major, minor, patch = version return ( - (major == 22 and (minor, patch) >= (22, 3)) - or (major == 24 and (minor, patch) >= (15, 0)) - or (major == 25 and (minor, patch) >= (9, 0)) - or major > 25 + (major == 24 and (minor, patch) >= (16, 0)) + or (major == 26 and (minor, patch) >= (1, 0)) + or major > 26 ) diff --git a/deployer/windows_setup.py b/deployer/windows_setup.py index ca6b40b..c73d92e 100644 --- a/deployer/windows_setup.py +++ b/deployer/windows_setup.py @@ -36,6 +36,8 @@ prune_previous_committed_backups, ) from deployer.openclaw_version import ( + NODE_DEFAULT_MAJOR, + NODE_ENGINE_RANGE, NODE_FALLBACK_VERSION, OPENCLAW_TARGET_VERSION, is_supported_node_version, @@ -385,7 +387,7 @@ class WindowsSetup: def __init__(self, config, logger: DeployerLogger): self.cfg = config self.log = logger - self.node_version = config.get("node.version", "22") + self.node_version = config.get("node.version", NODE_DEFAULT_MAJOR) # Re-read env var at construction time (UI may have set it) self.node_dir = Path(os.environ.get("OPENCLAW_NODE_DIR", str(DEFAULT_NODE_DIR))) self._node_bin: Path | None = None @@ -920,7 +922,7 @@ def _download_and_verify_node_msi(self, version: str, msi_path: Path) -> bool: return False def _resolve_latest_version(self, major: str) -> str: - """Resolve '22' to the latest specific version like '22.14.0'.""" + """Resolve a major line such as '24' to its latest specific version.""" self.log.debug(f"Resolving latest Node.js {major}.x version…") import json import re @@ -991,7 +993,7 @@ def check_node_windows(self) -> bool: elif ver: self.log.info( "Managed Node.js " - f"{ver} is outdated (need >=22.22.3, <23 / >=24.15.0, <25 / >=25.9.0), " + f"{ver} is unsupported (need {NODE_ENGINE_RANGE}), " "will reinstall" ) @@ -1062,7 +1064,7 @@ def _resolve_target_node_version(self) -> str: """Resolve the Node.js version to install, never downgrading. Starts from the configured target line (``self.node_version``, default - ``22``) but bumps up to the major of any already-installed Node when + ``24``) but bumps up to the major of any already-installed Node when that is higher, so the per-machine MSI performs an upgrade rather than a blocked downgrade. """ @@ -1078,8 +1080,30 @@ def _resolve_target_node_version(self) -> str: "to install an older version over a newer one." ) target_line = str(installed_major) + target_major = installed_major - return self._resolve_latest_version(target_line) + supported_major = max(target_major, int(NODE_DEFAULT_MAJOR)) + if supported_major == 25: + supported_major = 26 + if supported_major != target_major: + self.log.warn( + f"Node.js {target_line}.x is unsupported by OpenClaw {OPENCLAW_TARGET_VERSION}; " + f"targeting {supported_major}.x instead." + ) + target_line = str(supported_major) + + version = self._resolve_latest_version(target_line) + if ( + installed_major is not None + and _VERSION_RE.fullmatch(version) + and int(version.split(".")[0]) < installed_major + ): + raise NodeInstallBlocked( + f"Could not resolve a supported Node.js release at or above the installed " + f"{installed_major}.x line; refusing to downgrade to {version}. " + "Check network access to the Node.js version index and retry." + ) + return version def install_node_windows(self) -> bool: """Download and install Node.js on Windows via the official signed MSI. @@ -1091,11 +1115,18 @@ def install_node_windows(self) -> bool: produced on some configurations. """ self.log.step(f"Installing Node.js on Windows ({self._mirror_name})…") + existing_node_directory = self.node_dir.exists() version = self._resolve_target_node_version() if not _VERSION_RE.match(version): self.log.error(f"Invalid resolved version: {version!r}") return False + if not is_supported_node_version(version): + self.log.error( + f"Resolved Node.js {version} is unsupported by OpenClaw " + f"{OPENCLAW_TARGET_VERSION} (need {NODE_ENGINE_RANGE})" + ) + return False self.log.info(f"Resolved version: v{version}") tmp_dir: Path | None = None @@ -1193,12 +1224,20 @@ def install_node_windows(self) -> bool: self._node_bin = self.node_dir ver = self._get_node_version(str(node_exe)) - if not ver: - self.log.error("Node.js installed but verification failed") + if not ver or not is_supported_node_version(ver): + self.log.error( + f"Node.js installed but verification failed: {ver!r} (need {NODE_ENGINE_RANGE})" + ) return False self.log.success(f"Node.js {ver} installed to {self.node_dir}") + # Removing an upgraded prerequisite would also delete the restored + # OpenClaw package when npm's prefix shares this directory. + if existing_node_directory: + self.log.info("Preserving the existing Node.js directory during upgrade rollback.") + return True + # Record that MicroClaw owns this Node install so the uninstaller # knows it's safe to remove. Without this marker, uninstall # preserves any Node.js it finds (assumes user-installed). @@ -3353,10 +3392,25 @@ def _find_bundled_weixin_plugin(self) -> Path | None: candidates.append(Path(sys.executable).parent / "plugins" / "openclaw-weixin") # Project root (development scenario) project_root = Path(__file__).resolve().parent.parent - candidates.append(project_root / "plugins" / "openclaw-weixin") + candidates.append(project_root / "dist" / "openclaw-weixin") for p in candidates: if p.is_dir() and (p / "package.json").exists(): - return p + try: + package = json.loads((p / "package.json").read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + self.log.warn(f"Could not read bundled Weixin metadata at {p}: {error}") + continue + compatibility = package.get("microclaw") if isinstance(package, dict) else None + if ( + isinstance(compatibility, dict) + and compatibility.get("sdkCompatibility") == OPENCLAW_TARGET_VERSION + and (p / "dist" / "index.js").is_file() + ): + return p + self.log.warn( + f"Weixin bundle at {p} is not staged for OpenClaw {OPENCLAW_TARGET_VERSION}; " + "rebuild the installer with build.ps1." + ) return None @staticmethod @@ -3574,7 +3628,9 @@ def install_weixin_plugin(self) -> bool: plugin_dir = self._find_bundled_weixin_plugin() if not plugin_dir: - self.log.error("未找到插件安装包") + self.log.error( + "未找到插件安装包;从源码运行时请先运行 build.ps1 生成 dist/openclaw-weixin" + ) return False state_dir = Path.home() / ".openclaw" diff --git a/desktop/package.json b/desktop/package.json index f11dcec..56ce0e2 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -11,7 +11,8 @@ "build": "tsc && cd renderer && npm run build", "pack": "npm run build && electron-builder --win --x64 --dir", "dist": "npm run build && electron-builder --win", - "test": "vitest run", + "test": "npm run test:weixin-runtime && vitest run", + "test:weixin-runtime": "node --test scripts/stage-weixin-runtime.test.mjs", "test:watch": "vitest", "test:coverage": "vitest run --coverage", "lint": "eslint .", diff --git a/desktop/scripts/stage-weixin-runtime.mjs b/desktop/scripts/stage-weixin-runtime.mjs new file mode 100644 index 0000000..3826fad --- /dev/null +++ b/desktop/scripts/stage-weixin-runtime.mjs @@ -0,0 +1,77 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; + +export function stageWeixinRuntime(sourceDir, stageDir) { + const sourcePackage = JSON.parse(fs.readFileSync(path.join(sourceDir, "package.json"), "utf8")); + const stagedPackage = JSON.parse(fs.readFileSync(path.join(stageDir, "package.json"), "utf8")); + if (sourcePackage.version !== stagedPackage.version) { + throw new Error("Weixin source and vendored runtime versions do not match"); + } + if (path.resolve(sourceDir) === path.resolve(stageDir)) { + throw new Error("Weixin staging must not overwrite the source directory"); + } + + const files = ["index.ts"]; + function visit(relativeDir) { + for (const entry of fs.readdirSync(path.join(sourceDir, relativeDir), { + withFileTypes: true, + })) { + const relative = path.join(relativeDir, entry.name); + if (entry.isDirectory()) visit(relative); + else if (entry.isFile() && entry.name.endsWith(".ts") && !entry.name.endsWith(".test.ts")) { + files.push(relative); + } + } + } + visit("src"); + + let compiledCount = 0; + for (const relative of files) { + const source = fs.readFileSync(path.join(sourceDir, relative), "utf8"); + const stagedSource = path.join(stageDir, relative); + fs.mkdirSync(path.dirname(stagedSource), { recursive: true }); + fs.writeFileSync(stagedSource, source); + if (relative.endsWith(".d.ts")) continue; + + const compiled = ts.transpileModule(source, { + fileName: relative, + reportDiagnostics: true, + compilerOptions: { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ESNext, + }, + }); + const errors = (compiled.diagnostics ?? []).filter( + (diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error, + ); + if (errors.length > 0) { + throw new Error( + `${relative}: ${errors.map((error) => ts.flattenDiagnosticMessageText(error.messageText, "\n")).join("\n")}`, + ); + } + const stagedRuntime = path.join(stageDir, "dist", relative.replace(/\.ts$/, ".js")); + fs.mkdirSync(path.dirname(stagedRuntime), { recursive: true }); + fs.writeFileSync(stagedRuntime, compiled.outputText); + compiledCount++; + } + for (const name of ["package.json", "README.md", "README.zh_CN.md"]) { + fs.copyFileSync(path.join(sourceDir, name), path.join(stageDir, name)); + } + return compiledCount; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const stageDir = process.argv[2]; + if (!stageDir) throw new Error("Usage: node stage-weixin-runtime.mjs "); + const sourceDir = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "..", + "plugins", + "openclaw-weixin", + ); + const count = stageWeixinRuntime(sourceDir, path.resolve(stageDir)); + console.log(`Staged ${count} Weixin modules with the MicroClaw SDK compatibility changes.`); +} diff --git a/desktop/scripts/stage-weixin-runtime.test.mjs b/desktop/scripts/stage-weixin-runtime.test.mjs new file mode 100644 index 0000000..4a8f3d1 --- /dev/null +++ b/desktop/scripts/stage-weixin-runtime.test.mjs @@ -0,0 +1,207 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { after, before, test } from "node:test"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { stageWeixinRuntime } from "./stage-weixin-runtime.mjs"; + +const sourceDir = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "..", + "plugins", + "openclaw-weixin", +); +const retiredSdkPaths = new Set([ + "infra-runtime", + "config-runtime", + "channel-runtime", + "command-auth", +]); +const supportedSdkPaths = new Set([ + "plugin-entry", + "channel-config-schema", + "account-id", + "temp-path", + "file-lock", + "command-auth-native", + "config-mutation", + "hook-runtime", + "plugin-runtime", + "channel-outbound", + "reply-runtime", +]); +let tempDir; +let stageDir; +let compiledCount; + +before(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "microclaw-weixin-sdk-")); + stageDir = path.join(tempDir, "plugin"); + fs.mkdirSync(stageDir); + const archive = path.join(sourceDir, "vendor", "tencent-weixin-openclaw-weixin-2.4.6.tgz"); + assert.equal( + createHash("sha256").update(fs.readFileSync(archive)).digest("hex"), + "ef1c3600ca2fc0ee9076c1327af1e0d5d2e8e19fbb61e9f56c961fcde0bd07f6", + ); + execFileSync("tar", ["-xzf", archive, "-C", stageDir, "--strip-components=1"]); + compiledCount = stageWeixinRuntime(sourceDir, stageDir); + + // These narrow SDK test doubles exercise the emitted plugin modules without + // starting a Gateway, accessing the user's state, or depending on a registry. + const sdkDir = path.join(stageDir, "node_modules", "openclaw"); + fs.mkdirSync(sdkDir, { recursive: true }); + const modules = { + "account-id": "export const normalizeAccountId = (id) => id.trim();", + "temp-path": `export const resolvePreferredOpenClawTmpDir = () => ${JSON.stringify(path.join(tempDir, "logs"))};`, + "file-lock": "export const withFileLock = async (_path, _options, fn) => await fn();", + "command-auth-native": ` + export const shouldComputeCommandAuthorized = (body) => body.startsWith("/"); + export const resolveCommandAuthorizedFromAuthorizers = ({useAccessGroups, authorizers}) => + !useAccessGroups || authorizers.some((entry) => entry.configured && entry.allowed); + `, + "config-mutation": ` + export let config = {}; + export let failure; + export const reset = (value, error) => { config = value; failure = error; }; + export async function mutateConfigFile({mutate}) { + if (failure) throw failure; + const draft = structuredClone(config); + await mutate(draft, {attempt: 1}); + config = draft; + } + `, + }; + const exports = {}; + for (const [name, content] of Object.entries(modules)) { + exports[`./plugin-sdk/${name}`] = `./${name}.js`; + fs.writeFileSync(path.join(sdkDir, `${name}.js`), content); + } + fs.writeFileSync( + path.join(sdkDir, "package.json"), + JSON.stringify({ name: "openclaw", type: "module", exports }), + ); +}); + +after(() => { + if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +function stagedImport(relative) { + return import(pathToFileURL(path.join(stageDir, relative)).href); +} + +test("staged source and JavaScript use only the retained 10.2 SDK subpaths", () => { + const runtimeFiles = fs + .readdirSync(path.join(stageDir, "dist"), { recursive: true }) + .filter((file) => file.endsWith(".js")); + assert.equal(runtimeFiles.length, compiledCount); + const used = new Set(); + for (const relative of runtimeFiles) { + const runtime = fs.readFileSync(path.join(stageDir, "dist", relative), "utf8"); + for (const match of runtime.matchAll( + /(?:from\s*|import\()\s*["']openclaw\/plugin-sdk\/([^"']+)["']/g, + )) { + assert.ok(!retiredSdkPaths.has(match[1]), `${relative} still imports ${match[1]}`); + assert.ok(supportedSdkPaths.has(match[1]), `Review SDK subpath ${match[1]} in ${relative}`); + used.add(match[1]); + } + const sourceRelative = relative.replace(/\.js$/, ".ts"); + assert.equal( + fs.readFileSync(path.join(stageDir, sourceRelative), "utf8"), + fs.readFileSync(path.join(sourceDir, sourceRelative), "utf8"), + ); + } + for (const required of [ + "command-auth-native", + "config-mutation", + "temp-path", + "file-lock", + "channel-outbound", + ]) { + assert.ok(used.has(required), `Missing migrated SDK import ${required}`); + } + const pkg = JSON.parse(fs.readFileSync(path.join(stageDir, "package.json"), "utf8")); + assert.equal(pkg.peerDependencies.openclaw, ">=2026.10.2"); + assert.equal(pkg.microclaw.sdkCompatibility, "2026.10.2"); + assert.equal(pkg.engines.node, ">=24.16.0 <25 || >=26.1.0"); +}); + +test("emitted authorization preserves pairing and command access-group behavior", async () => { + const { resolveWeixinCommandAuthorization: authorize } = await stagedImport( + "dist/src/auth/authorization.js", + ); + assert.deepEqual(authorize({}, "/status", "owner", ["owner"]), { + senderAllowedForCommands: true, + commandAuthorized: true, + }); + assert.deepEqual(authorize({}, "/status", "stranger", ["owner"]), { + senderAllowedForCommands: false, + commandAuthorized: false, + }); + assert.deepEqual(authorize({}, "hello", "owner", ["owner"]), { + senderAllowedForCommands: true, + commandAuthorized: undefined, + }); + assert.deepEqual(authorize({}, "/status", "legacy", []), { + senderAllowedForCommands: true, + commandAuthorized: false, + }); + assert.deepEqual( + authorize({ commands: { useAccessGroups: false } }, "/status", "stranger", ["owner"]), + { + senderAllowedForCommands: false, + commandAuthorized: true, + }, + ); +}); + +test("emitted login reload atomically updates only the Weixin timestamp", async () => { + const sdk = await stagedImport("node_modules/openclaw/config-mutation.js"); + const { triggerWeixinChannelReload } = await stagedImport("dist/src/auth/accounts.js"); + const original = { + gateway: { port: 18789 }, + channels: { + telegram: { enabled: false }, + "openclaw-weixin": { enabled: true, accounts: { owner: { name: "Owner" } } }, + }, + }; + sdk.reset(original); + await triggerWeixinChannelReload(); + assert.notEqual(sdk.config, original); + const stamp = sdk.config.channels["openclaw-weixin"].channelConfigUpdatedAt; + assert.ok(Number.isFinite(Date.parse(stamp))); + const expected = structuredClone(original); + expected.channels["openclaw-weixin"].channelConfigUpdatedAt = stamp; + assert.deepEqual(sdk.config, expected); + assert.equal(original.channels["openclaw-weixin"].channelConfigUpdatedAt, undefined); + + sdk.reset({}); + await triggerWeixinChannelReload(); + assert.ok(sdk.config.channels["openclaw-weixin"].channelConfigUpdatedAt); +}); + +test("failed config mutation preserves configuration and logs the failure", async () => { + const sdk = await stagedImport("node_modules/openclaw/config-mutation.js"); + const { triggerWeixinChannelReload } = await stagedImport("dist/src/auth/accounts.js"); + const original = { gateway: { port: 18789 } }; + sdk.reset(original, new Error("write denied")); + await triggerWeixinChannelReload(); + assert.equal(sdk.config, original); + const logs = fs + .readdirSync(path.join(tempDir, "logs")) + .map((file) => fs.readFileSync(path.join(tempDir, "logs", file), "utf8")) + .join("\n"); + assert.match(logs, /triggerWeixinChannelReload: failed to update config: Error: write denied/); + sdk.reset({}); +}); + +test("staging rejects mismatched upstream versions", () => { + const mismatch = path.join(tempDir, "mismatch"); + fs.mkdirSync(mismatch); + fs.writeFileSync(path.join(mismatch, "package.json"), JSON.stringify({ version: "0.0.0" })); + assert.throws(() => stageWeixinRuntime(sourceDir, mismatch), /versions do not match/); +}); diff --git a/plugins/openclaw-weixin/README.md b/plugins/openclaw-weixin/README.md index 05ad188..fe9418d 100644 --- a/plugins/openclaw-weixin/README.md +++ b/plugins/openclaw-weixin/README.md @@ -6,8 +6,13 @@ OpenClaw's WeChat channel plugin, supporting login authorization via QR code sca ## Compatibility +MicroClaw bundles upstream **2.4.6** with a local **OpenClaw 2026.10.2+** SDK compatibility patch. This is not a new Tencent npm release. The original checksummed vendor archives are unchanged; `build.ps1` stages their runtime dependencies and recompiles the maintained source through `desktop/scripts/stage-weixin-runtime.mjs`. Both the TypeScript and compiled JavaScript entries receive the patch. + +The patch replaces retired SDK imports, uses atomic config mutation after login, and retains the existing pairing-store/legacy-owner and command access-group behavior. Source-mode installation also uses the staged `dist/openclaw-weixin` directory, so run `build.ps1` before `deploy.py`. + | Plugin Version | OpenClaw Version | npm dist-tag | Status | |---------------|------------------------|--------------|-------------| +| 2.4.6 + MicroClaw patch | >=2026.10.2 | Not published | Bundled | | 2.0.x | >=2026.3.22 | `latest` | Active | | 1.0.x | >=2026.1.0 <2026.3.22 | `legacy` | Maintenance | diff --git a/plugins/openclaw-weixin/README.zh_CN.md b/plugins/openclaw-weixin/README.zh_CN.md index 9322541..91e55da 100644 --- a/plugins/openclaw-weixin/README.zh_CN.md +++ b/plugins/openclaw-weixin/README.zh_CN.md @@ -6,8 +6,13 @@ OpenClaw 的微信渠道插件,支持通过扫码完成登录授权。 ## 兼容性 +MicroClaw 捆绑上游 **2.4.6**,并附带适用于 **OpenClaw 2026.10.2+** 的本地 SDK 兼容补丁;这不是腾讯发布的新 npm 版本。原始 vendor 压缩包及校验值保持不变,`build.ps1` 提取其运行时依赖后,通过 `desktop/scripts/stage-weixin-runtime.mjs` 重新编译本仓库维护的源码,确保 TypeScript 和编译后的 JavaScript 入口都包含补丁。 + +补丁替换已移除的 SDK 导入,登录后使用原子配置更新,并保留已有的配对名单、旧账户归属回退和命令访问组策略。从源码运行安装器也使用 `dist/openclaw-weixin`,因此请先运行 `build.ps1`,再运行 `deploy.py`。 + | 插件版本 | OpenClaw 版本 | npm dist-tag | 状态 | |---------|--------------------------|--------------|--------| +| 2.4.6 + MicroClaw 补丁 | >=2026.10.2 | 未发布 | 随安装器捆绑 | | 2.0.x | >=2026.3.22 | `latest` | 活跃 | | 1.0.x | >=2026.1.0 <2026.3.22 | `legacy` | 维护中 | diff --git a/plugins/openclaw-weixin/package.json b/plugins/openclaw-weixin/package.json index 464acdb..e10067d 100644 --- a/plugins/openclaw-weixin/package.json +++ b/plugins/openclaw-weixin/package.json @@ -25,18 +25,18 @@ "prepublishOnly": "npm run typecheck && npm run build" }, "engines": { - "node": ">=22" + "node": ">=24.16.0 <25 || >=26.1.0" }, "dependencies": { "qrcode-terminal": "0.12.0", "zod": "^4.3.6" }, "peerDependencies": { - "openclaw": ">=2026.5.12" + "openclaw": ">=2026.10.2" }, "devDependencies": { "@vitest/coverage-v8": "^3.1.0", - "openclaw": "2026.5.12", + "openclaw": "2026.10.2", "silk-wasm": "^3.7.1", "typescript": "^5.8.0", "vitest": "^3.1.0" @@ -60,8 +60,12 @@ "install": { "npmSpec": "@tencent-weixin/openclaw-weixin", "defaultChoice": "npm", - "minHostVersion": ">=2026.5.12" + "minHostVersion": ">=2026.10.2" } }, - "ilink_appid": "bot" + "ilink_appid": "bot", + "microclaw": { + "upstreamVersion": "2.4.6", + "sdkCompatibility": "2026.10.2" + } } diff --git a/plugins/openclaw-weixin/src/auth/accounts.ts b/plugins/openclaw-weixin/src/auth/accounts.ts index b9d2f01..90d3d0a 100644 --- a/plugins/openclaw-weixin/src/auth/accounts.ts +++ b/plugins/openclaw-weixin/src/auth/accounts.ts @@ -307,21 +307,20 @@ export function loadConfigBotAgent(): string | undefined { */ export async function triggerWeixinChannelReload(): Promise { try { - const { loadConfig, writeConfigFile } = await import("openclaw/plugin-sdk/config-runtime"); - const cfg = loadConfig(); - const channels = (cfg.channels ?? {}) as Record; - const existing = (channels["openclaw-weixin"] as Record | undefined) ?? {}; - const updated: OpenClawConfig = { - ...cfg, - channels: { - ...channels, - "openclaw-weixin": { - ...existing, - channelConfigUpdatedAt: new Date().toISOString(), - }, + const { mutateConfigFile } = await import("openclaw/plugin-sdk/config-mutation"); + await mutateConfigFile({ + mutate(cfg) { + const channels = (cfg.channels ?? {}) as Record; + const existing = (channels["openclaw-weixin"] as Record | undefined) ?? {}; + cfg.channels = { + ...channels, + "openclaw-weixin": { + ...existing, + channelConfigUpdatedAt: new Date().toISOString(), + }, + }; }, - }; - await writeConfigFile(updated); + }); logger.info("triggerWeixinChannelReload: wrote channel config to openclaw.json"); } catch (err) { logger.warn(`triggerWeixinChannelReload: failed to update config: ${String(err)}`); diff --git a/plugins/openclaw-weixin/src/auth/authorization.ts b/plugins/openclaw-weixin/src/auth/authorization.ts new file mode 100644 index 0000000..83d3b0f --- /dev/null +++ b/plugins/openclaw-weixin/src/auth/authorization.ts @@ -0,0 +1,25 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/core"; +import { + resolveCommandAuthorizedFromAuthorizers, + shouldComputeCommandAuthorized, +} from "openclaw/plugin-sdk/command-auth-native"; + +export function resolveWeixinCommandAuthorization( + cfg: OpenClawConfig, + rawBody: string, + senderId: string, + allowFrom: string[], +): { senderAllowedForCommands: boolean; commandAuthorized: boolean | undefined } { + // Preserve the released plugin's empty-list legacy DM behavior; command + // access remains subject to the host's access-groups policy. + const senderAllowedForCommands = allowFrom.length === 0 || allowFrom.includes(senderId); + const commandAuthorized = shouldComputeCommandAuthorized(rawBody, cfg) + ? resolveCommandAuthorizedFromAuthorizers({ + useAccessGroups: cfg.commands?.useAccessGroups !== false, + authorizers: [ + { configured: allowFrom.length > 0, allowed: senderAllowedForCommands }, + ], + }) + : undefined; + return { senderAllowedForCommands, commandAuthorized }; +} diff --git a/plugins/openclaw-weixin/src/auth/pairing.ts b/plugins/openclaw-weixin/src/auth/pairing.ts index 3d569a7..41266c7 100644 --- a/plugins/openclaw-weixin/src/auth/pairing.ts +++ b/plugins/openclaw-weixin/src/auth/pairing.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import path from "node:path"; -import { withFileLock } from "openclaw/plugin-sdk/infra-runtime"; +import { withFileLock } from "openclaw/plugin-sdk/file-lock"; import { resolveStateDir } from "../storage/state-dir.js"; import { logger } from "../util/logger.js"; diff --git a/plugins/openclaw-weixin/src/channel.ts b/plugins/openclaw-weixin/src/channel.ts index 2a084df..c2007d2 100644 --- a/plugins/openclaw-weixin/src/channel.ts +++ b/plugins/openclaw-weixin/src/channel.ts @@ -2,7 +2,7 @@ import path from "node:path"; import type { ChannelPlugin, OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/core"; import { normalizeAccountId } from "openclaw/plugin-sdk/account-id"; -import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/infra-runtime"; +import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; import { registerWeixinAccountId, diff --git a/plugins/openclaw-weixin/src/compat.ts b/plugins/openclaw-weixin/src/compat.ts index 2d4934c..f8fb5f0 100644 --- a/plugins/openclaw-weixin/src/compat.ts +++ b/plugins/openclaw-weixin/src/compat.ts @@ -8,7 +8,7 @@ import { logger } from "./util/logger.js"; -export const SUPPORTED_HOST_MIN = "2026.3.22"; +export const SUPPORTED_HOST_MIN = "2026.10.2"; export interface OpenClawVersion { year: number; diff --git a/plugins/openclaw-weixin/src/messaging/process-message.ts b/plugins/openclaw-weixin/src/messaging/process-message.ts index 08ab205..7cd62ca 100644 --- a/plugins/openclaw-weixin/src/messaging/process-message.ts +++ b/plugins/openclaw-weixin/src/messaging/process-message.ts @@ -1,18 +1,15 @@ import path from "node:path"; import { randomUUID } from "node:crypto"; -import { createTypingCallbacks } from "openclaw/plugin-sdk/channel-runtime"; -import { - resolveSenderCommandAuthorizationWithRuntime, - resolveDirectDmAuthorizationOutcome, -} from "openclaw/plugin-sdk/command-auth"; -import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/infra-runtime"; +import { createTypingCallbacks } from "openclaw/plugin-sdk/channel-outbound"; +import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; import type { PluginRuntime } from "openclaw/plugin-sdk/core"; import { sendTyping } from "../api/api.js"; import type { WeixinMessage } from "../api/types.js"; import { MessageItemType, TypingStatus } from "../api/types.js"; import { loadWeixinAccount } from "../auth/accounts.js"; +import { resolveWeixinCommandAuthorization } from "../auth/authorization.js"; import { readFrameworkAllowFromList } from "../auth/pairing.js"; import { downloadRemoteImageToTemp } from "../cdn/upload.js"; import { resolveReplyProgressMessagesEnabled } from "../config/reply-progress.js"; @@ -171,35 +168,21 @@ export async function processOneMessage( const senderId = full.from_user_id ?? ""; - const { senderAllowedForCommands, commandAuthorized } = - await resolveSenderCommandAuthorizationWithRuntime({ - cfg: deps.config, - rawBody, - isGroup: false, - dmPolicy: "pairing", - configuredAllowFrom: [], - configuredGroupAllowFrom: [], - senderId, - isSenderAllowed: (id: string, list: string[]) => list.length === 0 || list.includes(id), - /** Pairing: framework credentials `*-allowFrom.json`, with account `userId` fallback for legacy installs. */ - readAllowFromStore: async () => { - const fromStore = readFrameworkAllowFromList(deps.accountId); - if (fromStore.length > 0) return fromStore; - const uid = loadWeixinAccount(deps.accountId)?.userId?.trim(); - return uid ? [uid] : []; - }, - runtime: deps.channelRuntime.commands, - }); - - const directDmOutcome = resolveDirectDmAuthorizationOutcome({ - isGroup: false, - dmPolicy: "pairing", - senderAllowedForCommands, - }); + const fromStore = readFrameworkAllowFromList(deps.accountId); + const legacyUserId = fromStore.length === 0 + ? loadWeixinAccount(deps.accountId)?.userId?.trim() + : undefined; + const allowFrom = fromStore.length > 0 ? fromStore : legacyUserId ? [legacyUserId] : []; + const { senderAllowedForCommands, commandAuthorized } = resolveWeixinCommandAuthorization( + deps.config, + rawBody, + senderId, + allowFrom, + ); - if (directDmOutcome === "disabled" || directDmOutcome === "unauthorized") { + if (!senderAllowedForCommands) { logger.info( - `authorization: dropping message from=${senderId} outcome=${directDmOutcome}`, + `authorization: dropping message from=${senderId} outcome=unauthorized`, ); return; } diff --git a/plugins/openclaw-weixin/src/util/logger.ts b/plugins/openclaw-weixin/src/util/logger.ts index 0fd6541..213f49a 100644 --- a/plugins/openclaw-weixin/src/util/logger.ts +++ b/plugins/openclaw-weixin/src/util/logger.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/infra-runtime"; +import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; /** * Plugin logger — writes JSON lines to the main openclaw log file: diff --git a/scripts/windows/setup-dependencies.ps1 b/scripts/windows/setup-dependencies.ps1 index 585e810..5f3441a 100644 --- a/scripts/windows/setup-dependencies.ps1 +++ b/scripts/windows/setup-dependencies.ps1 @@ -5,7 +5,7 @@ .DESCRIPTION 此脚本安装 MicroClaw 桌面客户端所需的核心第三方依赖: 1. Git for Windows (PortableGit) - 2. Node.js v22+ (from npmmirror) + 2. Node.js v24.16+ (24.x) or v26.1+ (from npmmirror) 3. npm 镜像源配置 4. OpenClaw Gateway (npm install -g) 5. V8 编译缓存预热 @@ -24,7 +24,7 @@ 跳过 Git 安装 .PARAMETER OpenClawTag - OpenClaw npm 安装 tag (默认: 2026.7.1-1) + OpenClaw npm 安装 tag (默认: 2026.10.2) .EXAMPLE .\setup-dependencies.ps1 @@ -37,7 +37,7 @@ param( [ValidateSet("npmmirror", "tencent")] [string]$Mirror = "npmmirror", [switch]$SkipGit, - [string]$OpenClawTag = "2026.7.1-1" + [string]$OpenClawTag = "2026.10.2" ) Set-StrictMode -Version Latest @@ -77,10 +77,8 @@ function Test-SupportedNodeVersion { } catch { return $false } - if ($parsed.Major -eq 22) { return $parsed -ge [version]"22.22.3" } - if ($parsed.Major -eq 23) { return $false } - if ($parsed.Major -eq 24) { return $parsed -ge [version]"24.15.0" } - if ($parsed.Major -ge 25) { return $parsed -ge [version]"25.9.0" } + if ($parsed.Major -eq 24) { return $parsed -ge [version]"24.16.0" } + if ($parsed.Major -ge 26) { return $parsed -ge [version]"26.1.0" } return $false } @@ -254,13 +252,13 @@ if ($needInstall) { Write-Step "Installing Node.js ($Mirror)..." $arch = Get-Arch - # Resolve latest Node.js 22.x version - $nodeVersion = "22.22.3" + # Resolve latest supported Node.js 24.x version + $nodeVersion = "24.16.0" try { $versionIndex = Invoke-RestMethod -Uri "https://nodejs.org/dist/index.json" -TimeoutSec 15 -UseBasicParsing foreach ($entry in $versionIndex) { $v = $entry.version -replace '^v','' - if ($v -match '^22\.') { + if ($v -match '^24\.' -and (Test-SupportedNodeVersion $v)) { $nodeVersion = $v break } diff --git a/tests/test_openclaw_version.py b/tests/test_openclaw_version.py index 7128869..8a266a3 100644 --- a/tests/test_openclaw_version.py +++ b/tests/test_openclaw_version.py @@ -1,6 +1,7 @@ import unittest from deployer.openclaw_version import ( + NODE_DEFAULT_MAJOR, NODE_FALLBACK_VERSION, OPENCLAW_TARGET_VERSION, extract_openclaw_version, @@ -10,36 +11,42 @@ class OpenClawVersionTests(unittest.TestCase): def test_target_version(self) -> None: - self.assertEqual(OPENCLAW_TARGET_VERSION, "2026.7.1-1") + self.assertEqual(OPENCLAW_TARGET_VERSION, "2026.10.2") - def test_node_22_boundary(self) -> None: - self.assertFalse(is_supported_node_version("v22.22.2")) - self.assertTrue(is_supported_node_version("v22.22.3")) - self.assertTrue(is_supported_node_version("22.23.0")) + def test_unsupported_node_lines(self) -> None: + for version in ("20.20.0", "v22.22.3", "22.23.0", "23.10.0", "25.9.0", "25.99.0"): + with self.subTest(version=version): + self.assertFalse(is_supported_node_version(version)) - def test_node_23_rejection(self) -> None: - self.assertFalse(is_supported_node_version("23.0.0")) - self.assertFalse(is_supported_node_version("v23.10.0")) + def test_node_24_boundary(self) -> None: + self.assertFalse(is_supported_node_version("24.15.9")) + self.assertTrue(is_supported_node_version("v24.16.0")) + self.assertTrue(is_supported_node_version("24.18.0")) - def test_node_24_and_25_boundaries(self) -> None: - self.assertFalse(is_supported_node_version("24.14.9")) - self.assertTrue(is_supported_node_version("24.15.0")) - self.assertFalse(is_supported_node_version("25.8.9")) - self.assertTrue(is_supported_node_version("25.9.0")) + def test_node_26_boundary_and_newer(self) -> None: + self.assertFalse(is_supported_node_version("26.0.9")) + self.assertTrue(is_supported_node_version("26.1.0")) + self.assertTrue(is_supported_node_version("27.0.0")) - def test_node_26_acceptance(self) -> None: - self.assertTrue(is_supported_node_version("26.0.0")) + def test_invalid_node_versions(self) -> None: + for version in ("", "latest", "24", "24.16", "24.16.0.1", "24.16.0-beta.1"): + with self.subTest(version=version): + self.assertFalse(is_supported_node_version(version)) def test_fallback_validity(self) -> None: + self.assertEqual(NODE_DEFAULT_MAJOR, "24") + self.assertEqual(NODE_FALLBACK_VERSION, "24.16.0") self.assertTrue(is_supported_node_version(NODE_FALLBACK_VERSION)) def test_extract_openclaw_version(self) -> None: - output = """ - npm list -g openclaw --depth=0 - C:\\Program Files\\nodejs - `-- openclaw@2026.7.1-1 - """ - self.assertEqual(extract_openclaw_version(output), "2026.7.1-1") + for version in ("2026.7.1-1", "2026.10.2"): + with self.subTest(version=version): + output = f""" + npm list -g openclaw --depth=0 + C:\\Program Files\\nodejs + `-- openclaw@{version} + """ + self.assertEqual(extract_openclaw_version(output), version) def test_extract_openclaw_version_malformed(self) -> None: self.assertIsNone(extract_openclaw_version("openclaw missing")) diff --git a/tests/test_runtime_gates.py b/tests/test_runtime_gates.py index 0459e86..d098219 100644 --- a/tests/test_runtime_gates.py +++ b/tests/test_runtime_gates.py @@ -1,7 +1,19 @@ +import base64 +import json +import re +import shutil +import subprocess import unittest from pathlib import Path +from deployer.openclaw_version import ( + NODE_FALLBACK_VERSION, + OPENCLAW_TARGET_VERSION, + is_supported_node_version, +) + ROOT = Path(__file__).resolve().parents[1] +POWERSHELL = shutil.which("pwsh") or shutil.which("powershell") class RuntimeGateTests(unittest.TestCase): @@ -10,10 +22,68 @@ def test_legacy_helper_uses_exact_pin_and_supported_node_floor(self): encoding="utf-8" ) - self.assertIn('[string]$OpenClawTag = "2026.7.1-1"', script) - self.assertIn('$nodeVersion = "22.22.3"', script) + self.assertIn(f'[string]$OpenClawTag = "{OPENCLAW_TARGET_VERSION}"', script) + self.assertIn(f'$nodeVersion = "{NODE_FALLBACK_VERSION}"', script) self.assertIn("function Test-SupportedNodeVersion", script) - self.assertIn("$parsed.Major -eq 23", script) + self.assertIn("$parsed.Major -ge 26", script) + + @unittest.skipUnless(POWERSHELL, "PowerShell is required to execute runtime gates") + def test_powershell_gates_match_python_node_policy(self): + script = (ROOT / "scripts" / "windows" / "setup-dependencies.ps1").read_text( + encoding="utf-8" + ) + helper = re.search(r"(?ms)^function Test-SupportedNodeVersion \{.*?^\}", script) + self.assertIsNotNone(helper) + gates = [helper[0]] + for name in ("pr-build.yml", "release.yml"): + workflow = (ROOT / ".github" / "workflows" / name).read_text(encoding="utf-8") + expressions = re.findall( + r"(?m)^ \$supported =\n(?: .*\n)+", workflow + ) + self.assertEqual(len(expressions), 2 if name == "pr-build.yml" else 1) + gates.extend( + "function Test-SupportedNodeVersion { param([string]$Version)\n" + "$parsed = [version]$Version.TrimStart('v')\n" + expression + "return $supported\n}" + for expression in expressions + ) + versions = [ + "20.20.0", + "v22.22.3", + "23.10.0", + "24.15.9", + "v24.16.0", + "24.18.0", + "25.9.0", + "25.99.0", + "26.0.9", + "26.1.0", + "27.0.0", + ] + for index, gate in enumerate(gates): + with self.subTest(gate=index): + command = ( + "$ErrorActionPreference = 'Stop'\n" + gate + "\n" + f"$versions = '{json.dumps(versions)}' | ConvertFrom-Json\n" + "$results = @($versions | ForEach-Object { Test-SupportedNodeVersion $_ })\n" + "ConvertTo-Json -InputObject $results -Compress" + ) + result = subprocess.run( + [ + POWERSHELL, + "-NoProfile", + "-NonInteractive", + "-EncodedCommand", + base64.b64encode(command.encode("utf-16-le")).decode("ascii"), + ], + capture_output=True, + text=True, + check=True, + timeout=30, + ) + self.assertEqual( + json.loads(result.stdout), + [is_supported_node_version(version) for version in versions], + ) def test_legacy_helper_refuses_nontransactional_existing_upgrade(self): script = (ROOT / "scripts" / "windows" / "setup-dependencies.ps1").read_text( @@ -26,8 +96,15 @@ def test_legacy_helper_refuses_nontransactional_existing_upgrade(self): def test_ci_enforces_openclaw_node_range_in_both_jobs(self): workflow = (ROOT / ".github" / "workflows" / "pr-build.yml").read_text(encoding="utf-8") - self.assertGreaterEqual(workflow.count("22.22.3"), 2) - self.assertGreaterEqual(workflow.count("is unsupported by OpenClaw 2026.7.1-1"), 2) + self.assertEqual(workflow.count("24.16.0"), 2) + self.assertEqual(workflow.count("26.1.0"), 2) + self.assertEqual(workflow.count(f"is unsupported by OpenClaw {OPENCLAW_TARGET_VERSION}"), 2) + + def test_hosted_ci_installs_supported_node(self): + for name in ("release.yml", "pr-security-check.yml"): + with self.subTest(workflow=name): + workflow = (ROOT / ".github" / "workflows" / name).read_text(encoding="utf-8") + self.assertIn('node-version: "24.16.0"', workflow) def test_ci_runs_python_unit_tests(self): workflow = (ROOT / ".github" / "workflows" / "pr-build.yml").read_text(encoding="utf-8") @@ -68,6 +145,8 @@ def test_build_stages_pinned_weixin_archives_offline(self): self.assertTrue((vendor / archive).is_file()) self.assertIn("Get-FileHash", build_script) self.assertIn("--offline", build_script) + self.assertIn("stage-weixin-runtime.mjs", build_script) + self.assertIn('throw "Weixin SDK compatibility staging failed"', build_script) def test_nsis_build_declares_utf8_input_charset(self): build_script = (ROOT / "build.ps1").read_text(encoding="utf-8") diff --git a/tests/test_windows_setup_upgrade.py b/tests/test_windows_setup_upgrade.py index c26caa1..4d3d4ee 100644 --- a/tests/test_windows_setup_upgrade.py +++ b/tests/test_windows_setup_upgrade.py @@ -10,7 +10,7 @@ from types import SimpleNamespace from deployer.openclaw_upgrade import UpgradeBackupMode, UpgradePhase -from deployer.openclaw_version import OPENCLAW_TARGET_VERSION +from deployer.openclaw_version import NODE_FALLBACK_VERSION, OPENCLAW_TARGET_VERSION from deployer.uninstaller_bundle import UninstallerBundleError from deployer.windows_setup import ( _OPENCLAW_RPC_TIMEOUT, @@ -178,10 +178,42 @@ def test_kill_on_close_job_terminates_assigned_process(self): self.assertIsNotNone(process.returncode) def test_other_version_requires_upgrade(self): - self._write_package(self.home / ".openclaw-node", "2026.3.12") + self._write_package(self.home / ".openclaw-node", "2026.7.1-1") self.assertFalse(self.ws.check_openclaw_windows()) + def test_source_installer_requires_compatible_staged_weixin_runtime(self): + project = self.root / "project" + source = project / "plugins" / "openclaw-weixin" + self._write_weixin_payload(source) + staged = project / "dist" / "openclaw-weixin" + with unittest.mock.patch( + "deployer.windows_setup.__file__", str(project / "deployer" / "windows_setup.py") + ): + self.assertIsNone(self.ws._find_bundled_weixin_plugin()) + self._write_weixin_payload(staged) + self.assertIsNone(self.ws._find_bundled_weixin_plugin()) + package = { + "version": "2.4.6", + "microclaw": {"sdkCompatibility": OPENCLAW_TARGET_VERSION}, + } + (staged / "package.json").write_text(json.dumps(package), encoding="utf-8") + self.assertEqual(self.ws._find_bundled_weixin_plugin(), staged) + (staged / "dist" / "index.js").unlink() + self.assertIsNone(self.ws._find_bundled_weixin_plugin()) + + def test_source_installer_rejects_malformed_weixin_metadata(self): + project = self.root / "project" + staged = project / "dist" / "openclaw-weixin" + staged.mkdir(parents=True) + with unittest.mock.patch( + "deployer.windows_setup.__file__", str(project / "deployer" / "windows_setup.py") + ): + for content in ("not json", "[]", '{"microclaw": "invalid"}'): + with self.subTest(content=content): + (staged / "package.json").write_text(content, encoding="utf-8") + self.assertIsNone(self.ws._find_bundled_weixin_plugin()) + def test_detection_matches_desktop_search_order_and_tracks_all_shims(self): legacy_prefix = self.home / ".openclaw-node" appdata_prefix = self.appdata / "npm" @@ -619,26 +651,95 @@ def test_git_download_fails_when_all_mirrors_blocked(self): ) def test_resolve_target_node_version_bumps_to_installed_major(self): - # An already-installed newer Node (e.g. 24.x) must not be downgraded to - # the default 22.x line — the MSI refuses to install an older version. - self.ws.node_version = "22" - self.ws._installed_node_major = lambda: 24 + # The MSI refuses to downgrade an already-installed newer major. + self.ws.node_version = "24" + self.ws._installed_node_major = lambda: 26 self.ws._resolve_latest_version = lambda major: { - "22": "22.23.1", - "24": "24.15.0", + "24": "24.18.0", + "26": "26.1.0", }[major] - self.assertEqual(self.ws._resolve_target_node_version(), "24.15.0") + self.assertEqual(self.ws._resolve_target_node_version(), "26.1.0") def test_resolve_target_node_version_keeps_default_when_no_newer(self): - self.ws.node_version = "22" - self.ws._resolve_latest_version = lambda major: "22.23.1" if major == "22" else "wrong" + self.ws.node_version = "24" + self.ws._resolve_latest_version = lambda major: "24.18.0" if major == "24" else "wrong" self.ws._installed_node_major = lambda: None - self.assertEqual(self.ws._resolve_target_node_version(), "22.23.1") + self.assertEqual(self.ws._resolve_target_node_version(), "24.18.0") self.ws._installed_node_major = lambda: 20 - self.assertEqual(self.ws._resolve_target_node_version(), "22.23.1") + self.assertEqual(self.ws._resolve_target_node_version(), "24.18.0") + + def test_resolve_target_node_version_migrates_unsupported_config(self): + for configured, expected in (("22", "24"), ("23", "24"), ("25", "26")): + with self.subTest(configured=configured): + self.ws.node_version = configured + self.ws._installed_node_major = lambda: None + self.ws._resolve_latest_version = unittest.mock.Mock( + return_value=f"{expected}.18.0" + ) + self.assertEqual(self.ws._resolve_target_node_version(), f"{expected}.18.0") + self.ws._resolve_latest_version.assert_called_once_with(expected) + + def test_resolve_target_node_version_skips_installed_node_25(self): + self.ws.node_version = "24" + self.ws._installed_node_major = lambda: 25 + self.ws._resolve_latest_version = unittest.mock.Mock(return_value="26.1.0") + + self.assertEqual(self.ws._resolve_target_node_version(), "26.1.0") + self.ws._resolve_latest_version.assert_called_once_with("26") + + def test_resolve_target_node_version_refuses_fallback_downgrade(self): + self.ws.node_version = "24" + self.ws._installed_node_major = lambda: 26 + self.ws._resolve_latest_version = lambda _major: NODE_FALLBACK_VERSION + + with self.assertRaisesRegex(NodeInstallBlocked, "refusing to downgrade"): + self.ws._resolve_target_node_version() + + def test_install_node_rejects_unsupported_resolved_version_before_download(self): + self.ws._mirror_name = MIRROR_OFFICIAL + self.ws._download_and_verify_node_msi = unittest.mock.Mock() + for version in ("22.23.1", "24.15.0", "25.9.0", "26.0.0"): + with self.subTest(version=version): + self.ws._resolve_target_node_version = unittest.mock.Mock(return_value=version) + self.assertFalse(self.ws.install_node_windows()) + self.ws._download_and_verify_node_msi.assert_not_called() + + def test_node_upgrade_does_not_claim_or_delete_existing_installation(self): + self._write_package(self.ws.node_dir, "2026.7.1-1") + (self.ws.node_dir / "node.exe").write_bytes(b"old Node") + self._mock_successful_node_install() + + self.assertTrue(self.ws.install_node_windows()) + + self.ws._mark_node_owned_by_microclaw.assert_not_called() + self.assertEqual(self.ws._rollback_actions, []) + self.assertTrue((self.ws.node_dir / "node_modules" / "openclaw").is_dir()) + + def test_new_node_installation_is_owned_and_has_rollback(self): + self._mock_successful_node_install() + + self.assertTrue(self.ws.install_node_windows()) + + self.ws._mark_node_owned_by_microclaw.assert_called_once_with(self.ws.node_dir) + self.assertEqual(len(self.ws._rollback_actions), 1) + + def _mock_successful_node_install(self): + self.ws._mirror_name = MIRROR_OFFICIAL + self.ws._resolve_target_node_version = lambda: NODE_FALLBACK_VERSION + self.ws._download_and_verify_node_msi = lambda _version, _path: True + self.ws._get_arch = lambda: "x64" + self.ws._get_node_version = lambda _path: f"v{NODE_FALLBACK_VERSION}" + self.ws._mark_node_owned_by_microclaw = unittest.mock.Mock() + + def install(_cmd, **_kwargs): + self.ws.node_dir.mkdir(parents=True, exist_ok=True) + (self.ws.node_dir / "node.exe").write_bytes(b"new Node") + return SimpleNamespace(returncode=0, stdout="", stderr="") + + self.ws._run = install def test_installed_node_major_reads_highest(self): with ( @@ -662,8 +763,8 @@ def test_install_node_raises_blocked_on_launch_condition(self): # is deterministic: raise NodeInstallBlocked so the pipeline stops # instead of re-prompting UAC on every retry. self.ws._mirror_name = MIRROR_OFFICIAL - self.ws.node_version = "22" - self.ws._resolve_target_node_version = lambda: "24.15.0" + self.ws.node_version = "24" + self.ws._resolve_target_node_version = lambda: "24.16.0" self.ws._download_and_verify_node_msi = lambda _version, _path: True self.ws._get_arch = lambda: "x64"