From 2e0b0e9cf570fac6ae2b88e39f439737ac5ed5fa Mon Sep 17 00:00:00 2001 From: taoyizhi68 Date: Fri, 15 May 2026 16:42:25 +0800 Subject: [PATCH] fix(installer): install openclaw to %APPDATA%\npm when Program Files is read-only When the Node.js MSI is installed per-machine (default at C:\Program Files\nodejs), a non-elevated installer process gets EPERM trying to npm install -g into that directory. Three related fixes: 1. deployer/windows_setup.py: add _choose_npm_install_prefix() that probes writability of node_dir and falls back to the standard per-user prefix %APPDATA%\npm. The Electron desktop's resolveOpenClawEntry already searches that path, so no resolver changes are needed. 2. deployer/windows_setup.py: replace the bogus success heuristic ('openclaw' substring in npm output) with actual entry-file verification. The substring matched npm's cache-hit URLs (npm http cache https://registry.npmjs.org/openclaw) and so reported success even when install had failed with EPERM, causing a confusing 'openclaw command not found' later in the plugin step. 3. appcontainer/sandbox-state.js: add %APPDATA%\npm to the sandbox _safePaths allow-list so the gateway child process can load openclaw.mjs from the new location without EACCES. Also: build.ps1 now falls back to Get-Command node.exe when node isn't in the three standard install locations, and surfaces a clearer error listing every place it looked. _uninstall_clean_node still removes any system-installed Node.js as before; that behaviour is unchanged in this PR. --- appcontainer/sandbox-state.js | 3 + build.ps1 | 17 ++- deployer/windows_setup.py | 248 +++++++++++++++++++++------------- 3 files changed, 176 insertions(+), 92 deletions(-) diff --git a/appcontainer/sandbox-state.js b/appcontainer/sandbox-state.js index 2b3d61d..1a2bccb 100644 --- a/appcontainer/sandbox-state.js +++ b/appcontainer/sandbox-state.js @@ -93,6 +93,9 @@ var _safePaths = (function () { if (tmp) dirs.push(path.resolve(tmp).toLowerCase() + path.sep); var appdata = process.env.APPDATA || ""; if (appdata) dirs.push(path.resolve(appdata, "microclaw").toLowerCase() + path.sep); + // Per-user npm global prefix (default on Windows when no admin rights). + // The deployer falls back here when C:\Program Files\nodejs isn't writable. + if (appdata) dirs.push(path.resolve(appdata, "npm").toLowerCase() + path.sep); var systemDrive = process.env.SystemDrive || "C:"; dirs.push(path.resolve(systemDrive, "tmp", "openclaw").toLowerCase() + path.sep); return dirs; diff --git a/build.ps1 b/build.ps1 index 1678caf..7464027 100644 --- a/build.ps1 +++ b/build.ps1 @@ -27,7 +27,22 @@ foreach ($candidate in $nodeCandidates) { } } if (-not $nodeFound) { - Write-Host " ERROR: node.exe not found" -ForegroundColor Red + # Fall back to whatever node.exe is already on PATH (e.g. nvm, chocolatey, + # winget shims, or a non-standard install). Many dev machines keep node + # outside the three "standard" locations above, and after an uninstall the + # MSI directory is gone — but a system-wide node may still be available. + $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 + } +} +if (-not $nodeFound) { + Write-Host " ERROR: node.exe not found in any of:" -ForegroundColor Red + foreach ($candidate in $nodeCandidates) { Write-Host " - $candidate" -ForegroundColor Red } + Write-Host " - PATH (Get-Command node.exe)" -ForegroundColor Red + Write-Host " Install Node.js 22+ (https://nodejs.org/) and re-run build.ps1." -ForegroundColor Red exit 1 } diff --git a/deployer/windows_setup.py b/deployer/windows_setup.py index 03c8176..bcbb740 100644 --- a/deployer/windows_setup.py +++ b/deployer/windows_setup.py @@ -948,15 +948,23 @@ def check_openclaw_windows(self) -> bool: node_dir/node_modules/openclaw/, so the package must live there. """ # Primary check: the actual entry file that Electron uses - entry = self.node_dir / "node_modules" / "openclaw" / "openclaw.mjs" - if not entry.exists(): - entry = self.node_dir / "node_modules" / "openclaw" / "dist" / "index.js" - if not entry.exists(): - # Also check npm v10+ lib/ layout - entry = self.node_dir / "lib" / "node_modules" / "openclaw" / "openclaw.mjs" - if not entry.exists(): - entry = self.node_dir / "lib" / "node_modules" / "openclaw" / "dist" / "index.js" - if not entry.exists(): + appdata = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming") + search_roots = [self.node_dir, Path(appdata) / "npm"] + entry: Path | None = None + for root in search_roots: + for sub in ( + ("node_modules", "openclaw", "openclaw.mjs"), + ("node_modules", "openclaw", "dist", "index.js"), + ("lib", "node_modules", "openclaw", "openclaw.mjs"), + ("lib", "node_modules", "openclaw", "dist", "index.js"), + ): + candidate = root.joinpath(*sub) + if candidate.exists(): + entry = candidate + break + if entry: + break + if not entry: return False # Verify version via npm list (using our managed npm) @@ -1042,6 +1050,16 @@ def install_openclaw_windows(self) -> bool: env["NODE_LLAMA_CPP_SKIP_DOWNLOAD"] = "true" self.log.info("Set NODE_LLAMA_CPP_SKIP_DOWNLOAD=true") + # Choose an npm --prefix that the current user can actually write + # to. When Node.js is installed per-machine under + # ``C:\Program Files\nodejs`` (the standard MSI layout) the + # node_modules directory is owned by Administrators and a normal + # (non-elevated) user gets EPERM on ``npm install -g``. Fall + # back to a user-writable directory in those cases. + install_prefix = self._choose_npm_install_prefix() + self.install_prefix = install_prefix # remember for _find_openclaw_cmd + self.log.info(f" npm install prefix: {install_prefix}") + # Stream npm output line-by-line so the UI stays responsive proc = subprocess.Popen( [ @@ -1050,7 +1068,7 @@ def install_openclaw_windows(self) -> bool: "-g", f"openclaw@{tag}", "--prefix", - str(self.node_dir), + str(install_prefix), "--loglevel", "info", "--no-progress", @@ -1073,53 +1091,88 @@ def install_openclaw_windows(self) -> bool: self.log.info(f" npm: {stripped}") proc.wait(timeout=900) - if proc.returncode == 0: - self.log.success("OpenClaw installed on Windows") - # Verify the entry file actually landed where we expect - for _p in [ - self.node_dir / "node_modules" / "openclaw" / "openclaw.mjs", - self.node_dir / "lib" / "node_modules" / "openclaw" / "openclaw.mjs", - self.node_dir / "node_modules" / "openclaw" / "dist" / "index.js", - self.node_dir / "lib" / "node_modules" / "openclaw" / "dist" / "index.js", - ]: - if _p.exists(): - self.log.debug(f" Entry verified: {_p}") - break + # Verify the entry file actually landed under the chosen prefix. + # npm exit code alone is unreliable: it may print warnings (e.g. + # EBADENGINE) and still return non-zero, and the previous + # heuristic ("'openclaw' substring in output") was triggered by + # cache-hit URLs like ``https://registry.npmjs.org/openclaw`` + # even when the install failed with EPERM. + entry_found: Path | None = None + for _p in [ + install_prefix / "node_modules" / "openclaw" / "openclaw.mjs", + install_prefix / "lib" / "node_modules" / "openclaw" / "openclaw.mjs", + install_prefix / "node_modules" / "openclaw" / "dist" / "index.js", + install_prefix / "lib" / "node_modules" / "openclaw" / "dist" / "index.js", + ]: + if _p.exists(): + entry_found = _p + break + + if entry_found: + if proc.returncode == 0: + self.log.success("OpenClaw installed on Windows") else: self.log.warn( - f" openclaw entry file not found under {self.node_dir} — npm may have used a different prefix" + f"npm exited with code {proc.returncode} but openclaw entry was written" ) - # Log npm prefix for diagnostics - try: - _r = self._run( - [npm, "config", "get", "prefix"], - capture_output=True, - text=True, - timeout=10, - env=env, - ) - self.log.warn(f" npm prefix = {_r.stdout.strip()}") - except Exception: - pass + self.log.success("OpenClaw installed on Windows (with warnings)") + self.log.debug(f" Entry verified: {entry_found}") self._register_rollback_openclaw(npm, env) self._patch_pi_ai_usage_streaming() return True - # npm warnings (EBADENGINE etc.) may still succeed - all_output = "\n".join(collected).lower() - if "added" in all_output or "openclaw" in all_output: - self.log.warn(f"npm exited with code {proc.returncode} but packages were added") - self.log.success("OpenClaw installed on Windows (with warnings)") - self._register_rollback_openclaw(npm, env) - self._patch_pi_ai_usage_streaming() - return True - # Log the TAIL of output (actual error is at the end) + + # Entry file missing → install really failed. Log npm prefix for + # diagnostics and surface the tail of npm's output (where the + # real error message lives). + try: + _r = self._run( + [npm, "config", "get", "prefix"], + capture_output=True, + text=True, + timeout=10, + env=env, + ) + self.log.warn(f" npm prefix = {_r.stdout.strip()}") + except Exception: + pass err_out = "\n".join(collected) - self.log.error(f"npm install failed (exit {proc.returncode}):\n{err_out[-1000:]}") + self.log.error( + f"npm install failed (exit {proc.returncode}): openclaw entry not found under " + f"{install_prefix}\n{err_out[-1500:]}" + ) return False except Exception as e: self.log.error(f"OpenClaw install failed: {e}") return False + def _choose_npm_install_prefix(self) -> Path: + """Return a writable directory to use as ``npm install -g --prefix``. + + Prefers ``self.node_dir`` (where Node.js itself lives) so the + resulting ``openclaw.cmd`` sits next to ``node.exe`` on PATH. If + that directory isn't writable for the current user (typical for the + per-machine MSI install under ``C:\\Program Files\\nodejs`` when the + installer is run without elevation), fall back to the standard + per-user npm prefix at ``%APPDATA%\\npm`` — a location both the + deployer (`_find_openclaw_cmd`) and the Electron desktop + (`resolveOpenClawEntry`) already know how to find. + """ + candidate = self.node_dir + try: + candidate.mkdir(parents=True, exist_ok=True) + probe = candidate / ".write-probe" + probe.write_text("ok", encoding="utf-8") + probe.unlink(missing_ok=True) + return candidate + except (OSError, PermissionError): + appdata = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming") + fallback = Path(appdata) / "npm" + fallback.mkdir(parents=True, exist_ok=True) + self.log.info( + f" {candidate} 不可写,改用用户目录 {fallback}(无需管理员权限)" + ) + return fallback + def _patch_pi_ai_usage_streaming(self) -> None: """Force pi-ai's openai-completions provider to always emit ``stream_options: { include_usage: true }``. @@ -1131,42 +1184,35 @@ def _patch_pi_ai_usage_streaming(self) -> None: causes the Usage dashboard to report 0 tokens. Patch the guard away so usage is always requested. Idempotent; safe to re-run. """ - candidates = [ - self.node_dir - / "node_modules" - / "openclaw" - / "node_modules" - / "@mariozechner" - / "pi-ai" - / "dist" - / "providers" - / "openai-completions.js", - self.node_dir - / "node_modules" - / "@mariozechner" - / "pi-ai" - / "dist" - / "providers" - / "openai-completions.js", - self.node_dir - / "lib" - / "node_modules" - / "openclaw" - / "node_modules" - / "@mariozechner" - / "pi-ai" - / "dist" - / "providers" - / "openai-completions.js", - self.node_dir - / "lib" - / "node_modules" - / "@mariozechner" - / "pi-ai" - / "dist" - / "providers" - / "openai-completions.js", - ] + candidates = [] + # Search both node_dir and the npm install prefix actually used + # (these differ when we fell back to %APPDATA%\npm). + roots: list[Path] = [self.node_dir] + install_prefix = getattr(self, "install_prefix", None) + if install_prefix and Path(install_prefix) not in roots: + roots.append(Path(install_prefix)) + appdata = os.environ.get("APPDATA") + if appdata: + appdata_npm = Path(appdata) / "npm" + if appdata_npm not in roots: + roots.append(appdata_npm) + for root in roots: + for sub in ( + ("node_modules", "openclaw", "node_modules"), + ("node_modules",), + ("lib", "node_modules", "openclaw", "node_modules"), + ("lib", "node_modules"), + ): + candidates.append( + root.joinpath( + *sub, + "@mariozechner", + "pi-ai", + "dist", + "providers", + "openai-completions.js", + ) + ) target = next((p for p in candidates if p.exists()), None) if target is None: self.log.debug( @@ -1214,14 +1260,32 @@ def warmup_compile_cache(self) -> bool: self.log.step("Warming up compile cache for faster startup…") node = self.node_dir / "node.exe" - entry = self.node_dir / "node_modules" / "openclaw" / "openclaw.mjs" - if not entry.exists(): - entry = self.node_dir / "node_modules" / "openclaw" / "dist" / "index.js" - if not entry.exists(): - entry = self.node_dir / "lib" / "node_modules" / "openclaw" / "openclaw.mjs" - if not entry.exists(): - entry = self.node_dir / "lib" / "node_modules" / "openclaw" / "dist" / "index.js" - if not node.exists() or not entry.exists(): + # Search both node_dir and the npm install prefix actually used + # (these differ when we fell back to %APPDATA%\npm). + entry_roots: list[Path] = [self.node_dir] + install_prefix = getattr(self, "install_prefix", None) + if install_prefix and Path(install_prefix) not in entry_roots: + entry_roots.append(Path(install_prefix)) + appdata = os.environ.get("APPDATA") + if appdata: + appdata_npm = Path(appdata) / "npm" + if appdata_npm not in entry_roots: + entry_roots.append(appdata_npm) + entry: Path | None = None + for root in entry_roots: + for sub in ( + ("node_modules", "openclaw", "openclaw.mjs"), + ("node_modules", "openclaw", "dist", "index.js"), + ("lib", "node_modules", "openclaw", "openclaw.mjs"), + ("lib", "node_modules", "openclaw", "dist", "index.js"), + ): + candidate = root.joinpath(*sub) + if candidate.exists(): + entry = candidate + break + if entry: + break + if not node.exists() or entry is None: self.log.info(" Node or openclaw entry not found — skipping warmup") return True @@ -3157,10 +3221,12 @@ def _find_openclaw_cmd(self) -> list[str] | None: Prefer .cmd over bare name to avoid .ps1 execution-policy issues. """ + # Prefix used by the most recent install (set by install_openclaw_windows) + install_prefix = getattr(self, "install_prefix", None) # Managed node dir (always check, even if _node_bin not set) - for search_dir in filter(None, [self._node_bin, self.node_dir]): + for search_dir in filter(None, [install_prefix, self._node_bin, self.node_dir]): for name in ("openclaw.cmd", "openclaw.exe", "openclaw"): - p = search_dir / name + p = Path(search_dir) / name if p.exists(): return [str(p)] # npm global