fix: honor Windows pwsh for agent commands (#665)

Desktop terminal settings previously only controlled the UI terminal, so Windows sessions could show pwsh while agent PowerShell detection still fell back to Windows PowerShell 5.1. The sidecar now receives a narrow PowerShell-only override and the runtime resolves pwsh through PSHOME and standard Windows paths before falling back.

Fixes #665.

Constraint: Windows terminal shell selection must not make cmd, Git Bash, or arbitrary custom shells drive PowerShellTool execution
Rejected: Reuse the full terminal shell path for agent tools | would couple UI terminal behavior to tool execution and could route non-PowerShell shells into PowerShell detection
Confidence: medium
Scope-risk: moderate
Directive: Keep agent command shell selection narrower than terminal UI shell selection unless the permission/parser model is updated with it
Tested: bun test src/utils/shell/powershellDetection.test.ts; cd desktop/src-tauri && cargo test agent_powershell_override --lib; bun run check:native; bun run check:server; git diff --check
Not-tested: Live Windows desktop smoke with installed pwsh
Related: #665
(cherry picked from commit ecd813b9d2aebfb8d8b6d196170177eec969a6de)
This commit is contained in:
程序员阿江(Relakkes) 2026-06-01 20:13:59 +08:00
parent 264b24a462
commit a1f28507b2
3 changed files with 308 additions and 6 deletions

View File

@ -222,6 +222,7 @@ mod webview_panel;
const SERVER_STARTUP_LOG_LIMIT: usize = 80;
const SERVER_BIND_HOST: &str = "0.0.0.0";
const SERVER_CONTROL_HOST: &str = "127.0.0.1";
const CLAUDE_CODE_POWERSHELL_PATH_ENV: &str = "CLAUDE_CODE_POWERSHELL_PATH";
const MAIN_WINDOW_LABEL: &str = "main";
const TRAY_SHOW_ID: &str = "tray_show";
const TRAY_QUIT_ID: &str = "tray_quit";
@ -1386,6 +1387,11 @@ fn resolved_terminal_shell(app: &AppHandle) -> Result<String, String> {
Ok(override_shell.unwrap_or(system_default))
}
fn read_agent_powershell_path_override() -> Option<String> {
let configured = read_desktop_terminal_config();
resolve_agent_powershell_path_override(current_terminal_host_platform(), configured.as_ref())
}
fn current_terminal_host_platform() -> TerminalHostPlatform {
#[cfg(target_os = "windows")]
{
@ -1432,6 +1438,42 @@ fn resolve_desktop_terminal_shell(
}
}
fn is_powershell_executable_path(path: &str) -> bool {
let trimmed = path.trim();
if trimmed.is_empty() {
return false;
}
let file_name = trimmed.rsplit(['/', '\\']).next().unwrap_or(trimmed);
let lowercase = file_name.to_ascii_lowercase();
let base = lowercase.strip_suffix(".exe").unwrap_or(&lowercase);
matches!(base, "pwsh" | "powershell")
}
fn resolve_agent_powershell_path_override(
platform: TerminalHostPlatform,
config: Option<&DesktopTerminalConfig>,
) -> Option<String> {
if platform != TerminalHostPlatform::Windows {
return None;
}
let startup_shell = config?.startup_shell.as_deref()?.trim();
match startup_shell {
"pwsh" => Some("pwsh.exe".to_string()),
"powershell" => Some("powershell.exe".to_string()),
"custom" => {
let custom_path = config?.custom_shell_path.as_deref()?.trim();
if is_powershell_executable_path(custom_path) {
Some(custom_path.to_string())
} else {
None
}
}
_ => None,
}
}
fn normalize_terminal_bash_path(path: Option<String>) -> Result<Option<String>, String> {
let Some(path) = path else {
return Ok(None);
@ -1593,6 +1635,9 @@ fn start_server_sidecar(app: &AppHandle) -> Result<ServerRuntime, String> {
for (key, value) in terminal_environment(&default_shell(None)) {
sidecar = sidecar.env(key, value);
}
if let Some(powershell_path) = read_agent_powershell_path_override() {
sidecar = sidecar.env(CLAUDE_CODE_POWERSHELL_PATH_ENV, powershell_path);
}
// Pass through CLAUDE_CONFIG_DIR so the sidecar (Node.js) uses the same
// portable config directory. Also set XDG_CACHE_HOME to redirect the
// env-paths cache from %LOCALAPPDATA%\claude-cli-nodejs\ to alongside
@ -1920,12 +1965,11 @@ fn kill_windows_sidecars() {
#[cfg(test)]
mod tests {
use super::{
decode_terminal_output, default_utf8_locale, ensure_utf8_locale,
dir_has_portable_data, has_meaningful_intersection, is_persistable_window_state,
normalize_terminal_bash_path, parse_env_block, resolve_desktop_terminal_shell,
resolve_terminal_cwd, run_notification_bridge,
select_h5_dist_dir, DesktopTerminalConfig, StoredWindowState, TerminalHostPlatform,
SERVER_BIND_HOST, SERVER_CONTROL_HOST,
decode_terminal_output, default_utf8_locale, dir_has_portable_data, ensure_utf8_locale,
has_meaningful_intersection, is_persistable_window_state, normalize_terminal_bash_path,
parse_env_block, resolve_agent_powershell_path_override, resolve_desktop_terminal_shell,
resolve_terminal_cwd, run_notification_bridge, select_h5_dist_dir, DesktopTerminalConfig,
StoredWindowState, TerminalHostPlatform, SERVER_BIND_HOST, SERVER_CONTROL_HOST,
};
use std::{collections::HashMap, fs};
@ -2177,6 +2221,57 @@ mod tests {
);
}
#[test]
fn agent_powershell_override_uses_windows_power_shell_preferences() {
let pwsh = DesktopTerminalConfig {
startup_shell: Some("pwsh".to_string()),
custom_shell_path: None,
};
assert_eq!(
resolve_agent_powershell_path_override(TerminalHostPlatform::Windows, Some(&pwsh)),
Some("pwsh.exe".to_string())
);
let powershell = DesktopTerminalConfig {
startup_shell: Some("powershell".to_string()),
custom_shell_path: None,
};
assert_eq!(
resolve_agent_powershell_path_override(
TerminalHostPlatform::Windows,
Some(&powershell),
),
Some("powershell.exe".to_string())
);
}
#[test]
fn agent_powershell_override_accepts_only_custom_power_shell_paths() {
let custom_pwsh = DesktopTerminalConfig {
startup_shell: Some("custom".to_string()),
custom_shell_path: Some(r"C:\Program Files\PowerShell\7\pwsh.exe".to_string()),
};
assert_eq!(
resolve_agent_powershell_path_override(
TerminalHostPlatform::Windows,
Some(&custom_pwsh),
),
Some(r"C:\Program Files\PowerShell\7\pwsh.exe".to_string())
);
let custom_bash = DesktopTerminalConfig {
startup_shell: Some("custom".to_string()),
custom_shell_path: Some(r"C:\Program Files\Git\bin\bash.exe".to_string()),
};
assert_eq!(
resolve_agent_powershell_path_override(
TerminalHostPlatform::Windows,
Some(&custom_bash),
),
None
);
}
#[test]
fn server_sidecar_binds_lan_but_reports_loopback_control_url() {
assert_eq!(SERVER_BIND_HOST, "0.0.0.0");

View File

@ -0,0 +1,83 @@
import { describe, expect, test } from 'bun:test'
import {
isPowerShellExecutablePath,
resolvePowerShellPathOverride,
} from './powershellDetection.js'
describe('PowerShell detection override', () => {
test('accepts pwsh and powershell executable paths', () => {
expect(
isPowerShellExecutablePath('C:\\Program Files\\PowerShell\\7\\pwsh.exe'),
).toBe(true)
expect(
isPowerShellExecutablePath(
'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe',
),
).toBe(true)
})
test('rejects custom shells that are not PowerShell executables', () => {
expect(
isPowerShellExecutablePath('C:\\Program Files\\Git\\bin\\bash.exe'),
).toBe(false)
expect(isPowerShellExecutablePath('cmd.exe')).toBe(false)
})
test('resolves configured command names through PATH lookup', async () => {
const resolved = await resolvePowerShellPathOverride('pwsh.exe', {
getPlatform: () => 'macos',
probePath: async () => null,
which: async command =>
command === 'pwsh.exe'
? 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'
: null,
})
expect(resolved).toBe('C:\\Program Files\\PowerShell\\7\\pwsh.exe')
})
test('resolves Windows pwsh through PSHOME before PATH lookup', async () => {
const resolved = await resolvePowerShellPathOverride('pwsh.exe', {
getPlatform: () => 'windows',
probePath: async path =>
path === 'C:\\Program Files\\PowerShell\\7\\pwsh.exe' ? path : null,
readWindowsPwshHome: async () => 'C:\\Program Files\\PowerShell\\7',
which: async () => null,
})
expect(resolved).toBe('C:\\Program Files\\PowerShell\\7\\pwsh.exe')
})
test('falls back to known Windows PowerShell install paths', async () => {
const resolved = await resolvePowerShellPathOverride('powershell.exe', {
getPlatform: () => 'windows',
probePath: async path =>
path ===
'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
? path
: null,
which: async () => null,
})
expect(resolved).toBe(
'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe',
)
})
test('ignores missing or non-PowerShell overrides', async () => {
await expect(
resolvePowerShellPathOverride('C:\\Tools\\bash.exe', {
probePath: async () => 'C:\\Tools\\bash.exe',
which: async () => null,
}),
).resolves.toBeNull()
await expect(
resolvePowerShellPathOverride('C:\\Missing\\pwsh.exe', {
getPlatform: () => 'macos',
probePath: async () => null,
which: async () => null,
}),
).resolves.toBeNull()
})
})

View File

@ -1,7 +1,15 @@
import { execFile } from 'child_process'
import { realpath, stat } from 'fs/promises'
import { win32 as pathWin32 } from 'path'
import { getPlatform } from '../platform.js'
import type { Platform } from '../platform.js'
import { which } from '../which.js'
export const POWERSHELL_PATH_OVERRIDE_ENV = 'CLAUDE_CODE_POWERSHELL_PATH'
const WINDOWS_DEFAULT_PWSH_PATH = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'
const WINDOWS_DEFAULT_POWERSHELL_PATH =
'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
async function probePath(p: string): Promise<string | null> {
try {
return (await stat(p)).isFile() ? p : null
@ -10,6 +18,117 @@ async function probePath(p: string): Promise<string | null> {
}
}
function basenameWithoutExe(candidate: string): string {
return candidate
.trim()
.split(/[/\\]/)
.pop()!
.toLowerCase()
.replace(/\.exe$/, '')
}
export function isPowerShellExecutablePath(candidate: string): boolean {
const basename = basenameWithoutExe(candidate)
return basename === 'pwsh' || basename === 'powershell'
}
function hasPathSeparator(candidate: string): boolean {
return candidate.includes('/') || candidate.includes('\\')
}
async function readWindowsPwshHome(): Promise<string | null> {
return new Promise(resolve => {
execFile(
'cmd.exe',
['/C', 'pwsh', '-NoProfile', '-Command', '$PSHOME'],
{
maxBuffer: 8192,
timeout: 3000,
windowsHide: true,
},
(error, stdout) => {
if (error) {
resolve(null)
return
}
const trimmed = stdout.toString().trim()
resolve(trimmed || null)
},
)
})
}
type PowerShellDetectionDeps = {
getPlatform?: () => Platform
probePath?: (p: string) => Promise<string | null>
readWindowsPwshHome?: () => Promise<string | null>
which?: (command: string) => Promise<string | null>
}
async function resolveWindowsPowerShellFallbackPath(
basename: 'pwsh' | 'powershell',
deps: PowerShellDetectionDeps,
): Promise<string | null> {
const platform = (deps.getPlatform ?? getPlatform)()
if (platform !== 'windows') {
return null
}
const pathProbe = deps.probePath ?? probePath
if (basename === 'pwsh') {
// PowerShell Core may be invokable via Windows shell/app aliases while
// absent from the sidecar PATH. Ask pwsh where it actually lives.
const psHome = await (deps.readWindowsPwshHome ?? readWindowsPwshHome)()
if (psHome) {
const resolvedFromHome = await pathProbe(
pathWin32.join(psHome, 'pwsh.exe'),
)
if (resolvedFromHome) {
return resolvedFromHome
}
}
}
return pathProbe(
basename === 'pwsh'
? WINDOWS_DEFAULT_PWSH_PATH
: WINDOWS_DEFAULT_POWERSHELL_PATH,
)
}
export async function resolvePowerShellPathOverride(
override = process.env[POWERSHELL_PATH_OVERRIDE_ENV],
deps: PowerShellDetectionDeps = {},
): Promise<string | null> {
const trimmed = override?.trim()
if (!trimmed || !isPowerShellExecutablePath(trimmed)) {
return null
}
const pathProbe = deps.probePath ?? probePath
const basename = basenameWithoutExe(trimmed) as 'pwsh' | 'powershell'
const resolvedPath = await pathProbe(trimmed)
if (resolvedPath) {
return resolvedPath
}
const windowsFallback = await resolveWindowsPowerShellFallbackPath(
basename,
deps,
)
if (windowsFallback) {
return windowsFallback
}
if (!hasPathSeparator(trimmed)) {
return (deps.which ?? which)(trimmed)
}
return null
}
/**
* Attempts to find PowerShell on the system via PATH.
* Prefers pwsh (PowerShell Core 7+), falls back to powershell (5.1).
@ -22,6 +141,11 @@ async function probePath(p: string): Promise<string | null> {
* Windows/macOS, PATH is sufficient.
*/
export async function findPowerShell(): Promise<string | null> {
const overridePath = await resolvePowerShellPathOverride()
if (overridePath) {
return overridePath
}
const pwshPath = await which('pwsh')
if (pwshPath) {
// Snap launcher hangs in subprocesses. Prefer the direct binary.