From fa525bf212807735c0a0fb926e416ac55f0dbe99 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 01:49:33 +0000 Subject: [PATCH] fix(shell): prefer PowerShell 7 on Windows with tested fallbacks (#25684) --- CHANGELOG.md | 1 + src/agents/shell-utils.test.ts | 98 +++++++++++++++++++++++++++++++++- src/agents/shell-utils.ts | 22 +++++++- 3 files changed, 118 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b07860dbe3..0f7015de9ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai - iMessage/Reasoning safety: harden iMessage echo suppression with outbound `messageId` matching (plus scoped text fallback), and enforce reasoning-payload suppression on routed outbound delivery paths to prevent hidden thinking text from being sent as user-visible channel messages. (#25897, #1649, #25757) Thanks @rmarr and @Iranb. - Windows/Media safety checks: align async local-file identity validation with sync-safe-open behavior by treating win32 `dev=0` stats as unknown-device fallbacks (while keeping strict dev checks when both sides are non-zero), fixing false `Local media path is not safe to read` drops for local attachments/TTS/images. (#25708, #21989, #25699, #25878) Thanks @kevinWangSheng. +- Windows/Exec shell selection: prefer PowerShell 7 (`pwsh`) discovery (Program Files, ProgramW6432, PATH) before falling back to Windows PowerShell 5.1, fixing `&&` command chaining failures on Windows hosts with PS7 installed. (#25684, #25638) Thanks @zerone0x. - macOS/WebChat panel: fix rounded-corner clipping by using panel-specific visual-effect blending and matching corner masking on both effect and hosting layers. (#22458) Thanks @apethree and @agisilaos. - macOS/IME input: when marked text is active, treat Return as IME candidate confirmation first in both the voice overlay composer and shared chat composer to prevent accidental sends while composing CJK text. (#25178) Thanks @bottotl. - macOS/Voice wake routing: default forwarded voice-wake transcripts to the `webchat` channel (instead of ambiguous `last` routing) so local voice prompts stay pinned to the control chat surface unless explicitly overridden. (#25440) Thanks @chilu18. diff --git a/src/agents/shell-utils.test.ts b/src/agents/shell-utils.test.ts index 25be7c7574e..9716fb73c8d 100644 --- a/src/agents/shell-utils.test.ts +++ b/src/agents/shell-utils.test.ts @@ -3,7 +3,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { captureEnv } from "../test-utils/env.js"; -import { getShellConfig, resolveShellFromPath } from "./shell-utils.js"; +import { getShellConfig, resolvePowerShellPath, resolveShellFromPath } from "./shell-utils.js"; const isWin = process.platform === "win32"; @@ -42,7 +42,8 @@ describe("getShellConfig", () => { if (isWin) { it("uses PowerShell on Windows", () => { const { shell } = getShellConfig(); - expect(shell.toLowerCase()).toContain("powershell"); + const normalized = shell.toLowerCase(); + expect(normalized.includes("powershell") || normalized.includes("pwsh")).toBe(true); }); return; } @@ -113,3 +114,96 @@ describe("resolveShellFromPath", () => { expect(resolveShellFromPath("bash")).toBeUndefined(); }); }); + +describe("resolvePowerShellPath", () => { + let envSnapshot: ReturnType; + const tempDirs: string[] = []; + + beforeEach(() => { + envSnapshot = captureEnv([ + "ProgramFiles", + "PROGRAMFILES", + "ProgramW6432", + "SystemRoot", + "WINDIR", + "PATH", + ]); + }); + + afterEach(() => { + envSnapshot.restore(); + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("prefers PowerShell 7 in ProgramFiles", () => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-pfiles-")); + tempDirs.push(base); + const pwsh7Dir = path.join(base, "PowerShell", "7"); + fs.mkdirSync(pwsh7Dir, { recursive: true }); + const pwsh7Path = path.join(pwsh7Dir, "pwsh.exe"); + fs.writeFileSync(pwsh7Path, ""); + + process.env.ProgramFiles = base; + process.env.PATH = ""; + delete process.env.ProgramW6432; + delete process.env.SystemRoot; + delete process.env.WINDIR; + + expect(resolvePowerShellPath()).toBe(pwsh7Path); + }); + + it("prefers ProgramW6432 PowerShell 7 when ProgramFiles lacks pwsh", () => { + const programFiles = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-pfiles-")); + const programW6432 = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-pw6432-")); + tempDirs.push(programFiles, programW6432); + const pwsh7Dir = path.join(programW6432, "PowerShell", "7"); + fs.mkdirSync(pwsh7Dir, { recursive: true }); + const pwsh7Path = path.join(pwsh7Dir, "pwsh.exe"); + fs.writeFileSync(pwsh7Path, ""); + + process.env.ProgramFiles = programFiles; + process.env.ProgramW6432 = programW6432; + process.env.PATH = ""; + delete process.env.SystemRoot; + delete process.env.WINDIR; + + expect(resolvePowerShellPath()).toBe(pwsh7Path); + }); + + it("finds pwsh on PATH when not in standard install locations", () => { + const programFiles = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-pfiles-")); + const binDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bin-")); + tempDirs.push(programFiles, binDir); + const pwshPath = path.join(binDir, "pwsh"); + fs.writeFileSync(pwshPath, ""); + fs.chmodSync(pwshPath, 0o755); + + process.env.ProgramFiles = programFiles; + process.env.PATH = binDir; + delete process.env.ProgramW6432; + delete process.env.SystemRoot; + delete process.env.WINDIR; + + expect(resolvePowerShellPath()).toBe(pwshPath); + }); + + it("falls back to Windows PowerShell 5.1 path when pwsh is unavailable", () => { + const programFiles = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-pfiles-")); + const sysRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sysroot-")); + tempDirs.push(programFiles, sysRoot); + const ps51Dir = path.join(sysRoot, "System32", "WindowsPowerShell", "v1.0"); + fs.mkdirSync(ps51Dir, { recursive: true }); + const ps51Path = path.join(ps51Dir, "powershell.exe"); + fs.writeFileSync(ps51Path, ""); + + process.env.ProgramFiles = programFiles; + process.env.SystemRoot = sysRoot; + process.env.PATH = ""; + delete process.env.ProgramW6432; + delete process.env.WINDIR; + + expect(resolvePowerShellPath()).toBe(ps51Path); + }); +}); diff --git a/src/agents/shell-utils.ts b/src/agents/shell-utils.ts index ca4faa30195..a4a5dbc115a 100644 --- a/src/agents/shell-utils.ts +++ b/src/agents/shell-utils.ts @@ -2,7 +2,27 @@ import { spawn } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; -function resolvePowerShellPath(): string { +export function resolvePowerShellPath(): string { + // Prefer PowerShell 7 when available; PS 5.1 lacks "&&" support. + const programFiles = process.env.ProgramFiles || process.env.PROGRAMFILES || "C:\\Program Files"; + const pwsh7 = path.join(programFiles, "PowerShell", "7", "pwsh.exe"); + if (fs.existsSync(pwsh7)) { + return pwsh7; + } + + const programW6432 = process.env.ProgramW6432; + if (programW6432 && programW6432 !== programFiles) { + const pwsh7Alt = path.join(programW6432, "PowerShell", "7", "pwsh.exe"); + if (fs.existsSync(pwsh7Alt)) { + return pwsh7Alt; + } + } + + const pwshInPath = resolveShellFromPath("pwsh"); + if (pwshInPath) { + return pwshInPath; + } + const systemRoot = process.env.SystemRoot || process.env.WINDIR; if (systemRoot) { const candidate = path.join(