mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
parent
264b24a462
commit
a1f28507b2
@ -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");
|
||||
|
||||
83
src/utils/shell/powershellDetection.test.ts
Normal file
83
src/utils/shell/powershellDetection.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@ -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.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user