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"