From 06b29ed8d55519f589372fa62b282c42f17ccf7d Mon Sep 17 00:00:00 2001 From: "Angnuo Li (from Dev Box)" Date: Thu, 10 Sep 2026 10:27:36 +0800 Subject: [PATCH 1/2] Upgrade OpenClaw to 2026.9.3 Align installer, desktop, build and CI Node runtime gates with the upstream engine range. Update pinned MXC approval bundles and advertise approval delivery only while MXC is enabled. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/pr-build.yml | 28 ++-- .github/workflows/pr-security-check.yml | 4 +- .github/workflows/release.yml | 16 +- CLAUDE.md | 5 +- CONTRIBUTING.md | 2 +- MicroClawDeployer.spec | 1 + README.md | 6 +- README.zh-CN.md | 6 +- build.ps1 | 24 ++- deploy.py | 3 + deployer/config.py | 2 +- deployer/openclaw_version.py | 16 +- deployer/webview_bridge.py | 3 + deployer/windows_setup.py | 56 ++++--- desktop/src/gateway-client.test.ts | 32 ++++ desktop/src/gateway-client.ts | 2 + desktop/src/gateway-manager.test.ts | 33 +++++ desktop/src/gateway-manager.ts | 9 +- desktop/src/gateway-protocol.test.ts | 20 +++ desktop/src/gateway-protocol.ts | 3 +- desktop/src/main.ts | 12 +- .../src/openclaw-approval-replay-compat.mjs | 24 +-- .../openclaw-approval-replay-compat.test.ts | 106 +++++++++++++- desktop/src/path-resolver.test.ts | 138 +++++++++++++++++- desktop/src/path-resolver.ts | 65 +++++++-- docs/experimental-windows-node-mxc.md | 14 +- scripts/windows/node-runtime.ps1 | 16 ++ scripts/windows/setup-dependencies.ps1 | 31 ++-- tests/test_openclaw_version.py | 68 ++++++--- tests/test_runtime_gates.py | 103 ++++++++++++- tests/test_webview_bridge.py | 23 +++ tests/test_windows_setup_upgrade.py | 100 +++++++++++-- 32 files changed, 805 insertions(+), 166 deletions(-) create mode 100644 desktop/src/gateway-manager.test.ts create mode 100644 scripts/windows/node-runtime.ps1 diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 7c865fb..98349de 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -43,19 +43,17 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - # Toolchain (Node 22) is pre-installed on the self-hosted runner. + # Toolchain (Node 26.1+) 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 run: | $ErrorActionPreference = 'Stop' - $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.8.2" } + . .\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)" + } Write-Host "node $node" - name: Install dependencies (all packages) @@ -138,20 +136,18 @@ jobs: with: submodules: recursive - # Toolchain (Node 22, .NET 9/10 SDKs, Python 3.12+) is pre-installed on + # Toolchain (Node 26.1+, .NET 9/10 SDKs, 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). - name: Verify pre-installed toolchain run: | $ErrorActionPreference = 'Stop' - $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.8.2" } + . .\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)" + } Write-Host "node $node" $sdks = & dotnet --list-sdks if (-not ($sdks -match '^9\.')) { throw ".NET 9 SDK is required" } diff --git a/.github/workflows/pr-security-check.yml b/.github/workflows/pr-security-check.yml index a8aad95..4703937 100644 --- a/.github/workflows/pr-security-check.yml +++ b/.github/workflows/pr-security-check.yml @@ -77,10 +77,10 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - name: Setup Node.js 22 + - name: Setup Node.js 26 uses: actions/setup-node@v4 with: - node-version: "22" + node-version: "26" # Install dependencies - name: Install root dependencies diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bd0947a..da3d550 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -79,10 +79,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 26 uses: actions/setup-node@v4 with: - node-version: "22" + node-version: "26" - name: Setup .NET 9 and 10 SDKs uses: actions/setup-dotnet@v4 @@ -99,13 +99,11 @@ jobs: - name: Verify toolchain run: | $ErrorActionPreference = 'Stop' - $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.8.2" } + . .\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)" + } Write-Host "node $node" $sdks = & dotnet --list-sdks if (-not ($sdks -match '^9\.')) { throw ".NET 9 SDK is required" } diff --git a/CLAUDE.md b/CLAUDE.md index 89b1a6a..ea7bcd5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -137,7 +137,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 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**. +- 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) @@ -149,6 +150,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 26.1+ (recommended), or 24.16+ on the 24.x line (build.ps1 validates the runtime in `%ProgramFiles%\nodejs\`, the per-user `%LocalAppData%\Programs\nodejs\`, the legacy `~/.openclaw-node/`, or PATH) - 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 b0de3b0..3c254ad 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 22+ +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. 3. Install dependencies: ```bash cd desktop && npm install diff --git a/MicroClawDeployer.spec b/MicroClawDeployer.spec index df6613a..5c3ad36 100644 --- a/MicroClawDeployer.spec +++ b/MicroClawDeployer.spec @@ -35,6 +35,7 @@ a = Analysis( ('dist/microclaw-portable.zip', '.'), ('dist/install-manifest.json', '.'), ('scripts/windows/setup-dependencies.ps1', '.'), + ('scripts/windows/node-runtime.ps1', '.'), ('scripts', 'scripts'), ('deployer/assets', 'deployer/assets'), ] + managed_skill_datas + webview_datas + pythonnet_datas, diff --git a/README.md b/README.md index 6b0f04f..f256985 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 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. ```powershell .\build.ps1 # produces dist/MicroClawInstaller/MicroClawInstaller.exe @@ -124,7 +124,7 @@ 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) +- 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`) - Configures the npm registry mirror and V8 compile cache - Installs the MicroClaw desktop client, managed skills, AppContainer sandbox, WeChat plugin @@ -317,7 +317,7 @@ download-update path. ### Prerequisites -- Node.js 22+ +- Node.js 26.1+ (recommended), or 24.16+ on the 24.x line - Python 3.10+ — install build deps with `pip install -r requirements.txt` (includes PyInstaller) - .NET 9 SDK (for the AppContainer launcher) - .NET 10 SDK (for the bundled Windows Node host) diff --git a/README.zh-CN.md b/README.zh-CN.md index 545635e..2120234 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 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。 ```powershell .\build.ps1 # 生成 dist/MicroClawInstaller/MicroClawInstaller.exe @@ -70,7 +70,7 @@ MicroClaw 仅支持 **Windows 10/11**。对大多数用户来说,真正需要 安装器会在一次运行中完成 Windows 侧的主要准备工作: - Git for Windows(PortableGit → `~/.openclaw-git`) -- Node.js 22+,通过官方签名 `.msi` 以 per-machine 方式安装到 `%ProgramFiles%\nodejs\`(UAC 提权;若该路径已存在 ≥22.16 的系统 Node 则直接复用) +- 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`) - 配置 npm 镜像源与 V8 编译缓存 - 安装 MicroClaw 桌面客户端、托管技能、AppContainer 沙箱、微信插件 @@ -199,7 +199,7 @@ npm run dev ### 前置条件 -- Node.js 22+ +- Node.js 26.1+(推荐),或 24.x 系列的 24.16+ - Python 3.10+ —— 通过 `pip install -r requirements.txt` 安装构建依赖(已包含 PyInstaller) - .NET 9 SDK(用于构建 AppContainer 启动器) - .NET 10 SDK(用于构建内置 Windows Node 主机) diff --git a/build.ps1 b/build.ps1 index 20ccb8a..41f9406 100644 --- a/build.ps1 +++ b/build.ps1 @@ -31,8 +31,9 @@ function Get-FileSetId { } } -# Prefer Node 22 from the standard per-user MSI install location, falling back -# to the legacy zip-extract path and finally the system Node. +. "$root\scripts\windows\node-runtime.ps1" + +# Prefer a supported standard MSI install, then the legacy zip and PATH. $nodeCandidates = @( "$env:ProgramFiles\nodejs", "$env:LOCALAPPDATA\Programs\nodejs", @@ -41,8 +42,13 @@ $nodeCandidates = @( $nodeFound = $false foreach ($candidate in $nodeCandidates) { if (Test-Path "$candidate\node.exe") { + $version = & "$candidate\node.exe" --version + if ($LASTEXITCODE -ne 0 -or -not (Test-SupportedNodeVersion $version)) { + Write-Host " Skipping unsupported Node: $candidate ($version)" -ForegroundColor Yellow + continue + } $env:PATH = "$candidate;$env:PATH" - Write-Host " Using Node: $candidate ($(& node --version))" + Write-Host " Using Node: $candidate ($version)" $nodeFound = $true break } @@ -55,15 +61,19 @@ if (-not $nodeFound) { $nodeCmd = Get-Command node.exe -ErrorAction SilentlyContinue if ($nodeCmd) { $nodeDir = Split-Path -Parent $nodeCmd.Source - Write-Host " Using Node from PATH: $nodeDir ($(& node --version))" - $nodeFound = $true + $version = & $nodeCmd.Source --version + if ($LASTEXITCODE -eq 0 -and (Test-SupportedNodeVersion $version)) { + Write-Host " Using Node from PATH: $nodeDir ($version)" + $nodeFound = $true + } } } if (-not $nodeFound) { - Write-Host " ERROR: node.exe not found in any of:" -ForegroundColor Red + 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 " Install Node.js 22+ (https://nodejs.org/) and re-run build.ps1." -ForegroundColor Red + Write-Host " OpenClaw 2026.9.3 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/deploy.py b/deploy.py index 1e17f77..e187984 100644 --- a/deploy.py +++ b/deploy.py @@ -1064,6 +1064,9 @@ def _copy_bundled_assets(self, ws) -> bool: if src: try: shutil.copy2(str(src), str(dest_dir / "setup-dependencies.ps1")) + shutil.copy2( + str(src.with_name("node-runtime.ps1")), str(dest_dir / "node-runtime.ps1") + ) self.logger.info(f"Setup script copied to {dest_dir} (for reference)") except Exception as e: self.logger.warn(f"Could not copy setup script: {e}") diff --git a/deployer/config.py b/deployer/config.py index 13e8421..e8b6bce 100644 --- a/deployer/config.py +++ b/deployer/config.py @@ -57,7 +57,7 @@ def _load_dotenv() -> None: DEFAULT_CONFIG: dict[str, Any] = { "node": { - "version": "22", + "version": "26", }, "openclaw": { "install_method": "npm", # npm | source diff --git a/deployer/openclaw_version.py b/deployer/openclaw_version.py index f411a4c..98af117 100644 --- a/deployer/openclaw_version.py +++ b/deployer/openclaw_version.py @@ -4,10 +4,13 @@ import re -OPENCLAW_TARGET_VERSION = "2026.8.2" -NODE_FALLBACK_VERSION = "22.22.3" +OPENCLAW_TARGET_VERSION = "2026.9.3" +NODE_ENGINE_RANGE = ">=24.16.0 <25 || >=26.1.0" +NODE_FALLBACK_VERSION = "26.1.0" -_NODE_VERSION_RE = re.compile(r"^v?(?P\d+)\.(?P\d+)\.(?P\d+)$") +_NODE_VERSION_RE = re.compile( + r"v?(?P0|[1-9][0-9]*)\.(?P0|[1-9][0-9]*)\.(?P0|[1-9][0-9]*)" +) _OPENCLAW_VERSION_RE = re.compile(r"openclaw@(?P\S+)") @@ -25,10 +28,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/webview_bridge.py b/deployer/webview_bridge.py index 0db926a..1f21c1e 100644 --- a/deployer/webview_bridge.py +++ b/deployer/webview_bridge.py @@ -799,6 +799,9 @@ def _copy_bundled_assets(self): if src: try: shutil.copy2(str(src), str(dest_dir / "setup-dependencies.ps1")) + shutil.copy2( + str(src.with_name("node-runtime.ps1")), str(dest_dir / "node-runtime.ps1") + ) self._logger.info(f"Setup script copied to {dest_dir} (for reference)") except Exception as exc: self._logger.warn(f"Could not copy setup script: {exc}") diff --git a/deployer/windows_setup.py b/deployer/windows_setup.py index 10d7a25..51103d8 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_ENGINE_RANGE, NODE_FALLBACK_VERSION, OPENCLAW_TARGET_VERSION, is_supported_node_version, @@ -399,7 +400,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", "26") # 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 @@ -1023,7 +1024,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 '26' to the latest supported specific version like '26.1.0'.""" self.log.debug(f"Resolving latest Node.js {major}.x version…") import json import re @@ -1041,8 +1042,8 @@ def _resolve_latest_version(self, major: str) -> str: with resp: data = json.loads(resp.read()) for entry in data: - ver = entry.get("version", "").lstrip("v") - if ver.startswith(f"{major}."): + ver = entry.get("version", "").removeprefix("v") + if ver.startswith(f"{major}.") and is_supported_node_version(ver): self.log.debug(f"Resolved from {url}: {ver}") return ver except Exception as e: @@ -1056,7 +1057,7 @@ def _resolve_latest_version(self, major: str) -> str: html = resp.read().decode("utf-8", errors="replace") arch = self._get_arch() pattern = rf"node-v({major}\.\d+\.\d+)-win-{arch}\.zip" - matches = re.findall(pattern, html) + matches = [v for v in re.findall(pattern, html) if is_supported_node_version(v)] if matches: best = max(matches, key=lambda v: tuple(int(x) for x in v.split("."))) self.log.debug(f"Resolved from npmmirror: {best}") @@ -1072,9 +1073,8 @@ def _resolve_latest_version(self, major: str) -> str: def check_node_windows(self) -> bool: """Check if a suitable Node.js is available on Windows. - Only the managed install (inside node_dir) counts as a pass. - A system-level node is logged for diagnostics but never accepted, - because its version/PATH-priority is outside our control. + Accept a supported managed install or a system Node in a standard + installation directory, provided npm is also available. """ # Check our managed install first — only this is authoritative managed_node = self.node_dir / "node.exe" @@ -1094,7 +1094,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}; Node 26 recommended), " "will reinstall" ) @@ -1165,13 +1165,14 @@ 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 + ``26``) 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. """ target_line = str(self.node_version) - match = re.match(r"(\d+)", target_line) - target_major = int(match.group(1)) if match else 0 + target_major = int(target_line) if re.fullmatch(r"[1-9][0-9]*", target_line) else 26 + if target_major < 24 or target_major == 25: + target_major = 26 installed_major = self._installed_node_major() if installed_major is not None and installed_major > target_major: @@ -1180,9 +1181,22 @@ def _resolve_target_node_version(self) -> str: f"{installed_major}.x line instead of {target_major}.x — the MSI refuses " "to install an older version over a newer one." ) - target_line = str(installed_major) - - return self._resolve_latest_version(target_line) + target_major = installed_major + if target_major == 25: + target_major = 26 + + version = self._resolve_latest_version(str(target_major)) + if not is_supported_node_version(version): + raise NodeInstallBlocked( + f"Resolved Node.js {version!r} is unsupported by OpenClaw " + f"{OPENCLAW_TARGET_VERSION}; need {NODE_ENGINE_RANGE}." + ) + if installed_major is not None and int(version.split(".")[0]) < installed_major: + raise NodeInstallBlocked( + f"Could not resolve a supported Node.js {installed_major}.x or newer release; " + f"refusing to downgrade to {version}. Retry when the version index is available." + ) + return version def install_node_windows(self) -> bool: """Download and install Node.js on Windows via the official signed MSI. @@ -1197,8 +1211,10 @@ def install_node_windows(self) -> bool: self.log.step(f"Installing Node.js on Windows ({self._mirror_name})…") version = self._resolve_target_node_version() - if not _VERSION_RE.match(version): - self.log.error(f"Invalid resolved version: {version!r}") + if not _VERSION_RE.fullmatch(version) or not is_supported_node_version(version): + self.log.error( + f"Unsupported resolved Node.js version: {version!r}; need {NODE_ENGINE_RANGE}" + ) return False self.log.info(f"Resolved version: v{version}") @@ -1297,8 +1313,10 @@ 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; need {NODE_ENGINE_RANGE}" + ) return False self.log.success(f"Node.js {ver} installed to {self.node_dir}") diff --git a/desktop/src/gateway-client.test.ts b/desktop/src/gateway-client.test.ts index 3a78e5f..f50fc62 100644 --- a/desktop/src/gateway-client.test.ts +++ b/desktop/src/gateway-client.test.ts @@ -11,6 +11,38 @@ import { } from "./gateway-client"; import { AGENT_WARMUP_SESSION_KEY } from "./constants"; +describe("GatewayClient approval capability", () => { + it.each([true, false])( + "passes the handler capability through the connect handshake: %s", + async (supportsExecApprovals) => { + const client = Object.create(GatewayClient.prototype) as GatewayClient; + Object.assign(client, { + opts: { port: 18789, token: "test-token", supportsExecApprovals }, + connectSent: false, + connectTimer: null, + connectNonce: "test-nonce", + deviceIdentity: { + deviceId: "test-device", + publicKey: "test-public-key", + privateKey: Buffer.alloc(32, 1).toString("base64url"), + }, + ws: { readyState: 1 }, + }); + const request = vi.spyOn(client, "request").mockResolvedValue({}); + + client["sendConnect"](); + await Promise.resolve(); + + expect(request).toHaveBeenCalledExactlyOnceWith( + "connect", + expect.objectContaining({ + caps: supportsExecApprovals ? ["tool-events", "exec-approvals"] : ["tool-events"], + }), + ); + }, + ); +}); + describe("normalizeGatewayChannelsStatus", () => { it("preserves Gateway order and derives connection state from accounts", () => { const channels = normalizeGatewayChannelsStatus({ diff --git a/desktop/src/gateway-client.ts b/desktop/src/gateway-client.ts index 262d4d8..8579fbc 100644 --- a/desktop/src/gateway-client.ts +++ b/desktop/src/gateway-client.ts @@ -183,6 +183,7 @@ type Pending = { export type GatewayClientOptions = { port: number; token: string; + supportsExecApprovals?: boolean; beforeChatSend?: () => Promise; onEvent?: (evt: GatewayEventFrame) => void; onConnected?: (hello: Record) => void; @@ -636,6 +637,7 @@ export class GatewayClient { signature, signedAt: signedAtMs, nonce, + supportsExecApprovals: this.opts.supportsExecApprovals, }); this.request>("connect", params) diff --git a/desktop/src/gateway-manager.test.ts b/desktop/src/gateway-manager.test.ts new file mode 100644 index 0000000..0973324 --- /dev/null +++ b/desktop/src/gateway-manager.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it, vi } from "vitest"; +import { spawn } from "child_process"; +import { GatewayManager } from "./gateway-manager"; +import { resolveNodePath } from "./path-resolver"; + +vi.mock("child_process", () => ({ spawn: vi.fn() })); +vi.mock("./path-resolver", () => ({ + resolveNodePath: vi.fn(), + resolveOpenClawEntry: vi.fn(), + loadStateDirEnv: vi.fn(), +})); + +describe("GatewayManager runtime validation", () => { + it("reports an unsupported runtime without spawning a Gateway or leaving starting status", async () => { + const manager = new GatewayManager("C:\\MicroClaw\\state", 18789); + Object.assign(manager, { + waitForPortAvailable: vi.fn().mockResolvedValue(undefined), + cleanStaleLockFiles: vi.fn(), + }); + const statuses = vi.fn(); + const logs = vi.fn(); + manager.on("status", statuses); + manager.on("log", logs); + vi.mocked(resolveNodePath).mockImplementation(() => { + throw new Error("OpenClaw 2026.9.3 requires Node.js >=24.16.0 <25 || >=26.1.0"); + }); + + await expect(manager.start()).resolves.toBe(18789); + expect(statuses.mock.calls).toEqual([["starting"], ["failed"]]); + expect(logs).toHaveBeenCalledWith(expect.stringContaining(">=24.16.0 <25 || >=26.1.0")); + expect(spawn).not.toHaveBeenCalled(); + }); +}); diff --git a/desktop/src/gateway-manager.ts b/desktop/src/gateway-manager.ts index 620d8ff..7073fdf 100644 --- a/desktop/src/gateway-manager.ts +++ b/desktop/src/gateway-manager.ts @@ -99,7 +99,14 @@ export class GatewayManager extends EventEmitter { await this.waitForPortAvailable(); this.cleanStaleLockFiles(); - const nodePath = resolveNodePath(); + let nodePath: string; + try { + nodePath = resolveNodePath(); + } catch (error) { + this.emit("log", `ERROR: ${error instanceof Error ? error.message : String(error)}`); + this.emit("status", "failed"); + return this.port; + } const entryPath = resolveOpenClawEntry(); // Log resolved paths for diagnostics diff --git a/desktop/src/gateway-protocol.test.ts b/desktop/src/gateway-protocol.test.ts index 2cc82aa..2b0bd68 100644 --- a/desktop/src/gateway-protocol.test.ts +++ b/desktop/src/gateway-protocol.test.ts @@ -62,4 +62,24 @@ describe("buildGatewayConnectParams", () => { expect(params).not.toHaveProperty("auth"); }); + + it.each([true, false])( + "advertises approval delivery only when the handler is enabled: %s", + (supportsExecApprovals) => { + const params = buildGatewayConnectParams({ + token: "token", + platform: "win32", + deviceId: "device", + publicKey: "public", + signature: "signature", + signedAt: 123, + nonce: "nonce", + supportsExecApprovals, + }); + + expect(params.caps).toEqual( + supportsExecApprovals ? ["tool-events", "exec-approvals"] : ["tool-events"], + ); + }, + ); }); diff --git a/desktop/src/gateway-protocol.ts b/desktop/src/gateway-protocol.ts index 598ad4e..35a1678 100644 --- a/desktop/src/gateway-protocol.ts +++ b/desktop/src/gateway-protocol.ts @@ -15,6 +15,7 @@ export type GatewayConnectInput = { signature: string; signedAt: number; nonce: string; + supportsExecApprovals?: boolean; }; export function buildGatewayConnectParams(input: GatewayConnectInput): Record { @@ -36,7 +37,7 @@ export function buildGatewayConnectParams(input: GatewayConnectInput): Record { const configuredPort = config?.gateway?.port || DEFAULT_PORT; gatewayPort = configuredPort; const stateDir = getOpenClawStateDir(); - const nodePath = resolveNodePath(); + let nodePath: string; + try { + nodePath = resolveNodePath(); + } catch (error) { + const message = `[error] ${error instanceof Error ? error.message : String(error)}`; + console.error(message); + mainWindow?.webContents.send("gateway:log", message); + setGatewayStatus("failed"); + throw error; + } const entryPath = resolveOpenClawEntry(); const gatewayEnvironment = loadGatewayEnvironment(stateDir); @@ -4238,6 +4247,7 @@ function connectGatewayWs(): void { gwClient = new GatewayClient({ port: gatewayPort, token: gatewayToken, + supportsExecApprovals: isWindowsNodeMxcDesired(), beforeChatSend: requireEffectiveWindowsNodeMxc, onConnected: () => { console.log("[gateway-ws] connected"); diff --git a/desktop/src/openclaw-approval-replay-compat.mjs b/desktop/src/openclaw-approval-replay-compat.mjs index a1704de..b2ba402 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.8.2"; -const EXPECTED_NODE_GATEWAY_MODULE = "nodes-EjR1K851.js"; +const EXPECTED_VERSION = "2026.9.3"; +const EXPECTED_NODE_GATEWAY_MODULE = "nodes-C8-hkmi0.mjs"; const EXPECTED_NODE_GATEWAY_SHA256 = - "099116b8473febbf3ffc30022f78bd62451bc9f7f604be6ed987e9dd4cd1ad91"; -const EXPECTED_SYSTEM_RUN_MODULE = "system-run-approval-binding-CBdJlfb5.js"; + "805c73836285f1b0bd503f755b388052e75dfc21efdac5a408731e6efcb72fbb"; +const EXPECTED_SYSTEM_RUN_MODULE = "system-run-approval-binding-DMkQH3tb.mjs"; const EXPECTED_SYSTEM_RUN_SHA256 = - "aab50ca701cd8b5a453abbd4ba03277c51e22e8739ea7fac4c826ae61c053e50"; -const EXPECTED_EXEC_APPROVAL_MODULE = "exec-approval-D4dsyUjJ.js"; + "08ae201f03f2b0bcd2c8c91ade26acf1572401001ed3daf1564910058c598bfd"; +const EXPECTED_EXEC_APPROVAL_MODULE = "exec-approval-B0MHHes6.mjs"; const EXPECTED_EXEC_APPROVAL_SHA256 = - "f87300c477bc4a80a95c1f052b44a4fe21c5a85932044bd3afe6656fa3a0e99c"; + "182e54781c24463a2b636aa0ecebd9fa959871c594167fb0f14d9301d496edc2"; 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; @@ -450,8 +450,7 @@ function installApprovalProofMinter() { }); } -function initialize() { - const packageDir = process.env.MICROCLAW_OPENCLAW_PACKAGE_DIR; +export function validatePinnedOpenClawApprovalPackage(packageDir) { if (!packageDir || !isAbsolute(packageDir)) { throw new Error("MICROCLAW_OPENCLAW_PACKAGE_DIR must be an absolute path"); } @@ -464,7 +463,7 @@ function initialize() { ); } - const targets = [ + return [ { module: EXPECTED_NODE_GATEWAY_MODULE, sha256: EXPECTED_NODE_GATEWAY_SHA256, @@ -491,8 +490,13 @@ function initialize() { if (hash !== target.sha256) { throw new Error(`Pinned OpenClaw approval module hash mismatch (${target.module}): ${hash}`); } + target.patch(original.toString("utf8")); return { ...target, targetPath, original }; }); +} + +function initialize() { + const targets = validatePinnedOpenClawApprovalPackage(process.env.MICROCLAW_OPENCLAW_PACKAGE_DIR); installApprovalProofMinter(); registerHooks({ diff --git a/desktop/src/openclaw-approval-replay-compat.test.ts b/desktop/src/openclaw-approval-replay-compat.test.ts index a8b6014..2b41386 100644 --- a/desktop/src/openclaw-approval-replay-compat.test.ts +++ b/desktop/src/openclaw-approval-replay-compat.test.ts @@ -1,4 +1,9 @@ -import { describe, expect, it } from "vitest"; +import { spawnSync } from "node:child_process"; +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"; // @ts-expect-error The compatibility preload is intentionally external ESM for the Gateway child. import * as replayCompat from "./openclaw-approval-replay-compat.mjs"; @@ -17,8 +22,107 @@ const { patchPinnedOpenClawNodeGateway, patchPinnedOpenClawSystemRun, shouldInitializeApprovalPreload, + validatePinnedOpenClawApprovalPackage, } = replayCompat; +describe("pinned OpenClaw package validation", () => { + const temporaryPackages: string[] = []; + + function createPackage(version: string): string { + const packageDir = mkdtempSync(join(tmpdir(), "microclaw-approval-compat-")); + temporaryPackages.push(packageDir); + writeFileSync(join(packageDir, "package.json"), JSON.stringify({ version })); + mkdirSync(join(packageDir, "dist")); + return packageDir; + } + + afterEach(() => { + for (const packageDir of temporaryPackages.splice(0)) { + rmSync(packageDir, { recursive: true, force: true }); + } + }); + + it.each([undefined, "", "relative-package"])("rejects a non-absolute package path: %s", (dir) => { + expect(() => validatePinnedOpenClawApprovalPackage(dir)).toThrow(/absolute path/); + }); + + it.each(["2026.8.2", "2026.9.2", "2026.9.3-beta.1", "2026.9.4"])( + "rejects an unpinned OpenClaw version: %s", + (version) => { + expect(() => validatePinnedOpenClawApprovalPackage(createPackage(version))).toThrow( + /requires OpenClaw 2026\.9\.3/, + ); + }, + ); + + it("rejects modified 9.3 approval bundles", () => { + const packageDir = createPackage("2026.9.3"); + writeFileSync(join(packageDir, "dist", "nodes-C8-hkmi0.mjs"), "// changed"); + expect(() => validatePinnedOpenClawApprovalPackage(packageDir)).toThrow(/hash mismatch/); + }); + + const publishedPackage = process.env.MICROCLAW_OPENCLAW_TEST_PACKAGE_DIR; + describe.skipIf(!publishedPackage)("official npm package integration", () => { + 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", + ]); + for (const target of targets) { + const patched = target.patch(target.original.toString("utf8")); + const checked = spawnSync(process.execPath, ["--check", "--input-type=module"], { + input: patched, + encoding: "utf8", + timeout: 10_000, + }); + expect(checked.error).toBeUndefined(); + expect(checked.status, checked.stderr).toBe(0); + if (target.module.startsWith("nodes-")) { + expect(patched.indexOf("manager.projectDecisionIfActive")).toBeLessThan( + patched.indexOf("next.microclawApprovalProof ="), + ); + } + } + }); + + it("initializes the real preload only after package validation", () => { + const hook = pathToFileURL(resolve(__dirname, "openclaw-approval-replay-compat.mjs")).href; + const initialized = spawnSync( + process.execPath, + [ + "--import", + hook, + "--input-type=module", + "-e", + `import assert from "node:assert/strict"; +assert.equal(process.env.MICROCLAW_MXC_APPROVAL_PROOF_SECRET, undefined); +assert.equal(process.env.MICROCLAW_MXC_APPROVAL_PRELOAD_INITIALIZED, "1"); +assert.equal(typeof globalThis[Symbol.for("microclaw.windows-node-mxc.approval-proof.v1")].mint, "function");`, + ], + { + env: { + ...process.env, + MICROCLAW_WINDOWS_NODE_MXC_APPROVAL_COMPAT: "1", + MICROCLAW_MXC_APPROVAL_PRELOAD_INITIALIZED: "", + MICROCLAW_OPENCLAW_PACKAGE_DIR: publishedPackage, + MICROCLAW_MXC_APPROVAL_PROOF_SECRET: Buffer.alloc(32, 1).toString("base64"), + MICROCLAW_MXC_APPROVAL_PROOF_GATEWAY_GENERATION: "test-generation", + MICROCLAW_MXC_APPROVAL_PROOF_POLICY_FINGERPRINT: "a".repeat(64), + MICROCLAW_MXC_APPROVAL_PROOF_NODE_ID: "b".repeat(64), + }, + encoding: "utf8", + timeout: 10_000, + }, + ); + expect(initialized.error).toBeUndefined(); + expect(initialized.status, initialized.stderr).toBe(0); + expect(initialized.stdout).toContain("enabled one-use node proof"); + }); + }); +}); + describe("pinned OpenClaw node approval proof backport", () => { const source = [ PINNED_NODE_GATEWAY_MINT_INSERT_SOURCE, diff --git a/desktop/src/path-resolver.test.ts b/desktop/src/path-resolver.test.ts index fa66472..f4324b2 100644 --- a/desktop/src/path-resolver.test.ts +++ b/desktop/src/path-resolver.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; // ── Mock electron ───────────────────────────────────────────────────── +const mockApp = vi.hoisted(() => ({ isPackaged: false })); vi.mock("electron", () => ({ app: { getPath: vi.fn((name: string) => { @@ -8,7 +9,9 @@ vi.mock("electron", () => ({ if (name === "appData") return "C:\\Users\\testuser\\AppData\\Roaming"; return ""; }), - isPackaged: false, + get isPackaged() { + return mockApp.isPackaged; + }, }, })); @@ -25,11 +28,17 @@ vi.mock("fs", async () => { }; }); +const mockExecFileSync = vi.hoisted(() => vi.fn()); +vi.mock("child_process", () => ({ + execFileSync: mockExecFileSync, +})); + import * as path from "path"; import { getOpenClawStateDir, loadGatewayEnvironment, loadStateDirEnv, + isSupportedNodeVersion, resolveNodePath, resolveOpenClawEntry, resolveOpenClawPackageDir, @@ -41,6 +50,8 @@ const originalEnv = { ...process.env }; beforeEach(() => { mockExistsSync.mockReset().mockReturnValue(false); mockReadFileSync.mockReset().mockReturnValue(""); + mockExecFileSync.mockReset().mockReturnValue("v26.1.0\r\n"); + mockApp.isPackaged = false; }); afterEach(() => { @@ -119,9 +130,7 @@ describe("loadStateDirEnv", () => { describe("loadGatewayEnvironment", () => { it("uses state-directory values over the desktop process environment", () => { - mockReadFileSync.mockReturnValue( - "OPENCLAW_HOME=D:\\state-home\nSTATE_ONLY=from-state\n", - ); + mockReadFileSync.mockReturnValue("OPENCLAW_HOME=D:\\state-home\nSTATE_ONLY=from-state\n"); expect( loadGatewayEnvironment("D:\\state", { @@ -139,6 +148,14 @@ describe("loadGatewayEnvironment", () => { // ── resolveNodePath ───────────────────────────────────────────────── describe("resolveNodePath", () => { + beforeEach(() => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + it("returns deployer-installed node when it exists", () => { process.env.USERPROFILE = "C:\\Users\\testuser"; const expected = path.join("C:\\Users\\testuser", ".openclaw-node", "node.exe"); @@ -146,10 +163,15 @@ describe("resolveNodePath", () => { expect(resolveNodePath()).toBe(expected); }); - it("falls back to 'node' when nothing exists", () => { + it("falls back to a supported Node on PATH when standard locations are missing", () => { process.env.USERPROFILE = "C:\\Users\\testuser"; mockExistsSync.mockReturnValue(false); expect(resolveNodePath()).toBe("node"); + expect(mockExecFileSync).toHaveBeenCalledWith( + "node", + ["--version"], + expect.objectContaining({ windowsHide: true, timeout: 5_000 }), + ); }); it("falls back to Program Files when deployer node missing", () => { @@ -158,6 +180,112 @@ describe("resolveNodePath", () => { mockExistsSync.mockImplementation((p) => String(p) === progFiles); expect(resolveNodePath()).toBe(progFiles); }); + + it.each(["v22.22.3", "v24.15.9", "v25.9.0", "v26.0.0", "v26.1.0-rc.1"])( + "skips an unsupported legacy installation (%s)", + (version) => { + process.env.USERPROFILE = "C:\\Users\\testuser"; + process.env.ProgramFiles = "C:\\Program Files"; + delete process.env.OPENCLAW_NODE_DIR; + const legacy = path.join(process.env.USERPROFILE, ".openclaw-node", "node.exe"); + const supported = path.join(process.env.ProgramFiles, "nodejs", "node.exe"); + mockExistsSync.mockImplementation((p) => [legacy, supported].includes(String(p))); + mockExecFileSync.mockImplementation((p) => (p === legacy ? version : "v26.1.0")); + expect(resolveNodePath()).toBe(supported); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining(`Skipping unsupported Node.js ${version}`), + ); + }, + ); + + it("skips a broken binary and accepts the per-user MSI installation", () => { + process.env.ProgramFiles = "C:\\Program Files"; + process.env.LOCALAPPDATA = "C:\\Users\\testuser\\AppData\\Local"; + const broken = path.join(process.env.ProgramFiles, "nodejs", "node.exe"); + const supported = path.join(process.env.LOCALAPPDATA, "Programs", "nodejs", "node.exe"); + mockExistsSync.mockImplementation((p) => [broken, supported].includes(String(p))); + mockExecFileSync.mockImplementation((p) => { + if (p === broken) throw new Error("cannot execute"); + return "v24.16.0"; + }); + expect(resolveNodePath()).toBe(supported); + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining("cannot execute")); + }); + + it("honors a supported absolute OPENCLAW_NODE_DIR override", () => { + process.env.OPENCLAW_NODE_DIR = "D:\\custom-node"; + const expected = path.join(process.env.OPENCLAW_NODE_DIR, "node.exe"); + mockExistsSync.mockImplementation((p) => String(p) === expected); + expect(resolveNodePath()).toBe(expected); + }); + + it("does not execute a relative OPENCLAW_NODE_DIR override", () => { + process.env.OPENCLAW_NODE_DIR = "relative-node"; + mockExistsSync.mockReturnValue(false); + expect(resolveNodePath()).toBe("node"); + expect(mockExecFileSync).toHaveBeenCalledTimes(1); + }); + + it("skips an unsupported bundled runtime", () => { + mockApp.isPackaged = true; + vi.stubGlobal("process", { ...process, resourcesPath: "C:\\MicroClaw\\resources" }); + try { + const bundled = path.join(process.resourcesPath, "node.exe"); + mockExistsSync.mockImplementation((p) => String(p) === bundled); + mockExecFileSync.mockImplementation((p) => (p === bundled ? "v22.22.3" : "v26.1.0")); + expect(resolveNodePath()).toBe("node"); + } finally { + vi.unstubAllGlobals(); + } + }); + + it.each(["v25.9.0", "v26.0.0", "invalid"])( + "rejects unsupported Node on PATH (%s) with the required runtime range", + (version) => { + mockExecFileSync.mockReturnValue(version); + expect(() => resolveNodePath()).toThrow(">=24.16.0 <25 || >=26.1.0"); + }, + ); + + it("reports how to install Node when no executable runs", () => { + mockExecFileSync.mockImplementation(() => { + throw new Error("ENOENT"); + }); + expect(() => resolveNodePath()).toThrow("Run the MicroClaw installer or install Node.js 26"); + }); +}); + +describe("isSupportedNodeVersion", () => { + it.each(["24.16.0", "v24.17.0", "26.1.0", "v26.2.0", "27.0.0"])("accepts %s", (version) => + expect(isSupportedNodeVersion(version)).toBe(true), + ); + + it.each([ + "22.22.3", + "v22.99.0", + "23.10.0", + "24.15.9", + "25.9.0", + "v25.99.0", + "26.0.0", + "v26.0.9", + "", + "26", + "26.1", + "26.1.0.0", + "26.1.0-rc.1", + "26.1.0+build.1", + " 26.1.0", + "26.1.0\n", + "vv26.1.0", + "V26.1.0", + "026.1.0", + "26.01.0", + "26.1.00", + "26.1.0", + "-26.1.0", + "26.a.0", + ])("rejects %s", (version) => expect(isSupportedNodeVersion(version)).toBe(false)); }); // ── resolveOpenClawEntry ──────────────────────────────────────────── diff --git a/desktop/src/path-resolver.ts b/desktop/src/path-resolver.ts index 6fcf1c5..ad1ed88 100644 --- a/desktop/src/path-resolver.ts +++ b/desktop/src/path-resolver.ts @@ -8,6 +8,7 @@ import { app } from "electron"; import * as fs from "fs"; import * as path from "path"; +import { execFileSync } from "child_process"; import { resolveBundledOpenClawDir } from "./bundled-runtime"; /** @@ -65,29 +66,61 @@ export function loadGatewayEnvironment( return { ...environment, ...loadStateDirEnv(stateDir) }; } +/** + * Stable Node releases supported by OpenClaw 2026.9.3. + */ +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); + if (!match || match[0] !== value) return false; + const [major, minor, patch] = match.slice(1).map(Number); + if (![major, minor, patch].every(Number.isSafeInteger)) return false; + return (major === 24 && minor >= 16) || (major === 26 && minor >= 1) || major > 26; +} + /** * Resolve the path to `node.exe`. * - * Priority: + * Priority (skipping unsupported or unreadable runtimes): * 1. Bundled in packaged app resources - * 2. Deployer-installed `~/.openclaw-node/node.exe` - * 3. `C:\Program Files\nodejs\node.exe` - * 4. Bare `"node"` (rely on PATH) + * 2. Explicit `OPENCLAW_NODE_DIR` + * 3. Deployer-installed `~/.openclaw-node/node.exe` + * 4. Per-machine and per-user MSI installations + * 5. Bare `"node"` (rely on PATH) */ export function resolveNodePath(): string { - if (app.isPackaged) { - const bundled = path.join(process.resourcesPath, "node.exe"); - if (fs.existsSync(bundled)) return bundled; + const override = process.env.OPENCLAW_NODE_DIR || ""; + const candidates = [ + app.isPackaged ? path.join(process.resourcesPath, "node.exe") : "", + path.isAbsolute(override) ? path.join(override, "node.exe") : "", + process.env.USERPROFILE ? path.join(process.env.USERPROFILE, ".openclaw-node", "node.exe") : "", + path.join(process.env.ProgramFiles || "C:\\Program Files", "nodejs", "node.exe"), + process.env.LOCALAPPDATA + ? path.join(process.env.LOCALAPPDATA, "Programs", "nodejs", "node.exe") + : "", + "node", + ]; + for (const candidate of new Set(candidates.filter(Boolean))) { + if (candidate !== "node" && !fs.existsSync(candidate)) continue; + let version: string; + try { + version = execFileSync(candidate, ["--version"], { + encoding: "utf-8", + windowsHide: true, + timeout: 5_000, + }).trim(); + } catch (error) { + console.warn( + `[path-resolver] Cannot read Node.js version at ${candidate}: ${error instanceof Error ? error.message : String(error)}`, + ); + continue; + } + if (isSupportedNodeVersion(version)) return candidate; + console.warn(`[path-resolver] Skipping unsupported Node.js ${version} at ${candidate}`); } - const ocNode = process.env.USERPROFILE - ? path.join(process.env.USERPROFILE, ".openclaw-node", "node.exe") - : ""; - if (ocNode && fs.existsSync(ocNode)) return ocNode; - - const programFiles = "C:\\Program Files\\nodejs\\node.exe"; - if (fs.existsSync(programFiles)) return programFiles; - - return "node"; + throw new Error( + "OpenClaw 2026.9.3 requires Node.js >=24.16.0 <25 || >=26.1.0. " + + "Run the MicroClaw installer or install Node.js 26.", + ); } /** diff --git a/docs/experimental-windows-node-mxc.md b/docs/experimental-windows-node-mxc.md index 2571248..d8c6cc7 100644 --- a/docs/experimental-windows-node-mxc.md +++ b/docs/experimental-windows-node-mxc.md @@ -203,9 +203,19 @@ identity when replaying the approved `node.invoke system.run`, causing [openclaw/openclaw#103886](https://github.com/openclaw/openclaw/pull/103886), commit `7a38f140a2cf2c99dd08f92db3ea1b291d5b10c4`. MXC mode still enables a MicroClaw-owned Node load hook for the one-use approval proof and prepared-plan identity extensions. The installed OpenClaw -package is not modified. The hook requires OpenClaw `2026.8.2` and exact SHA-256 hashes for the +package is not modified. The hook requires OpenClaw `2026.9.3` and exact SHA-256 hashes for the affected compiled modules; any version, hash, or source-shape mismatch prevents the managed Gateway -from starting. +from starting. The 9.3 package uses `.mjs` bundles; its approval replay and plan-normalization +contracts retain the same patch points, including the upstream active-decision check before replay. +The runtime must be Node `>=24.16.0 <25 || >=26.1.0`. +The desktop advertises the `exec-approvals` Gateway capability only while MXC mode is enabled, +so 9.3 routes approval events to its handler without diverting approvals in normal mode. + +To verify the hook against an unpacked official `openclaw@2026.9.3` npm package, set +`MICROCLAW_OPENCLAW_TEST_PACKAGE_DIR` to its absolute package directory and run +`npm test --prefix desktop -- openclaw-approval-replay-compat`. The integration cases validate the +published module hashes, patch points, preload initialization, and compiled JavaScript syntax without +modifying the installed package or requiring a configured Gateway. OpenClaw's doctor migrates the roster to keyed `agents.entries` with explicit ownership. Desktop startup, skill updates and MXC policy changes preserve this format and never reintroduce diff --git a/scripts/windows/node-runtime.ps1 b/scripts/windows/node-runtime.ps1 new file mode 100644 index 0000000..22a252b --- /dev/null +++ b/scripts/windows/node-runtime.ps1 @@ -0,0 +1,16 @@ +function Test-SupportedNodeVersion { + param([string]$Version) + + if ($Version -cnotmatch '\Av?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\z') { + return $false + } + try { + $parsed = [version]$Version.TrimStart("v") + } catch { + return $false + } + return ( + ($parsed.Major -eq 24 -and $parsed -ge [version]"24.16.0") -or + ($parsed.Major -ge 26 -and $parsed -ge [version]"26.1.0") + ) +} diff --git a/scripts/windows/setup-dependencies.ps1 b/scripts/windows/setup-dependencies.ps1 index cf26f51..33e034a 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 v26.1+ (from npmmirror; also supports v24.16.x and later v24.x) 3. npm 镜像源配置 4. OpenClaw Gateway (npm install -g) 5. V8 编译缓存预热 @@ -24,7 +24,7 @@ 跳过 Git 安装 .PARAMETER OpenClawTag - OpenClaw npm 安装 tag (默认: 2026.8.2) + OpenClaw npm 安装 tag (默认: 2026.9.3) .EXAMPLE .\setup-dependencies.ps1 @@ -37,7 +37,7 @@ param( [ValidateSet("npmmirror", "tencent")] [string]$Mirror = "npmmirror", [switch]$SkipGit, - [string]$OpenClawTag = "2026.8.2" + [string]$OpenClawTag = "2026.9.3" ) Set-StrictMode -Version Latest @@ -70,19 +70,7 @@ function Write-Warn { param([string]$msg) Write-Host " [WARN] $msg" -Foregroun function Write-Info { param([string]$msg) Write-Host " $msg" -ForegroundColor Gray } function Write-Err { param([string]$msg) Write-Host " [ERROR] $msg" -ForegroundColor Red } -function Test-SupportedNodeVersion { - param([string]$Version) - try { - $parsed = [version]$Version.TrimStart("v") - } 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" } - return $false -} +. "$PSScriptRoot\node-runtime.ps1" # ── Helpers ── function Get-Arch { @@ -245,7 +233,7 @@ if (Test-Path $nodeExe) { if (Test-SupportedNodeVersion $ver) { $needInstall = $false } else { - Write-Warn "Node.js $ver is unsupported by OpenClaw $OpenClawTag; upgrading" + Write-Warn "Node.js $ver is unsupported by OpenClaw $OpenClawTag (need >=24.16.0 <25 || >=26.1.0); upgrading to Node 26" } } } @@ -254,13 +242,13 @@ if ($needInstall) { Write-Step "Installing Node.js ($Mirror)..." $arch = Get-Arch - # Resolve latest Node.js 22.x version - $nodeVersion = "22.22.3" + # Resolve the latest supported Node.js 26.x version. + $nodeVersion = "26.1.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 '^26\.' -and (Test-SupportedNodeVersion $v)) { $nodeVersion = $v break } @@ -323,6 +311,9 @@ if ($needInstall) { } $ver = & (Join-Path $NodeDir "node.exe") --version 2>$null + if ($LASTEXITCODE -ne 0 -or -not (Test-SupportedNodeVersion $ver)) { + throw "Installed Node.js $ver is unsupported (need >=24.16.0 <25 || >=26.1.0)" + } Write-Ok "Node.js $ver installed to $NodeDir" } catch { Write-Err "Node.js install failed: $_" diff --git a/tests/test_openclaw_version.py b/tests/test_openclaw_version.py index f7cde4a..bf535e5 100644 --- a/tests/test_openclaw_version.py +++ b/tests/test_openclaw_version.py @@ -1,6 +1,8 @@ import unittest +from deployer.config import DEFAULT_CONFIG from deployer.openclaw_version import ( + NODE_ENGINE_RANGE, NODE_FALLBACK_VERSION, OPENCLAW_TARGET_VERSION, extract_openclaw_version, @@ -10,36 +12,68 @@ class OpenClawVersionTests(unittest.TestCase): def test_target_version(self) -> None: - self.assertEqual(OPENCLAW_TARGET_VERSION, "2026.8.2") + self.assertEqual(OPENCLAW_TARGET_VERSION, "2026.9.3") + self.assertEqual(NODE_ENGINE_RANGE, ">=24.16.0 <25 || >=26.1.0") - 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.99.0", + "23.0.0", + "v23.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.17.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.0")) + self.assertFalse(is_supported_node_version("v26.0.9")) + self.assertTrue(is_supported_node_version("26.1.0")) + self.assertTrue(is_supported_node_version("v26.2.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_malformed_node_versions(self) -> None: + for version in ( + "", + "26", + "26.1", + "26.1.0.0", + "v26.1.0-rc.1", + "26.1.0+build.1", + " 26.1.0", + "26.1.0\n", + "vv26.1.0", + "V26.1.0", + "026.1.0", + "26.01.0", + "26.1.00", + "26.1.0", + "-26.1.0", + "26.a.0", + ): + with self.subTest(version=version): + self.assertFalse(is_supported_node_version(version)) def test_fallback_validity(self) -> None: + self.assertEqual(NODE_FALLBACK_VERSION, "26.1.0") + self.assertEqual(DEFAULT_CONFIG["node"]["version"], "26") 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.8.2 + `-- openclaw@2026.9.3 """ - self.assertEqual(extract_openclaw_version(output), "2026.8.2") + self.assertEqual(extract_openclaw_version(output), "2026.9.3") 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 c7be5f7..56a1610 100644 --- a/tests/test_runtime_gates.py +++ b/tests/test_runtime_gates.py @@ -1,19 +1,97 @@ +import json +import re +import shutil +import subprocess import unittest from pathlib import Path +from deployer.openclaw_version import 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): + def test_approval_replay_hook_targets_installer_openclaw_version(self): + hook = (ROOT / "desktop" / "src" / "openclaw-approval-replay-compat.mjs").read_text( + encoding="utf-8" + ) + versions = re.findall(r'^const EXPECTED_VERSION\s*=\s*"([^"]+)";$', hook, re.MULTILINE) + self.assertEqual(versions, [OPENCLAW_TARGET_VERSION]) + 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.8.2"', script) - self.assertIn('$nodeVersion = "22.22.3"', script) - self.assertIn("function Test-SupportedNodeVersion", script) - self.assertIn("$parsed.Major -eq 23", script) + self.assertIn('[string]$OpenClawTag = "2026.9.3"', script) + self.assertIn('$nodeVersion = "26.1.0"', script) + self.assertIn(r'. "$PSScriptRoot\node-runtime.ps1"', script) + self.assertIn("Test-SupportedNodeVersion $v", script) + self.assertIn("Test-SupportedNodeVersion $ver", script) + self.assertIn("'^26\\.'", script) + + @unittest.skipUnless(POWERSHELL, "PowerShell is required to exercise its runtime gate") + def test_powershell_gate_matches_python_for_boundaries_and_malformed_versions(self): + versions = [ + "v22.22.3", + "22.99.0", + "23.10.0", + "24.15.9", + "24.16.0", + "v24.17.0", + "25.0.0", + "25.9.0", + "25.99.0", + "26.0.0", + "v26.0.9", + "26.1.0", + "v26.2.0", + "27.0.0", + "", + "26", + "26.1", + "26.1.0.0", + "26.1.0-rc.1", + "26.1.0+build.1", + " 26.1.0", + "26.1.0\n", + "vv26.1.0", + "V26.1.0", + "026.1.0", + "26.01.0", + "26.1.00", + "-26.1.0", + "26.a.0", + ] + command = ( + r". .\scripts\windows\node-runtime.ps1; " + f"$versions = '{json.dumps(versions)}' | ConvertFrom-Json; " + "$results = @($versions | ForEach-Object { Test-SupportedNodeVersion $_ }); " + "ConvertTo-Json -InputObject $results -Compress" + ) + result = subprocess.run( + [POWERSHELL, "-NoProfile", "-NonInteractive", "-Command", command], + cwd=ROOT, + 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_build_validates_standard_and_path_node_candidates(self): + script = (ROOT / "build.ps1").read_text(encoding="utf-8") + self.assertIn(r'. "$root\scripts\windows\node-runtime.ps1"', script) + self.assertEqual(script.count("Test-SupportedNodeVersion $version"), 2) + self.assertIn(">=24.16.0 <25 || >=26.1.0", script) + + def test_installer_bundles_runtime_gate_beside_standalone_setup_script(self): + spec = (ROOT / "MicroClawDeployer.spec").read_text(encoding="utf-8") + self.assertIn("('scripts/windows/node-runtime.ps1', '.')", spec) def test_legacy_helper_refuses_nontransactional_existing_upgrade(self): script = (ROOT / "scripts" / "windows" / "setup-dependencies.ps1").read_text( @@ -26,14 +104,23 @@ 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.8.2"), 2) + 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) def test_release_enforces_openclaw_node_range(self): workflow = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8") - self.assertIn("22.22.3", workflow) - self.assertIn("is unsupported by OpenClaw 2026.8.2", workflow) + 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) + + def test_security_workflow_uses_recommended_node(self): + workflow = (ROOT / ".github" / "workflows" / "pr-security-check.yml").read_text( + encoding="utf-8" + ) + self.assertIn('node-version: "26"', workflow) def test_ci_runs_python_unit_tests(self): workflow = (ROOT / ".github" / "workflows" / "pr-build.yml").read_text(encoding="utf-8") diff --git a/tests/test_webview_bridge.py b/tests/test_webview_bridge.py index aa13f2f..2da3cdc 100644 --- a/tests/test_webview_bridge.py +++ b/tests/test_webview_bridge.py @@ -36,6 +36,29 @@ def tearDown(self): def _step_labels(steps): return [step[1] for step in steps] + def test_both_installers_copy_setup_runtime_gate(self): + app = DeployerApp.__new__(DeployerApp) + app.logger = _Log() + for module, copy_assets in ( + ("deployer.webview_bridge", self.bridge._copy_bundled_assets), + ("deploy", lambda: app._copy_bundled_assets(None)), + ): + with self.subTest(module=module): + destination = Path(self.temp.name) / module + with ( + unittest.mock.patch(f"{module}.DEFAULT_DESKTOP_DIR", destination), + unittest.mock.patch(f"{module}.shutil.copytree"), + ): + self.assertTrue(copy_assets()) + self.assertIn( + "Test-SupportedNodeVersion", + (destination / "node-runtime.ps1").read_text(encoding="utf-8"), + ) + self.assertIn( + r'. "$PSScriptRoot\node-runtime.ps1"', + (destination / "setup-dependencies.ps1").read_text(encoding="utf-8"), + ) + def test_prepare_upgrade_prompts_then_closes_running_gateway(self): gateway = ActiveGateway(pid=4321, port=18789, lock_path=Path("gateway.lock")) active = ActiveInstallation(pids=(1234,), gateway=gateway) diff --git a/tests/test_windows_setup_upgrade.py b/tests/test_windows_setup_upgrade.py index 40de89e..a13dc25 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, @@ -770,26 +770,98 @@ 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 install an older major over an existing newer one. + self.ws.node_version = "26" + self.ws._installed_node_major = lambda: 27 self.ws._resolve_latest_version = lambda major: { - "22": "22.23.1", - "24": "24.15.0", + "26": "26.1.0", + "27": "27.0.0", }[major] - self.assertEqual(self.ws._resolve_target_node_version(), "24.15.0") + self.assertEqual(self.ws._resolve_target_node_version(), "27.0.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 = "26" + self.ws._resolve_latest_version = lambda major: "26.1.0" if major == "26" 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(), "26.1.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(), "26.1.0") + + def test_resolve_target_migrates_unsupported_configured_lines(self): + self.ws._installed_node_major = lambda: None + self.ws._resolve_latest_version = unittest.mock.Mock(return_value="26.1.0") + for target in ("22", "23", "25", "invalid"): + with self.subTest(target=target): + self.ws.node_version = target + self.assertEqual(self.ws._resolve_target_node_version(), "26.1.0") + self.ws._resolve_latest_version.assert_called_with("26") + + def test_resolve_target_keeps_supported_node_24_line(self): + self.ws.node_version = "24" + self.ws._installed_node_major = lambda: 24 + self.ws._resolve_latest_version = unittest.mock.Mock(return_value="24.16.0") + self.assertEqual(self.ws._resolve_target_node_version(), "24.16.0") + self.ws._resolve_latest_version.assert_called_once_with("24") + + def test_resolve_target_skips_installed_node_25_line(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_refuses_unsupported_resolution(self): + self.ws.node_version = "26" + self.ws._installed_node_major = lambda: None + for version in ("26.0.0", "25.9.0", "24.15.0", "26.1.0-rc.1"): + with self.subTest(version=version): + self.ws._resolve_latest_version = unittest.mock.Mock(return_value=version) + with self.assertRaises(NodeInstallBlocked): + self.ws._resolve_target_node_version() + + def test_resolve_target_refuses_fallback_major_downgrade(self): + self.ws.node_version = "26" + self.ws._installed_node_major = lambda: 27 + self.ws._resolve_latest_version = unittest.mock.Mock(return_value=NODE_FALLBACK_VERSION) + with self.assertRaisesRegex(NodeInstallBlocked, "refusing to downgrade"): + self.ws._resolve_target_node_version() + + def test_version_index_skips_unsupported_or_malformed_releases(self): + response = unittest.mock.MagicMock() + response.__enter__.return_value.read.return_value = json.dumps( + [{"version": v} for v in ("v26.2.0-rc.1", "v26.0.0", "vv26.1.0", "v26.1.0")] + ).encode() + with unittest.mock.patch("urllib.request.urlopen", return_value=response): + self.assertEqual(self.ws._resolve_latest_version("26"), "26.1.0") + + def test_version_resolution_uses_supported_fallback_offline(self): + self.ws._node_download_base = MIRRORS[MIRROR_OFFICIAL]["node_download_base"] + with unittest.mock.patch("urllib.request.urlopen", side_effect=OSError("offline")): + self.assertEqual(self.ws._resolve_latest_version("26"), NODE_FALLBACK_VERSION) + + def test_managed_node_requires_supported_version(self): + self.ws.node_dir.mkdir(parents=True) + (self.ws.node_dir / "node.exe").write_text("", encoding="utf-8") + self.ws._get_npm_path = unittest.mock.Mock(return_value="npm.cmd") + with unittest.mock.patch("deployer.windows_setup.shutil.which", return_value=None): + for version in ("v22.22.3", "v24.15.9", "v25.9.0", "v26.0.0", "v26.1.0-rc.1"): + with self.subTest(version=version): + self.ws._get_node_version = unittest.mock.Mock(return_value=version) + self.assertFalse(self.ws.check_node_windows()) + for version in ("v24.16.0", "v26.1.0"): + with self.subTest(version=version): + self.ws._get_node_version = unittest.mock.Mock(return_value=version) + self.assertTrue(self.ws.check_node_windows()) + + def test_install_node_rejects_unsupported_version_before_download(self): + self.ws._mirror_name = MIRROR_OFFICIAL + self.ws._resolve_target_node_version = lambda: "26.0.0" + self.ws._download_and_verify_node_msi = unittest.mock.Mock() + self.assertFalse(self.ws.install_node_windows()) + self.ws._download_and_verify_node_msi.assert_not_called() def test_installed_node_major_reads_highest(self): with ( @@ -813,8 +885,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 = "26" + self.ws._resolve_target_node_version = lambda: "26.1.0" self.ws._download_and_verify_node_msi = lambda _version, _path: True self.ws._get_arch = lambda: "x64" From ebf42584c32c5e2291f2c6f0b58fd9d2a5e5dfad Mon Sep 17 00:00:00 2001 From: "Angnuo Li (from Dev Box)" Date: Fri, 11 Sep 2026 10:35:18 +0800 Subject: [PATCH 2/2] Fix OpenClaw 9.3 SQLite worker startup in AppContainer mode Accept the upstream readonly worker caller and optional canonical PID-bound staging directory while retaining existing executable, state and preload restrictions. Preserve the worker kill signal and add regression coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- appcontainer/sandbox-cp-hooks.js | 33 +++++++++++++--- desktop/src/sandbox-logic.test.ts | 63 +++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 6 deletions(-) diff --git a/appcontainer/sandbox-cp-hooks.js b/appcontainer/sandbox-cp-hooks.js index 6a098f4..ad33cd0 100644 --- a/appcontainer/sandbox-cp-hooks.js +++ b/appcontainer/sandbox-cp-hooks.js @@ -132,9 +132,29 @@ function isCanonicalPathWithin(rootPath, candidatePath, runtime) { return relative !== "" && !relative.startsWith("..") && !pathMod.isAbsolute(relative); } +function isOpenClawSqliteStagingRoot(value, runtime) { + if (typeof value !== "string" || !pathMod.isAbsolute(value)) return false; + var localAppData = + runtime && runtime.localAppData ? runtime.localAppData : TRUSTED_WORKER_ENV.LOCALAPPDATA; + if (!localAppData) return false; + var root = canonicalExistingPath(pathMod.join(localAppData, "openclaw"), runtime); + var candidate = canonicalExistingPath(value, runtime); + if (!root || !candidate) return false; + var relative = pathMod.relative(root, candidate); + var pid = runtime && runtime.pid !== undefined ? runtime.pid : process.pid; + var expectedName = new RegExp( + "^openclaw-sqlite-readonly-" + + pid + + "-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", + "i", + ); + return relative === pathMod.basename(value).toLowerCase() && expectedName.test(relative); +} + function isOpenClawInternalSqliteWorkerCommand(cmd, args, stack, runtime, options) { if (options && options.shell) return false; - if (!Array.isArray(args) || args.length !== 4) return false; + if (!Array.isArray(args) || (args.length !== 4 && args.length !== 5)) return false; + if (args.length === 5 && !isOpenClawSqliteStagingRoot(args[4], runtime)) return false; var execPath = normalizeComparablePath( runtime && runtime.execPath ? runtime.execPath : TRUSTED_NODE_EXEC_PATH, ); @@ -164,11 +184,11 @@ function isOpenClawInternalSqliteWorkerCommand(cmd, args, stack, runtime, option var normalizedStack = String(stack || "") .replace(/\//g, pathMod.sep) .toLowerCase(); - var trustedCallerPrefix = - normalizeComparablePath(pathMod.join(packageRoot, "dist")) + - pathMod.sep + - "sqlite-readonly-location-"; - return normalizedStack.indexOf(trustedCallerPrefix) >= 0; + var trustedDist = normalizeComparablePath(pathMod.join(packageRoot, "dist")) + pathMod.sep; + return ( + normalizedStack.indexOf(trustedDist + "sqlite-readonly-location-") >= 0 || + normalizedStack.indexOf(trustedDist + "sqlite-readonly-worker-") >= 0 + ); } function buildInternalSqliteWorkerInvocation(args, options) { @@ -181,6 +201,7 @@ function buildInternalSqliteWorkerInvocation(args, options) { if (options && options.encoding !== undefined) nextOptions.encoding = options.encoding; if (options && options.maxBuffer !== undefined) nextOptions.maxBuffer = options.maxBuffer; if (options && options.timeout !== undefined) nextOptions.timeout = options.timeout; + if (options && options.killSignal !== undefined) nextOptions.killSignal = options.killSignal; return { args: ["--require", TRUSTED_PRELOAD_PATH].concat(args), options: nextOptions, diff --git a/desktop/src/sandbox-logic.test.ts b/desktop/src/sandbox-logic.test.ts index 852b062..098dbd4 100644 --- a/desktop/src/sandbox-logic.test.ts +++ b/desktop/src/sandbox-logic.test.ts @@ -2480,6 +2480,12 @@ describe("OpenClaw internal SQLite worker", () => { const execPath = "C:\\Program Files\\nodejs\\node.exe"; const entryPath = path.join(packageRoot, "openclaw.mjs"); const stateDir = "C:\\Users\\test\\.openclaw"; + const localAppData = "C:\\Users\\test\\AppData\\Local"; + const stagingRoot = path.join( + localAppData, + "openclaw", + "openclaw-sqlite-readonly-2056-139531e6-3179-429e-90e2-f16d5a267869", + ); const workerPath = path.join(packageRoot, "dist", "infra", "sqlite-readonly-location.worker.js"); const args = [ workerPath, @@ -2492,11 +2498,15 @@ describe("OpenClaw internal SQLite worker", () => { const canonical = new Map([ [stateDir.toLowerCase(), stateDir], [args[3].toLowerCase(), args[3]], + [path.join(localAppData, "openclaw").toLowerCase(), path.join(localAppData, "openclaw")], + [stagingRoot.toLowerCase(), stagingRoot], ]); const runtime = { entryPath, execPath, stateDir, + localAppData, + pid: 2056, realpath: (value: string) => { const resolved = canonical.get(value.toLowerCase()); if (!resolved) throw new Error("ENOENT"); @@ -2510,6 +2520,55 @@ describe("OpenClaw internal SQLite worker", () => { ); }); + it.each(["async", "sync"])("accepts the 9.3 %s worker with its owned staging root", (mode) => { + expect( + cpHooks.isOpenClawInternalSqliteWorkerCommand( + execPath, + [workerPath, args[1], mode, args[3], stagingRoot], + stack.replace( + "sqlite-readonly-location-AbCd1234.js", + "sqlite-readonly-worker-AbCd1234.mjs", + ), + runtime, + ), + ).toBe(true); + }); + + it.each([ + "relative", + path.join(stateDir, "output"), + path.join(localAppData, "openclaw", "credentials"), + stagingRoot.replace("-2056-", "-9999-"), + path.join(stagingRoot, "nested"), + ])("rejects an unowned 9.3 staging root: %s", (staging) => { + expect( + cpHooks.isOpenClawInternalSqliteWorkerCommand(execPath, [...args, staging], stack, { + ...runtime, + realpath: (value: string) => value, + }), + ).toBe(false); + }); + + it("rejects redirected staging directories and surplus worker arguments", () => { + expect( + cpHooks.isOpenClawInternalSqliteWorkerCommand(execPath, [...args, stagingRoot], stack, { + ...runtime, + realpath: (value: string) => + value.toLowerCase() === stagingRoot.toLowerCase() + ? path.join(stateDir, path.basename(stagingRoot)) + : runtime.realpath(value), + }), + ).toBe(false); + expect( + cpHooks.isOpenClawInternalSqliteWorkerCommand( + execPath, + [...args, stagingRoot, "--extra"], + stack, + runtime, + ), + ).toBe(false); + }); + it.each([ ["executable", "C:\\Windows\\System32\\cmd.exe", args, stack], [ @@ -2561,6 +2620,8 @@ describe("OpenClaw internal SQLite worker", () => { it("launches the worker with a fixed preload and without caller-controlled Node loading", () => { const invocation = cpHooks.buildInternalSqliteWorkerInvocation(args, { encoding: "utf8", + timeout: 30_000, + killSignal: "SIGKILL", env: { NODE_OPTIONS: '--require "C:\\Users\\test\\.openclaw\\payload.js"', NODE_PATH: "C:\\Users\\test\\.openclaw\\modules", @@ -2573,6 +2634,8 @@ describe("OpenClaw internal SQLite worker", () => { ]); expect(invocation.args.slice(2)).toEqual(args); expect(invocation.options.encoding).toBe("utf8"); + expect(invocation.options.timeout).toBe(30_000); + expect(invocation.options.killSignal).toBe("SIGKILL"); expect(invocation.options.env.NODE_OPTIONS).toBeUndefined(); expect(invocation.options.env.NODE_PATH).toBeUndefined(); expect(invocation.options.env.MICROCLAW_OPENCLAW_INTERNAL_SQLITE_WORKER).toBe("1");