Skip to content

Commit fa525bf

Browse files
committed
fix(shell): prefer PowerShell 7 on Windows with tested fallbacks (#25684)
1 parent bf5a96a commit fa525bf

File tree

3 files changed

+118
-3
lines changed

3 files changed

+118
-3
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai
1818

1919
- 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.
2020
- 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.
21+
- 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.
2122
- 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.
2223
- 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.
2324
- 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.

src/agents/shell-utils.test.ts

Lines changed: 96 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import os from "node:os";
33
import path from "node:path";
44
import { afterEach, beforeEach, describe, expect, it } from "vitest";
55
import { captureEnv } from "../test-utils/env.js";
6-
import { getShellConfig, resolveShellFromPath } from "./shell-utils.js";
6+
import { getShellConfig, resolvePowerShellPath, resolveShellFromPath } from "./shell-utils.js";
77

88
const isWin = process.platform === "win32";
99

@@ -42,7 +42,8 @@ describe("getShellConfig", () => {
4242
if (isWin) {
4343
it("uses PowerShell on Windows", () => {
4444
const { shell } = getShellConfig();
45-
expect(shell.toLowerCase()).toContain("powershell");
45+
const normalized = shell.toLowerCase();
46+
expect(normalized.includes("powershell") || normalized.includes("pwsh")).toBe(true);
4647
});
4748
return;
4849
}
@@ -113,3 +114,96 @@ describe("resolveShellFromPath", () => {
113114
expect(resolveShellFromPath("bash")).toBeUndefined();
114115
});
115116
});
117+
118+
describe("resolvePowerShellPath", () => {
119+
let envSnapshot: ReturnType<typeof captureEnv>;
120+
const tempDirs: string[] = [];
121+
122+
beforeEach(() => {
123+
envSnapshot = captureEnv([
124+
"ProgramFiles",
125+
"PROGRAMFILES",
126+
"ProgramW6432",
127+
"SystemRoot",
128+
"WINDIR",
129+
"PATH",
130+
]);
131+
});
132+
133+
afterEach(() => {
134+
envSnapshot.restore();
135+
for (const dir of tempDirs.splice(0)) {
136+
fs.rmSync(dir, { recursive: true, force: true });
137+
}
138+
});
139+
140+
it("prefers PowerShell 7 in ProgramFiles", () => {
141+
const base = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-pfiles-"));
142+
tempDirs.push(base);
143+
const pwsh7Dir = path.join(base, "PowerShell", "7");
144+
fs.mkdirSync(pwsh7Dir, { recursive: true });
145+
const pwsh7Path = path.join(pwsh7Dir, "pwsh.exe");
146+
fs.writeFileSync(pwsh7Path, "");
147+
148+
process.env.ProgramFiles = base;
149+
process.env.PATH = "";
150+
delete process.env.ProgramW6432;
151+
delete process.env.SystemRoot;
152+
delete process.env.WINDIR;
153+
154+
expect(resolvePowerShellPath()).toBe(pwsh7Path);
155+
});
156+
157+
it("prefers ProgramW6432 PowerShell 7 when ProgramFiles lacks pwsh", () => {
158+
const programFiles = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-pfiles-"));
159+
const programW6432 = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-pw6432-"));
160+
tempDirs.push(programFiles, programW6432);
161+
const pwsh7Dir = path.join(programW6432, "PowerShell", "7");
162+
fs.mkdirSync(pwsh7Dir, { recursive: true });
163+
const pwsh7Path = path.join(pwsh7Dir, "pwsh.exe");
164+
fs.writeFileSync(pwsh7Path, "");
165+
166+
process.env.ProgramFiles = programFiles;
167+
process.env.ProgramW6432 = programW6432;
168+
process.env.PATH = "";
169+
delete process.env.SystemRoot;
170+
delete process.env.WINDIR;
171+
172+
expect(resolvePowerShellPath()).toBe(pwsh7Path);
173+
});
174+
175+
it("finds pwsh on PATH when not in standard install locations", () => {
176+
const programFiles = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-pfiles-"));
177+
const binDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bin-"));
178+
tempDirs.push(programFiles, binDir);
179+
const pwshPath = path.join(binDir, "pwsh");
180+
fs.writeFileSync(pwshPath, "");
181+
fs.chmodSync(pwshPath, 0o755);
182+
183+
process.env.ProgramFiles = programFiles;
184+
process.env.PATH = binDir;
185+
delete process.env.ProgramW6432;
186+
delete process.env.SystemRoot;
187+
delete process.env.WINDIR;
188+
189+
expect(resolvePowerShellPath()).toBe(pwshPath);
190+
});
191+
192+
it("falls back to Windows PowerShell 5.1 path when pwsh is unavailable", () => {
193+
const programFiles = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-pfiles-"));
194+
const sysRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sysroot-"));
195+
tempDirs.push(programFiles, sysRoot);
196+
const ps51Dir = path.join(sysRoot, "System32", "WindowsPowerShell", "v1.0");
197+
fs.mkdirSync(ps51Dir, { recursive: true });
198+
const ps51Path = path.join(ps51Dir, "powershell.exe");
199+
fs.writeFileSync(ps51Path, "");
200+
201+
process.env.ProgramFiles = programFiles;
202+
process.env.SystemRoot = sysRoot;
203+
process.env.PATH = "";
204+
delete process.env.ProgramW6432;
205+
delete process.env.WINDIR;
206+
207+
expect(resolvePowerShellPath()).toBe(ps51Path);
208+
});
209+
});

src/agents/shell-utils.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,27 @@ import { spawn } from "node:child_process";
22
import fs from "node:fs";
33
import path from "node:path";
44

5-
function resolvePowerShellPath(): string {
5+
export function resolvePowerShellPath(): string {
6+
// Prefer PowerShell 7 when available; PS 5.1 lacks "&&" support.
7+
const programFiles = process.env.ProgramFiles || process.env.PROGRAMFILES || "C:\\Program Files";
8+
const pwsh7 = path.join(programFiles, "PowerShell", "7", "pwsh.exe");
9+
if (fs.existsSync(pwsh7)) {
10+
return pwsh7;
11+
}
12+
13+
const programW6432 = process.env.ProgramW6432;
14+
if (programW6432 && programW6432 !== programFiles) {
15+
const pwsh7Alt = path.join(programW6432, "PowerShell", "7", "pwsh.exe");
16+
if (fs.existsSync(pwsh7Alt)) {
17+
return pwsh7Alt;
18+
}
19+
}
20+
21+
const pwshInPath = resolveShellFromPath("pwsh");
22+
if (pwshInPath) {
23+
return pwshInPath;
24+
}
25+
626
const systemRoot = process.env.SystemRoot || process.env.WINDIR;
727
if (systemRoot) {
828
const candidate = path.join(

0 commit comments

Comments
 (0)