diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 98349de..1fb34dc 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -52,7 +52,7 @@ jobs: . .\scripts\windows\node-runtime.ps1 $node = & node --version if ($LASTEXITCODE -ne 0 -or -not (Test-SupportedNodeVersion $node)) { - throw "Node $node is unsupported by OpenClaw 2026.9.3 (need >=24.16.0 <25 || >=26.1.0; install Node 26)" + throw "Node $node is unsupported by OpenClaw 2026.10.2 (need >=24.16.0 <25 || >=26.1.0; install Node 26)" } Write-Host "node $node" @@ -146,7 +146,7 @@ jobs: . .\scripts\windows\node-runtime.ps1 $node = & node --version if ($LASTEXITCODE -ne 0 -or -not (Test-SupportedNodeVersion $node)) { - throw "Node $node is unsupported by OpenClaw 2026.9.3 (need >=24.16.0 <25 || >=26.1.0; install Node 26)" + throw "Node $node is unsupported by OpenClaw 2026.10.2 (need >=24.16.0 <25 || >=26.1.0; install Node 26)" } Write-Host "node $node" $sdks = & dotnet --list-sdks diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index da3d550..c18fe07 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -102,7 +102,7 @@ jobs: . .\scripts\windows\node-runtime.ps1 $node = & node --version if ($LASTEXITCODE -ne 0 -or -not (Test-SupportedNodeVersion $node)) { - throw "Node $node is unsupported by OpenClaw 2026.9.3 (need >=24.16.0 <25 || >=26.1.0; install Node 26)" + throw "Node $node is unsupported by OpenClaw 2026.10.2 (need >=24.16.0 <25 || >=26.1.0; install Node 26)" } Write-Host "node $node" $sdks = & dotnet --list-sdks 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 ea7bcd5..a38a690 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 -- OpenClaw target: **2026.9.3**. Node.js engine range: `>=24.16.0 <25 || >=26.1.0` (Node 22 and 25 unsupported); Node **26** is recommended and the download fallback is **26.1.0**. +- 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. +- OpenClaw target: **2026.10.2**. Node.js engine range: `>=24.16.0 <25 || >=26.1.0` (Node 22 and 25 unsupported); Node **26** is recommended and the download fallback is **26.1.0**. - Node.js installed via the official signed `.msi` to `%ProgramFiles%\nodejs\` (per-machine, UAC-elevated; override with `OPENCLAW_NODE_DIR`); an existing supported system Node at a standard path is reused as-is; OpenClaw state dir at `%APPDATA%/openclaw` ## Key Constants (desktop/src/constants.ts) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3c254ad..c3cb927 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,7 +45,7 @@ contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additio ### Development Setup 1. Clone the repository -2. Install Node.js 26.1+ (recommended), or 24.16+ on the 24.x line. OpenClaw 2026.9.3 requires `>=24.16.0 <25 || >=26.1.0`; Node 22 and 25 are unsupported. +2. Install Node.js 26.1+ (recommended), or 24.16+ on the 24.x line. OpenClaw 2026.10.2 requires `>=24.16.0 <25 || >=26.1.0`; Node 22 and 25 are unsupported. 3. Install dependencies: ```bash cd desktop && npm install diff --git a/README.md b/README.md index f8050f8..2cb9752 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 26.1+ (recommended)** or **24.16+ on the 24.x line**, and **Python 3.10+**. OpenClaw **2026.9.3** requires `>=24.16.0 <25 || >=26.1.0`; Node 22 and 25 are unsupported. 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 26.1+ (recommended)** or **24.16+ on the 24.x line**, and **Python 3.10+**. OpenClaw **2026.10.2** requires `>=24.16.0 <25 || >=26.1.0`; Node 22 and 25 are unsupported. 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 @@ -125,13 +125,15 @@ The installer handles the Windows-side setup in a single run: - Git for Windows (PortableGit → `~/.openclaw-git`) - Node.js 26 via the official signed `.msi` (per-machine install to `%ProgramFiles%\nodejs\`, UAC-elevated; an existing supported system Node at that path is reused as-is; supported range: `>=24.16.0 <25 || >=26.1.0`, download fallback: `26.1.0`) -- OpenClaw Gateway (`npm install -g openclaw`) +- 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 +- Installs the MicroClaw desktop client, managed skills, and Windows sandbox components; the WeChat plugin is not bundled or automatically installed - 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: diff --git a/README.zh-CN.md b/README.zh-CN.md index 1e3ded1..49e7ef4 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -60,7 +60,7 @@ MicroClaw 仅支持 **Windows 10/11**。对大多数用户来说,真正需要 从源码构建安装器后运行: -> **构建前置依赖**(仅运行 `build.ps1` 时需要):**Node.js 26.1+(推荐)** 或 **24.x 系列的 24.16+**,以及 **Python 3.10+**。OpenClaw **2026.9.3** 要求 `>=24.16.0 <25 || >=26.1.0`,不再支持 Node 22 和 25。使用 `pip install -r requirements.txt` 安装 Python 构建依赖(其中包含用于打包安装器 exe 的 PyInstaller)。终端用户运行已打包的 `MicroClawInstaller.exe` **无需** 安装 Python。 +> **构建前置依赖**(仅运行 `build.ps1` 时需要):**Node.js 26.1+(推荐)** 或 **24.x 系列的 24.16+**,以及 **Python 3.10+**。OpenClaw **2026.10.2** 要求 `>=24.16.0 <25 || >=26.1.0`,不再支持 Node 22 和 25。使用 `pip install -r requirements.txt` 安装 Python 构建依赖(其中包含用于打包安装器 exe 的 PyInstaller)。终端用户运行已打包的 `MicroClawInstaller.exe` **无需** 安装 Python。 ```powershell .\build.ps1 # 生成 dist/MicroClawInstaller/MicroClawInstaller.exe @@ -71,13 +71,15 @@ MicroClaw 仅支持 **Windows 10/11**。对大多数用户来说,真正需要 - Git for Windows(PortableGit → `~/.openclaw-git`) - Node.js 26,通过官方签名 `.msi` 以 per-machine 方式安装到 `%ProgramFiles%\nodejs\`(UAC 提权;若该路径已存在受支持的系统 Node 则直接复用;支持范围为 `>=24.16.0 <25 || >=26.1.0`,下载回退版本为 `26.1.0`) -- OpenClaw Gateway(`npm install -g openclaw`) +- OpenClaw Gateway,默认 `stable` 通道固定为 **2026.10.2**(`npm install -g openclaw@2026.10.2`) - 配置 npm 镜像源与 V8 编译缓存 -- 安装 MicroClaw 桌面客户端、托管技能、AppContainer 沙箱、微信插件 +- 安装 MicroClaw 桌面客户端、托管技能及 Windows 沙箱组件;微信插件不随安装包捆绑,也不会自动安装 - 添加 Windows Defender 排除项,创建桌面快捷方式(含一键卸载) 安装完成后,从开始菜单或桌面快捷方式启动 **MicroClaw**。桌面应用会自动拉起 Gateway。 +升级已有安装时,请重新运行 MicroClaw 安装器,继续使用事务备份、验证和失败回滚流程;旧版依赖脚本会拒绝直接覆盖升级 OpenClaw。旧配置中的 Node 22/23/25 会迁移到受支持的版本线。升级回滚会保留更新后的既有 Node 目录,避免连同已恢复的 OpenClaw 包一起删除。`stable` 是精确版本锁定,不会随 npm 的 `latest` 自动变化。 + ### 2. 只补上你的模型配置 首次启动时,只有在模型尚未配置的情况下才会进入 SetupWizard。这里真正需要用户填写的内容只有: diff --git a/build.ps1 b/build.ps1 index 41f9406..559144c 100644 --- a/build.ps1 +++ b/build.ps1 @@ -72,7 +72,7 @@ if (-not $nodeFound) { Write-Host " ERROR: supported 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 " OpenClaw 2026.9.3 requires Node.js >=24.16.0 <25 || >=26.1.0." -ForegroundColor Red + Write-Host " OpenClaw 2026.10.2 requires Node.js >=24.16.0 <25 || >=26.1.0." -ForegroundColor Red Write-Host " Install Node.js 26 (https://nodejs.org/) and re-run build.ps1." -ForegroundColor Red exit 1 } diff --git a/deployer/config.py b/deployer/config.py index e8b6bce..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": "26", + "version": NODE_DEFAULT_MAJOR, }, "openclaw": { "install_method": "npm", # npm | source diff --git a/deployer/openclaw_version.py b/deployer/openclaw_version.py index 98af117..dadf6ab 100644 --- a/deployer/openclaw_version.py +++ b/deployer/openclaw_version.py @@ -4,7 +4,8 @@ import re -OPENCLAW_TARGET_VERSION = "2026.9.3" +OPENCLAW_TARGET_VERSION = "2026.10.2" +NODE_DEFAULT_MAJOR = "26" NODE_ENGINE_RANGE = ">=24.16.0 <25 || >=26.1.0" NODE_FALLBACK_VERSION = "26.1.0" diff --git a/deployer/windows_setup.py b/deployer/windows_setup.py index 12a1b4b..708cbc9 100644 --- a/deployer/windows_setup.py +++ b/deployer/windows_setup.py @@ -45,6 +45,7 @@ prune_previous_committed_backups, ) from deployer.openclaw_version import ( + NODE_DEFAULT_MAJOR, NODE_ENGINE_RANGE, NODE_FALLBACK_VERSION, OPENCLAW_TARGET_VERSION, @@ -404,7 +405,7 @@ class WindowsSetup: def __init__(self, config, logger: DeployerLogger): self.cfg = config self.log = logger - self.node_version = config.get("node.version", "26") + 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 @@ -1213,6 +1214,7 @@ def install_node_windows(self) -> bool: """ self._select_download_mirror() 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.fullmatch(version) or not is_supported_node_version(version): @@ -1319,12 +1321,18 @@ def install_node_windows(self) -> bool: ver = self._get_node_version(str(node_exe)) if not ver or not is_supported_node_version(ver): self.log.error( - f"Node.js installed but verification failed; need {NODE_ENGINE_RANGE}" + 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). @@ -3584,10 +3592,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 @@ -4018,7 +4041,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 1bc3dd0..4075f03 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -15,7 +15,8 @@ "dist:msix": "npm run prepare-resources && npm run build && node scripts/build-msix.mjs", "prepare-windows-node-resources": "node scripts/prepare-windows-node-resources.mjs --arch=x64", "prepare-windows-node-resources:arm64": "node scripts/prepare-windows-node-resources.mjs --arch=arm64", - "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/desktop/src/default-model-selection.test.ts b/desktop/src/default-model-selection.test.ts index 0e52943..2c5fbb3 100644 --- a/desktop/src/default-model-selection.test.ts +++ b/desktop/src/default-model-selection.test.ts @@ -77,25 +77,29 @@ function fixture(target = "cloud/b") { afterEach(() => vi.useRealTimers()); describe("default model hot selection", () => { - it("writes only the primary using CAS and verifies application without inference or restart", async () => { - const f = fixture(); - await expect(f.select()).resolves.toEqual({ modelRef: "cloud/b" }); - expect(f.config).toEqual({ - ...f.before, - agents: { - ...f.before.agents, - defaults: { - ...f.before.agents.defaults, - model: { ...f.before.agents.defaults.model, primary: "cloud/b" }, + it.each(["2026.9.3", "2026.10.2"])( + "writes only the primary using CAS on %s without inference or restart", + async (version) => { + const f = fixture(); + f.gateway.serverVersion = version; + await expect(f.select()).resolves.toEqual({ modelRef: "cloud/b" }); + expect(f.config).toEqual({ + ...f.before, + agents: { + ...f.before.agents, + defaults: { + ...f.before.agents.defaults, + model: { ...f.before.agents.defaults.model, primary: "cloud/b" }, + }, }, - }, - }); - expect(f.request.mock.calls.map(([method]) => method)).toEqual([ - "config.get", - "config.patch", - "config.get", - ]); - }); + }); + expect(f.request.mock.calls.map(([method]) => method)).toEqual([ + "config.get", + "config.patch", + "config.get", + ]); + }, + ); it("uses only the authenticated prepared Copilot catalogue, never discovery", async () => { const f = fixture("github-copilot/gpt-6-sol"); diff --git a/desktop/src/default-model-selection.ts b/desktop/src/default-model-selection.ts index 962b2b5..85da949 100644 --- a/desktop/src/default-model-selection.ts +++ b/desktop/src/default-model-selection.ts @@ -3,7 +3,7 @@ import { parseGitHubCopilotGatewayAuthStatus, parseGitHubCopilotGatewayModels, } from "./github-copilot-auth"; -import { supportsLocalModelVersion, type LocalModelGateway } from "./local-model-service"; +import { supportsLocalModelGatewayVersion, type LocalModelGateway } from "./local-model-service"; import { assertModelConfigUnchanged, getPrimaryModel, @@ -59,8 +59,8 @@ export async function selectDefaultModel( deps.assertMutable(); assertModelConfigUnchanged(params.expectedConfig, deps.readConfig()); const gateway = deps.gateway(); - if (!gateway?.connected || !supportsLocalModelVersion(gateway.serverVersion)) - throw new Error("Default model selection requires a connected OpenClaw 2026.9.3 Gateway"); + if (!gateway?.connected || !supportsLocalModelGatewayVersion(gateway.serverVersion)) + throw new Error("Default model selection requires a connected, supported OpenClaw Gateway"); for (const method of ["config.get", "config.patch"]) { if (!gateway.methods.includes(method)) throw new Error(`Gateway does not support ${method}`); } diff --git a/desktop/src/local-model-service.test.ts b/desktop/src/local-model-service.test.ts index 177af56..27dc8cf 100644 --- a/desktop/src/local-model-service.test.ts +++ b/desktop/src/local-model-service.test.ts @@ -6,6 +6,7 @@ import { GatewayRequestError } from "./gateway-client"; import { LocalModelService, hasLocalModelPrimary, + supportsLocalModelGatewayVersion, supportsLocalModelVersion, validateLocalModelUrl, LOCAL_MODEL_DETECTION_TTL_MS, @@ -412,6 +413,17 @@ describe("local model protocol guards", () => { it.each(["2026.9.3", "2026.9.4"])("accepts the supported host %s", (version) => { expect(supportsLocalModelVersion(version)).toBe(true); }); + it.each(["2026.9.3", "2026.9.4", "2026.10.2", "2026.10.2+build.1"])( + "accepts the verified Gateway release %s", + (version) => expect(supportsLocalModelGatewayVersion(version)).toBe(true), + ); + it.each(["2026.10.1", "2026.10.3", "2026.10.2-beta.1", null])( + "does not broaden Gateway compatibility to %s", + (version) => expect(supportsLocalModelGatewayVersion(version)).toBe(false), + ); + it("does not treat Gateway support as permission to replace the pinned provider", () => { + expect(supportsLocalModelVersion("2026.10.2")).toBe(false); + }); it.each([null, "2026.9.2", "2026.10.1", "2026.9.3-beta.1", "unknown"])( "does not assume compatibility for %s", (version) => { diff --git a/desktop/src/local-model-service.ts b/desktop/src/local-model-service.ts index e8f0073..2a78c78 100644 --- a/desktop/src/local-model-service.ts +++ b/desktop/src/local-model-service.ts @@ -172,6 +172,12 @@ export function supportsLocalModelVersion(version: string | null, minimumPatch = return Boolean(match && Number(match[1]) >= minimumPatch); } +export function supportsLocalModelGatewayVersion(version: string | null): boolean { + return ( + supportsLocalModelVersion(version) || /^2026\.10\.2(?:\+[a-zA-Z0-9.-]+)?$/.test(version ?? "") + ); +} + export class LocalModelService { private operation: LocalModelOperation | null = null; private receipt: LocalModelReceipt | null = null; @@ -363,9 +369,11 @@ export class LocalModelService { plugin: { installed: false, enabled: false, canInstall: false }, }; } - const versionSupported = supportsLocalModelVersion(gateway.serverVersion); + const versionSupported = supportsLocalModelGatewayVersion(gateway.serverVersion); if (!versionSupported) { - blockers.push("Local setup requires a supported OpenClaw 2026.9.3+ (2026.9.x) Gateway"); + blockers.push( + "Local setup requires OpenClaw 2026.10.2 or a supported 2026.9.3+ (2026.9.x) Gateway", + ); } const missing = METHODS.filter((method) => !gateway.methods.includes(method)); if (missing.length) diff --git a/desktop/src/openclaw-approval-replay-compat.mjs b/desktop/src/openclaw-approval-replay-compat.mjs index b2ba402..5936696 100644 --- a/desktop/src/openclaw-approval-replay-compat.mjs +++ b/desktop/src/openclaw-approval-replay-compat.mjs @@ -7,16 +7,16 @@ import process from "node:process"; import { fileURLToPath } from "node:url"; import { isMainThread } from "node:worker_threads"; -const EXPECTED_VERSION = "2026.9.3"; -const EXPECTED_NODE_GATEWAY_MODULE = "nodes-C8-hkmi0.mjs"; +const EXPECTED_VERSION = "2026.10.2"; +const EXPECTED_NODE_GATEWAY_MODULE = "nodes-CUbe_1MQ.mjs"; const EXPECTED_NODE_GATEWAY_SHA256 = - "805c73836285f1b0bd503f755b388052e75dfc21efdac5a408731e6efcb72fbb"; -const EXPECTED_SYSTEM_RUN_MODULE = "system-run-approval-binding-DMkQH3tb.mjs"; + "3bc901af8f195d8b5d7ed41b2c50656f3da2ff160938d94d97d0c2fa8e42e676"; +const EXPECTED_SYSTEM_RUN_MODULE = "system-run-approval-plan-Cu-Ftdkp.mjs"; const EXPECTED_SYSTEM_RUN_SHA256 = - "08ae201f03f2b0bcd2c8c91ade26acf1572401001ed3daf1564910058c598bfd"; -const EXPECTED_EXEC_APPROVAL_MODULE = "exec-approval-B0MHHes6.mjs"; + "c0df5badb2a8cc6adfdfb8bac48e5379e0825005e0dec827d01b2fb3e223820e"; +const EXPECTED_EXEC_APPROVAL_MODULE = "exec-approval-mGU7M4-K.mjs"; const EXPECTED_EXEC_APPROVAL_SHA256 = - "182e54781c24463a2b636aa0ecebd9fa959871c594167fb0f14d9301d496edc2"; + "aacf6a38e1ea77a9ce02fc4f60069230eec6b9c0a2e7123d35032eb105253267"; export const APPROVAL_PROOF_CONTRACT = "microclaw.windows-node-approval.v1"; export const APPROVAL_PROOF_PLAN_CONTRACT = "microclaw.windows-node-approval-plan.v2"; export const APPROVAL_PROOF_TTL_MS = 15_000; @@ -24,58 +24,50 @@ const APPROVAL_PROOF_MINTER_SYMBOL = Symbol.for("microclaw.windows-node-mxc.appr const PRELOAD_INITIALIZED_ENV = "MICROCLAW_MXC_APPROVAL_PRELOAD_INITIALIZED"; export const PINNED_NODE_GATEWAY_MINT_INSERT_SOURCE = - "function sanitizeSystemRunParamsForForwarding(opts) {"; + "async function sanitizeSystemRunParamsForForwarding(opts) {"; const PATCHED_NODE_GATEWAY_MINT_INSERT_SOURCE = `function mintMicroclawNodeApprovalProof(params) { \tconst minter = globalThis[Symbol.for("microclaw.windows-node-mxc.approval-proof.v1")]; \tif (!minter || typeof minter.mint !== "function") throw new Error("MicroClaw node approval proof minter is unavailable"); \treturn minter.mint(params); } -function sanitizeSystemRunParamsForForwarding(opts) {`; +async function sanitizeSystemRunParamsForForwarding(opts) {`; -export const PINNED_NODE_GATEWAY_ALLOW_ONCE_SOURCE = `\tif (snapshot.decision === "allow-once") { +export const PINNED_NODE_GATEWAY_APPROVAL_SOURCE = `\tconst approvalDecision = snapshot.decision; +\tif (approvalDecision === "allow-once" || approvalDecision === "allow-always") { \t\tif (approvalSource !== null) return systemRunApprovalGuardError({ \t\t\tcode: "APPROVAL_SOURCE_MISMATCH", \t\t\tmessage: "approval source does not match approval record", \t\t\tdetails: { runId } \t\t}); -\t\tif (recordedResolutionSource === "auto-review") { -\t\t\tif (!runtimeContext.plan) return systemRunApprovalGuardError({ -\t\t\t\tcode: "APPROVAL_PLAN_REQUIRED", -\t\t\t\tmessage: "auto-review approval requires an approved execution plan", -\t\t\t\tdetails: { runId } -\t\t\t}); -\t\t} -\t\tif (typeof manager.consumeAllowOnce !== "function" || !manager.consumeAllowOnce(runId)) return systemRunApprovalRequired(runId); -\t\tif (recordedResolutionSource === "auto-review") { -\t\t\tnext.approvalSource = "auto-review"; -\t\t\treturn { -\t\t\t\tok: true, -\t\t\t\tparams: next, -\t\t\t\tapprovalAuthority: { -\t\t\t\t\trecordId: runId, -\t\t\t\t\tdecision: "allow-once" -\t\t\t\t} -\t\t\t}; +\t\tif (approvalDecision === "allow-once" && recordedResolutionSource === "auto-review" && !runtimeContext.plan) return systemRunApprovalGuardError({ +\t\t\tcode: "APPROVAL_PLAN_REQUIRED", +\t\t\tmessage: "auto-review approval requires an approved execution plan", +\t\t\tdetails: { runId } +\t\t}); +\t\tif (approvalDecision === "allow-once" && (typeof manager.consumeAllowOnce !== "function" || !await manager.consumeAllowOnce(runId))) return systemRunApprovalRequired(runId); +\t\tif (approvalDecision === "allow-once" && recordedResolutionSource === "auto-review") next.approvalSource = "auto-review"; +\t\telse { +\t\t\tnext.approved = true; +\t\t\tnext.approvalDecision = approvalDecision; \t\t} -\t\tnext.approved = true; -\t\tnext.approvalDecision = "allow-once"; \t\treturn { \t\t\tok: true, \t\t\tparams: next, \t\t\tapprovalAuthority: { \t\t\t\trecordId: runId, -\t\t\t\tdecision: "allow-once" +\t\t\t\tdecision: approvalDecision \t\t\t} \t\t}; \t}`; -const PATCHED_NODE_GATEWAY_ALLOW_ONCE_SOURCE = `\tif (snapshot.decision === "allow-once") { +const PATCHED_NODE_GATEWAY_APPROVAL_SOURCE = `\tconst approvalDecision = snapshot.decision; +\tif (approvalDecision === "allow-once" || approvalDecision === "allow-always") { \t\tif (approvalSource !== null) return systemRunApprovalGuardError({ \t\t\tcode: "APPROVAL_SOURCE_MISMATCH", \t\t\tmessage: "approval source does not match approval record", \t\t\tdetails: { runId } \t\t}); -\t\tif (recordedResolutionSource !== "operator") return systemRunApprovalRequired(runId); -\t\tif (typeof manager.consumeAllowOnce !== "function" || !manager.consumeAllowOnce(runId)) return systemRunApprovalRequired(runId); +\t\tif (approvalDecision !== "allow-once" || recordedResolutionSource !== "operator") return systemRunApprovalRequired(runId); +\t\tif (typeof manager.consumeAllowOnce !== "function" || !await manager.consumeAllowOnce(runId)) return systemRunApprovalRequired(runId); \t\tnext.approved = true; \t\tnext.approvalDecision = "allow-once"; \t\tnext.microclawApprovalProof = mintMicroclawNodeApprovalProof({ @@ -93,27 +85,6 @@ const PATCHED_NODE_GATEWAY_ALLOW_ONCE_SOURCE = `\tif (snapshot.decision === "all \t\t}; \t}`; -export const PINNED_NODE_GATEWAY_ALLOW_ALWAYS_SOURCE = `\tif (snapshot.decision === "allow-always") { -\t\tif (approvalSource !== null) return systemRunApprovalGuardError({ -\t\t\tcode: "APPROVAL_SOURCE_MISMATCH", -\t\t\tmessage: "approval source does not match approval record", -\t\t\tdetails: { runId } -\t\t}); -\t\tnext.approved = true; -\t\tnext.approvalDecision = "allow-always"; -\t\treturn { -\t\t\tok: true, -\t\t\tparams: next, -\t\t\tapprovalAuthority: { -\t\t\t\trecordId: runId, -\t\t\t\tdecision: "allow-always" -\t\t\t} -\t\t}; -\t}`; -const PATCHED_NODE_GATEWAY_ALLOW_ALWAYS_SOURCE = `\tif (snapshot.decision === "allow-always") { -\t\treturn systemRunApprovalRequired(runId); -\t}`; - export const PINNED_NODE_GATEWAY_ASK_FALLBACK_SOURCE = `\tif (timedOut && approvalSource === "ask-fallback" && !approved && requestedDecision === null && clientHasApprovals(opts.client)) { \t\tif (!runtimeContext.plan) return systemRunApprovalGuardError({ \t\t\tcode: "APPROVAL_PLAN_REQUIRED", @@ -135,24 +106,23 @@ const PATCHED_NODE_GATEWAY_ASK_FALLBACK_SOURCE = `\tif (timedOut && approvalSour \t\treturn systemRunApprovalRequired(runId); \t}`; -export const PINNED_SYSTEM_RUN_PLAN_SOURCE = `function normalizeSystemRunApprovalPlan(value) { -\tif (!value || typeof value !== "object" || Array.isArray(value)) return null; -\tconst candidate = value; +export const PINNED_SYSTEM_RUN_PLAN_SOURCE = `function normalizeSystemRunApprovalPlan(candidate) { +\tif (!isRecord(candidate)) return null; \tconst argv = normalizeStringArray(candidate.argv); \tif (argv.length === 0) return null; \tconst mutableFileOperand = normalizeSystemRunApprovalFileOperand(candidate.mutableFileOperand); \tif (candidate.mutableFileOperand !== void 0 && mutableFileOperand === null) return null; \tconst policySnapshot = normalizeExecApprovalPolicySnapshot(candidate.policySnapshot); \tif (candidate.policySnapshot !== void 0 && policySnapshot === null) return null; -\tconst commandText = normalizeNonEmptyString(candidate.commandText) ?? normalizeNonEmptyString(candidate.rawCommand); +\tconst commandText = normalizeNullableString(candidate.commandText) ?? normalizeNullableString(candidate.rawCommand); \tif (!commandText) return null; \treturn { \t\targv, -\t\tcwd: normalizeNonEmptyString(candidate.cwd), +\t\tcwd: normalizeNullableString(candidate.cwd), \t\tcommandText, -\t\tcommandPreview: normalizeNonEmptyString(candidate.commandPreview), -\t\tagentId: normalizeNonEmptyString(candidate.agentId), -\t\tsessionKey: normalizeNonEmptyString(candidate.sessionKey), +\t\tcommandPreview: normalizeNullableString(candidate.commandPreview), +\t\tagentId: normalizeNullableString(candidate.agentId), +\t\tsessionKey: normalizeNullableString(candidate.sessionKey), \t\t...policySnapshot ? { policySnapshot } : {}, \t\tmutableFileOperand: mutableFileOperand ?? void 0 \t}; @@ -164,36 +134,35 @@ const PATCHED_SYSTEM_RUN_PLAN_SOURCE = `function normalizeMicroclawSystemRunDecl \tfor (const item of value) { \t\tif (!item || typeof item !== "object" || Array.isArray(item)) return null; \t\tconst access = item.access; -\t\tconst folderPath = normalizeNonEmptyString(item.path); +\t\tconst folderPath = normalizeNullableString(item.path); \t\tif ((access !== "ro" && access !== "rw") || !folderPath) return null; \t\tentries.push({ access, path: folderPath }); \t} \treturn entries.sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0); } -function normalizeSystemRunApprovalPlan(value) { -\tif (!value || typeof value !== "object" || Array.isArray(value)) return null; -\tconst candidate = value; +function normalizeSystemRunApprovalPlan(candidate) { +\tif (!isRecord(candidate)) return null; \tconst argv = normalizeStringArray(candidate.argv); \tif (argv.length === 0) return null; \tconst mutableFileOperand = normalizeSystemRunApprovalFileOperand(candidate.mutableFileOperand); \tif (candidate.mutableFileOperand !== void 0 && mutableFileOperand === null) return null; \tconst policySnapshot = normalizeExecApprovalPolicySnapshot(candidate.policySnapshot); \tif (candidate.policySnapshot !== void 0 && policySnapshot === null) return null; -\tconst commandText = normalizeNonEmptyString(candidate.commandText) ?? normalizeNonEmptyString(candidate.rawCommand); +\tconst commandText = normalizeNullableString(candidate.commandText) ?? normalizeNullableString(candidate.rawCommand); \tif (!commandText) return null; \tconst hasMicroclawIdentity = candidate.executablePath !== void 0 || candidate.executableSha256 !== void 0 || candidate.cwdBinding !== void 0 || candidate.declaredAccess !== void 0; -\tconst executablePath = normalizeNonEmptyString(candidate.executablePath); -\tconst executableSha256 = normalizeNonEmptyString(candidate.executableSha256); -\tconst cwdBinding = normalizeNonEmptyString(candidate.cwdBinding); +\tconst executablePath = normalizeNullableString(candidate.executablePath); +\tconst executableSha256 = normalizeNullableString(candidate.executableSha256); +\tconst cwdBinding = normalizeNullableString(candidate.cwdBinding); \tconst declaredAccess = normalizeMicroclawSystemRunDeclaredAccess(candidate.declaredAccess); \tif (hasMicroclawIdentity && (!executablePath || !/^[a-z]:\\\\/i.test(executablePath) || !executableSha256 || !/^[a-f0-9]{64}$/i.test(executableSha256) || !cwdBinding || declaredAccess === null)) return null; \treturn { \t\targv, -\t\tcwd: normalizeNonEmptyString(candidate.cwd), +\t\tcwd: normalizeNullableString(candidate.cwd), \t\tcommandText, -\t\tcommandPreview: normalizeNonEmptyString(candidate.commandPreview), -\t\tagentId: normalizeNonEmptyString(candidate.agentId), -\t\tsessionKey: normalizeNonEmptyString(candidate.sessionKey), +\t\tcommandPreview: normalizeNullableString(candidate.commandPreview), +\t\tagentId: normalizeNullableString(candidate.agentId), +\t\tsessionKey: normalizeNullableString(candidate.sessionKey), \t\t...policySnapshot ? { policySnapshot } : {}, \t\tmutableFileOperand: mutableFileOperand ?? void 0, \t\t...hasMicroclawIdentity ? { @@ -235,15 +204,9 @@ export function patchPinnedOpenClawNodeGateway(source) { ); patched = replaceExactlyOnce( patched, - PINNED_NODE_GATEWAY_ALLOW_ONCE_SOURCE, - PATCHED_NODE_GATEWAY_ALLOW_ONCE_SOURCE, - "node allow-once approval proof handoff", - ); - patched = replaceExactlyOnce( - patched, - PINNED_NODE_GATEWAY_ALLOW_ALWAYS_SOURCE, - PATCHED_NODE_GATEWAY_ALLOW_ALWAYS_SOURCE, - "node allow-always approval proof handoff", + PINNED_NODE_GATEWAY_APPROVAL_SOURCE, + PATCHED_NODE_GATEWAY_APPROVAL_SOURCE, + "node approval proof handoff", ); return replaceExactlyOnce( patched, diff --git a/desktop/src/openclaw-approval-replay-compat.test.ts b/desktop/src/openclaw-approval-replay-compat.test.ts index 2b41386..83901ba 100644 --- a/desktop/src/openclaw-approval-replay-compat.test.ts +++ b/desktop/src/openclaw-approval-replay-compat.test.ts @@ -3,7 +3,7 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; // @ts-expect-error The compatibility preload is intentionally external ESM for the Gateway child. import * as replayCompat from "./openclaw-approval-replay-compat.mjs"; @@ -11,8 +11,7 @@ const { APPROVAL_PROOF_CONTRACT, APPROVAL_PROOF_TTL_MS, PINNED_EXEC_APPROVAL_VALIDATION_SOURCE, - PINNED_NODE_GATEWAY_ALLOW_ALWAYS_SOURCE, - PINNED_NODE_GATEWAY_ALLOW_ONCE_SOURCE, + PINNED_NODE_GATEWAY_APPROVAL_SOURCE, PINNED_NODE_GATEWAY_ASK_FALLBACK_SOURCE, PINNED_NODE_GATEWAY_MINT_INSERT_SOURCE, PINNED_SYSTEM_RUN_PLAN_SOURCE, @@ -46,18 +45,18 @@ describe("pinned OpenClaw package validation", () => { expect(() => validatePinnedOpenClawApprovalPackage(dir)).toThrow(/absolute path/); }); - it.each(["2026.8.2", "2026.9.2", "2026.9.3-beta.1", "2026.9.4"])( + it.each(["2026.8.2", "2026.9.3", "2026.10.1", "2026.10.2-beta.1", "2026.10.3"])( "rejects an unpinned OpenClaw version: %s", (version) => { expect(() => validatePinnedOpenClawApprovalPackage(createPackage(version))).toThrow( - /requires OpenClaw 2026\.9\.3/, + /requires OpenClaw 2026\.10\.2/, ); }, ); - it("rejects modified 9.3 approval bundles", () => { - const packageDir = createPackage("2026.9.3"); - writeFileSync(join(packageDir, "dist", "nodes-C8-hkmi0.mjs"), "// changed"); + it("rejects modified 10.2 approval bundles", () => { + const packageDir = createPackage("2026.10.2"); + writeFileSync(join(packageDir, "dist", "nodes-CUbe_1MQ.mjs"), "// changed"); expect(() => validatePinnedOpenClawApprovalPackage(packageDir)).toThrow(/hash mismatch/); }); @@ -66,9 +65,9 @@ describe("pinned OpenClaw package validation", () => { it("validates all published hashes and patch points and parses the patched bundles", () => { const targets = validatePinnedOpenClawApprovalPackage(publishedPackage); expect(targets.map((target: { module: string }) => target.module)).toEqual([ - "nodes-C8-hkmi0.mjs", - "system-run-approval-binding-DMkQH3tb.mjs", - "exec-approval-B0MHHes6.mjs", + "nodes-CUbe_1MQ.mjs", + "system-run-approval-plan-Cu-Ftdkp.mjs", + "exec-approval-mGU7M4-K.mjs", ]); for (const target of targets) { const patched = target.patch(target.original.toString("utf8")); @@ -126,8 +125,7 @@ assert.equal(typeof globalThis[Symbol.for("microclaw.windows-node-mxc.approval-p describe("pinned OpenClaw node approval proof backport", () => { const source = [ PINNED_NODE_GATEWAY_MINT_INSERT_SOURCE, - PINNED_NODE_GATEWAY_ALLOW_ONCE_SOURCE, - PINNED_NODE_GATEWAY_ALLOW_ALWAYS_SOURCE, + PINNED_NODE_GATEWAY_APPROVAL_SOURCE, PINNED_NODE_GATEWAY_ASK_FALLBACK_SOURCE, ].join("\n"); @@ -137,7 +135,7 @@ describe("pinned OpenClaw node approval proof backport", () => { expect(patched.match(/microclawApprovalProof =/g)).toHaveLength(1); expect(patched).toContain("manager.consumeAllowOnce(runId)"); expect(patched).toContain( - 'if (snapshot.decision === "allow-always") {\n\t\treturn systemRunApprovalRequired(runId);', + 'if (approvalDecision !== "allow-once" || recordedResolutionSource !== "operator") return systemRunApprovalRequired(runId);', ); expect(patched).toContain( 'timedOut && approvalSource === "ask-fallback" && !approved && requestedDecision === null && clientHasApprovals(opts.client)) {\n\t\treturn systemRunApprovalRequired(runId);', @@ -150,6 +148,46 @@ describe("pinned OpenClaw node approval proof backport", () => { ); }); + it.each([ + { decision: "allow-once", resolutionSource: "operator", consumed: true, allowed: true }, + { decision: "allow-once", resolutionSource: "operator", consumed: false, allowed: false }, + { decision: "allow-once", resolutionSource: "auto-review", consumed: true, allowed: false }, + { decision: "allow-always", resolutionSource: "operator", consumed: true, allowed: false }, + ])("awaits one-use consumption before minting a proof: %j", async (entry) => { + const patched = patchPinnedOpenClawNodeGateway(source); + const branch = patched.slice( + patched.indexOf("\tconst approvalDecision ="), + patched.indexOf("\tif (timedOut"), + ); + const run = new Function( + "snapshot", + "recordedResolutionSource", + "manager", + "mintMicroclawNodeApprovalProof", + `return (async () => { + const approvalSource = null, next = {}, runId = "approval", targetNodeId = "node"; + const runtimeContext = {plan: {}}; + const systemRunApprovalRequired = () => ({ok: false}); + const systemRunApprovalGuardError = () => ({ok: false}); + ${branch} + return {ok: false}; + })();`, + ); + const consumeAllowOnce = vi.fn().mockResolvedValue(entry.consumed); + const mint = vi.fn().mockReturnValue("proof"); + const result = await run( + { decision: entry.decision }, + entry.resolutionSource, + { consumeAllowOnce }, + mint, + ); + expect(result.ok).toBe(entry.allowed); + expect(mint).toHaveBeenCalledTimes(entry.allowed ? 1 : 0); + expect(consumeAllowOnce).toHaveBeenCalledTimes( + entry.decision === "allow-once" && entry.resolutionSource === "operator" ? 1 : 0, + ); + }); + describe("pinned OpenClaw prepared-plan identity backport", () => { it("preserves the exact MicroClaw executable, CWD, and declared-access identity", () => { const patched = patchPinnedOpenClawSystemRun(PINNED_SYSTEM_RUN_PLAN_SOURCE); @@ -205,8 +243,7 @@ describe("pinned OpenClaw node approval proof backport", () => { it.each([ PINNED_NODE_GATEWAY_MINT_INSERT_SOURCE, - PINNED_NODE_GATEWAY_ALLOW_ONCE_SOURCE, - PINNED_NODE_GATEWAY_ALLOW_ALWAYS_SOURCE, + PINNED_NODE_GATEWAY_APPROVAL_SOURCE, PINNED_NODE_GATEWAY_ASK_FALLBACK_SOURCE, ])("fails closed when a pinned source fragment drifts", (fragment) => { expect(() => @@ -218,7 +255,7 @@ describe("pinned OpenClaw node approval proof backport", () => { it("fails closed when a pinned source fragment appears more than once", () => { expect(() => - patchPinnedOpenClawNodeGateway(`${source}\n${PINNED_NODE_GATEWAY_ALLOW_ONCE_SOURCE}`), + patchPinnedOpenClawNodeGateway(`${source}\n${PINNED_NODE_GATEWAY_APPROVAL_SOURCE}`), ).toThrow(/did not match exactly once/); }); }); diff --git a/desktop/src/path-resolver.ts b/desktop/src/path-resolver.ts index ad1ed88..222c11e 100644 --- a/desktop/src/path-resolver.ts +++ b/desktop/src/path-resolver.ts @@ -67,7 +67,7 @@ export function loadGatewayEnvironment( } /** - * Stable Node releases supported by OpenClaw 2026.9.3. + * Stable Node releases supported by OpenClaw 2026.10.2. */ export function isSupportedNodeVersion(value: string): boolean { const match = /^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/.exec(value); @@ -118,7 +118,7 @@ export function resolveNodePath(): string { console.warn(`[path-resolver] Skipping unsupported Node.js ${version} at ${candidate}`); } throw new Error( - "OpenClaw 2026.9.3 requires Node.js >=24.16.0 <25 || >=26.1.0. " + + "OpenClaw 2026.10.2 requires Node.js >=24.16.0 <25 || >=26.1.0. " + "Run the MicroClaw installer or install Node.js 26.", ); } diff --git a/desktop/src/session-model-service.test.ts b/desktop/src/session-model-service.test.ts index b37f203..8af7b06 100644 --- a/desktop/src/session-model-service.test.ts +++ b/desktop/src/session-model-service.test.ts @@ -244,26 +244,30 @@ describe("session model reads", () => { }); describe("session model selection", () => { - it("selects a configured cloud model with only an official metadata patch", async () => { - const f = fixture(); - const config = structuredClone(f.config); - await expect(f.service.select({ sessionKey: "chat", modelRef: "cloud/b" })).resolves.toEqual({ - sessionKey: "agent:main:chat", - modelRef: "cloud/b", - overridden: true, - }); - expect(f.patches()[0][1]).toEqual({ - key: "agent:main:chat", - agentId: "main", - model: "cloud/b", - }); - expect(f.config).toEqual(config); - expect( - f.request.mock.calls.every(([method]) => - ["sessions.describe", "sessions.list", "sessions.patch", "config.get"].includes(method), - ), - ).toBe(true); - }); + it.each(["2026.9.3", "2026.10.2"])( + "selects a configured cloud model on %s with only an official metadata patch", + async (version) => { + const f = fixture(); + f.gateway.serverVersion = version; + const config = structuredClone(f.config); + await expect(f.service.select({ sessionKey: "chat", modelRef: "cloud/b" })).resolves.toEqual({ + sessionKey: "agent:main:chat", + modelRef: "cloud/b", + overridden: true, + }); + expect(f.patches()[0][1]).toEqual({ + key: "agent:main:chat", + agentId: "main", + model: "cloud/b", + }); + expect(f.config).toEqual(config); + expect( + f.request.mock.calls.every(([method]) => + ["sessions.describe", "sessions.list", "sessions.patch", "config.get"].includes(method), + ), + ).toBe(true); + }, + ); it("clears only the session override and restores the agent default", async () => { const f = fixture(); diff --git a/desktop/src/session-model-service.ts b/desktop/src/session-model-service.ts index d1719fa..d91d421 100644 --- a/desktop/src/session-model-service.ts +++ b/desktop/src/session-model-service.ts @@ -2,7 +2,7 @@ import { isDeepStrictEqual } from "node:util"; import { randomUUID } from "node:crypto"; import { inspectLocalModelFiles } from "./local-model-files"; import { GatewayRequestError } from "./gateway-client"; -import { supportsLocalModelVersion, validateLocalModelUrl } from "./local-model-service"; +import { supportsLocalModelGatewayVersion, validateLocalModelUrl } from "./local-model-service"; import { parseGitHubCopilotGatewayAuthStatus, parseGitHubCopilotGatewayModels, @@ -283,8 +283,8 @@ export class SessionModelService { private context(): Context { const gateway = this.deps.gateway(); if (!gateway?.connected) throw new Error("Gateway is not connected"); - if (!supportsLocalModelVersion(gateway.serverVersion)) { - throw new Error("Session model selection requires OpenClaw 2026.9.3"); + if (!supportsLocalModelGatewayVersion(gateway.serverVersion)) { + throw new Error("Session model selection requires a supported OpenClaw Gateway"); } for (const method of ["sessions.describe", "sessions.list", "sessions.patch", "config.get"]) { if (!gateway.methods.includes(method)) throw new Error(`Gateway does not support ${method}`); diff --git a/plugins/openclaw-weixin/README.md b/plugins/openclaw-weixin/README.md index 05ad188..8b0c655 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 +This repository maintains upstream **2.4.6** with a local **OpenClaw 2026.10.2+** SDK compatibility patch. This is not a new Tencent npm release. The default MicroClaw installer does not bundle or automatically install WeChat. The original checksummed vendor archives are unchanged; `desktop/scripts/stage-weixin-runtime.mjs` remains a standalone tool for explicitly prepared plugin staging directories. Both the TypeScript and compiled JavaScript entries receive the patch when this tool is used. + +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. `build.ps1` and `deploy.py` do not install this optional plugin. + | Plugin Version | OpenClaw Version | npm dist-tag | Status | |---------------|------------------------|--------------|-------------| +| 2.4.6 + MicroClaw patch | >=2026.10.2 | Not published | Optional source | | 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..c753dac 100644 --- a/plugins/openclaw-weixin/README.zh_CN.md +++ b/plugins/openclaw-weixin/README.zh_CN.md @@ -6,8 +6,13 @@ OpenClaw 的微信渠道插件,支持通过扫码完成登录授权。 ## 兼容性 +本仓库维护上游 **2.4.6** 及适用于 **OpenClaw 2026.10.2+** 的本地 SDK 兼容补丁;这不是腾讯发布的新 npm 版本。默认 MicroClaw 安装器不捆绑或自动安装微信插件。原始 vendor 压缩包及校验值保持不变;`desktop/scripts/stage-weixin-runtime.mjs` 保留为独立工具,对显式准备好的插件暂存目录重新编译,确保 TypeScript 和 JavaScript 入口都包含补丁。 + +补丁替换已移除的 SDK 导入,登录后使用原子配置更新,并保留已有的配对名单、旧账户归属回退和命令访问组策略。`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 33e034a..bbff309 100644 --- a/scripts/windows/setup-dependencies.ps1 +++ b/scripts/windows/setup-dependencies.ps1 @@ -24,7 +24,7 @@ 跳过 Git 安装 .PARAMETER OpenClawTag - OpenClaw npm 安装 tag (默认: 2026.9.3) + 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.9.3" + [string]$OpenClawTag = "2026.10.2" ) Set-StrictMode -Version Latest diff --git a/tests/test_openclaw_version.py b/tests/test_openclaw_version.py index bf535e5..0c71401 100644 --- a/tests/test_openclaw_version.py +++ b/tests/test_openclaw_version.py @@ -2,6 +2,7 @@ from deployer.config import DEFAULT_CONFIG from deployer.openclaw_version import ( + NODE_DEFAULT_MAJOR, NODE_ENGINE_RANGE, NODE_FALLBACK_VERSION, OPENCLAW_TARGET_VERSION, @@ -12,13 +13,14 @@ class OpenClawVersionTests(unittest.TestCase): def test_target_version(self) -> None: - self.assertEqual(OPENCLAW_TARGET_VERSION, "2026.9.3") + self.assertEqual(OPENCLAW_TARGET_VERSION, "2026.10.2") self.assertEqual(NODE_ENGINE_RANGE, ">=24.16.0 <25 || >=26.1.0") def test_unsupported_node_lines(self) -> None: for version in ( "20.20.0", "v22.22.3", + "22.23.0", "22.99.0", "23.0.0", "v23.10.0", @@ -32,6 +34,7 @@ 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.17.0")) + self.assertTrue(is_supported_node_version("24.18.0")) def test_node_26_boundary_and_newer(self) -> None: self.assertFalse(is_supported_node_version("26.0.0")) @@ -43,6 +46,11 @@ def test_node_26_boundary_and_newer(self) -> None: def test_malformed_node_versions(self) -> None: for version in ( "", + "latest", + "24", + "24.16", + "24.16.0.1", + "24.16.0-beta.1", "26", "26.1", "26.1.0.0", @@ -63,17 +71,20 @@ def test_malformed_node_versions(self) -> None: self.assertFalse(is_supported_node_version(version)) def test_fallback_validity(self) -> None: + self.assertEqual(NODE_DEFAULT_MAJOR, "26") self.assertEqual(NODE_FALLBACK_VERSION, "26.1.0") - self.assertEqual(DEFAULT_CONFIG["node"]["version"], "26") + self.assertEqual(DEFAULT_CONFIG["node"]["version"], NODE_DEFAULT_MAJOR) 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.9.3 - """ - self.assertEqual(extract_openclaw_version(output), "2026.9.3") + for version in ("2026.7.1-1", "2026.9.3", "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 56a1610..8e8f25c 100644 --- a/tests/test_runtime_gates.py +++ b/tests/test_runtime_gates.py @@ -5,7 +5,11 @@ import unittest from pathlib import Path -from deployer.openclaw_version import OPENCLAW_TARGET_VERSION, is_supported_node_version +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") @@ -23,9 +27,8 @@ def test_legacy_helper_uses_exact_pin_and_supported_node_floor(self): script = (ROOT / "scripts" / "windows" / "setup-dependencies.ps1").read_text( encoding="utf-8" ) - - self.assertIn('[string]$OpenClawTag = "2026.9.3"', script) - self.assertIn('$nodeVersion = "26.1.0"', script) + self.assertIn(f'[string]$OpenClawTag = "{OPENCLAW_TARGET_VERSION}"', script) + self.assertIn(f'$nodeVersion = "{NODE_FALLBACK_VERSION}"', script) self.assertIn(r'. "$PSScriptRoot\node-runtime.ps1"', script) self.assertIn("Test-SupportedNodeVersion $v", script) self.assertIn("Test-SupportedNodeVersion $ver", script) @@ -97,24 +100,21 @@ def test_legacy_helper_refuses_nontransactional_existing_upgrade(self): script = (ROOT / "scripts" / "windows" / "setup-dependencies.ps1").read_text( encoding="utf-8" ) - self.assertIn("requires transactional upgrade", script) self.assertIn("Run the MicroClaw installer", script) def test_ci_enforces_openclaw_node_range_in_both_jobs(self): workflow = (ROOT / ".github" / "workflows" / "pr-build.yml").read_text(encoding="utf-8") - self.assertEqual(workflow.count(r". .\scripts\windows\node-runtime.ps1"), 2) self.assertEqual(workflow.count("Test-SupportedNodeVersion $node"), 2) - self.assertGreaterEqual(workflow.count("is unsupported by OpenClaw 2026.9.3"), 2) + self.assertEqual(workflow.count(f"is unsupported by OpenClaw {OPENCLAW_TARGET_VERSION}"), 2) def test_release_enforces_openclaw_node_range(self): workflow = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8") - self.assertIn('node-version: "26"', workflow) self.assertIn(r". .\scripts\windows\node-runtime.ps1", workflow) self.assertIn("Test-SupportedNodeVersion $node", workflow) - self.assertIn("is unsupported by OpenClaw 2026.9.3", workflow) + self.assertIn(f"is unsupported by OpenClaw {OPENCLAW_TARGET_VERSION}", workflow) def test_security_workflow_uses_recommended_node(self): workflow = (ROOT / ".github" / "workflows" / "pr-security-check.yml").read_text( @@ -124,23 +124,17 @@ def test_security_workflow_uses_recommended_node(self): def test_ci_runs_python_unit_tests(self): workflow = (ROOT / ".github" / "workflows" / "pr-build.yml").read_text(encoding="utf-8") - self.assertIn("python -m unittest discover -s tests -v", workflow) def test_build_uses_uv_only_for_a_real_uv_project(self): build_script = (ROOT / "build.ps1").read_text(encoding="utf-8") - self.assertIn("$hasUvProject", build_script) self.assertIn("if ($hasUvProject)", build_script) - self.assertIn( - 'python -m pip install -r "$root\\requirements.txt"', - build_script, - ) + self.assertIn('python -m pip install -r "$root\\requirements.txt"', build_script) self.assertIn("$pipExitCode = $LASTEXITCODE", build_script) def test_installer_spec_excludes_weixin_runtime(self): spec = (ROOT / "MicroClawDeployer.spec").read_text(encoding="utf-8") - self.assertNotIn("weixin_plugin_datas", spec) self.assertNotIn("plugins/openclaw-weixin", spec) self.assertNotIn("collect_plugin_files", spec) @@ -148,7 +142,6 @@ def test_installer_spec_excludes_weixin_runtime(self): def test_build_does_not_stage_weixin_archives(self): build_script = (ROOT / "build.ps1").read_text(encoding="utf-8") - for archive in ( "tencent-weixin-openclaw-weixin-2.4.6.tgz", "zod-4.4.3.tgz", @@ -156,10 +149,10 @@ def test_build_does_not_stage_weixin_archives(self): ): self.assertNotIn(archive, build_script) self.assertNotIn("Stage Weixin plugin", build_script) + self.assertNotIn("stage-weixin-runtime.mjs", build_script) def test_nsis_build_declares_utf8_input_charset(self): build_script = (ROOT / "build.ps1").read_text(encoding="utf-8") - self.assertIn('"/INPUTCHARSET"', build_script) self.assertIn('"UTF8"', build_script) diff --git a/tests/test_windows_setup_upgrade.py b/tests/test_windows_setup_upgrade.py index c4804aa..14c4889 100644 --- a/tests/test_windows_setup_upgrade.py +++ b/tests/test_windows_setup_upgrade.py @@ -272,10 +272,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" @@ -769,6 +801,41 @@ def test_git_download_fails_when_all_mirrors_blocked(self): self.ws._download_git_installer("2.53.0", "PortableGit.7z.exe", dl_path) ) + 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._select_download_mirror = unittest.mock.Mock() + 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_resolve_target_node_version_bumps_to_installed_major(self): # The MSI refuses to install an older major over an existing newer one. self.ws.node_version = "26"