From a1f28507b267c745d9ad0397fd149473a88c7ea0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Mon, 1 Jun 2026 20:13:59 +0800 Subject: [PATCH] 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) --- desktop/src-tauri/src/lib.rs | 107 ++++++++++++++++- src/utils/shell/powershellDetection.test.ts | 83 +++++++++++++ src/utils/shell/powershellDetection.ts | 124 ++++++++++++++++++++ 3 files changed, 308 insertions(+), 6 deletions(-) create mode 100644 src/utils/shell/powershellDetection.test.ts diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 93d8f08c..3c508931 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -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 { Ok(override_shell.unwrap_or(system_default)) } +fn read_agent_powershell_path_override() -> Option { + 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 { + 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) -> Result, String> { let Some(path) = path else { return Ok(None); @@ -1593,6 +1635,9 @@ fn start_server_sidecar(app: &AppHandle) -> Result { 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"); diff --git a/src/utils/shell/powershellDetection.test.ts b/src/utils/shell/powershellDetection.test.ts new file mode 100644 index 00000000..e36314ee --- /dev/null +++ b/src/utils/shell/powershellDetection.test.ts @@ -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() + }) +}) diff --git a/src/utils/shell/powershellDetection.ts b/src/utils/shell/powershellDetection.ts index acc3fb77..0baaa29b 100644 --- a/src/utils/shell/powershellDetection.ts +++ b/src/utils/shell/powershellDetection.ts @@ -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 { try { return (await stat(p)).isFile() ? p : null @@ -10,6 +18,117 @@ async function probePath(p: string): Promise { } } +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 { + 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 + readWindowsPwshHome?: () => Promise + which?: (command: string) => Promise +} + +async function resolveWindowsPowerShellFallbackPath( + basename: 'pwsh' | 'powershell', + deps: PowerShellDetectionDeps, +): Promise { + 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 { + 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 { * Windows/macOS, PATH is sufficient. */ export async function findPowerShell(): Promise { + const overridePath = await resolvePowerShellPathOverride() + if (overridePath) { + return overridePath + } + const pwshPath = await which('pwsh') if (pwshPath) { // Snap launcher hangs in subprocesses. Prefer the direct binary.