mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-20 13:53:32 +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_STARTUP_LOG_LIMIT: usize = 80;
|
||||||
const SERVER_BIND_HOST: &str = "0.0.0.0";
|
const SERVER_BIND_HOST: &str = "0.0.0.0";
|
||||||
const SERVER_CONTROL_HOST: &str = "127.0.0.1";
|
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 MAIN_WINDOW_LABEL: &str = "main";
|
||||||
const TRAY_SHOW_ID: &str = "tray_show";
|
const TRAY_SHOW_ID: &str = "tray_show";
|
||||||
const TRAY_QUIT_ID: &str = "tray_quit";
|
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))
|
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 {
|
fn current_terminal_host_platform() -> TerminalHostPlatform {
|
||||||
#[cfg(target_os = "windows")]
|
#[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> {
|
fn normalize_terminal_bash_path(path: Option<String>) -> Result<Option<String>, String> {
|
||||||
let Some(path) = path else {
|
let Some(path) = path else {
|
||||||
return Ok(None);
|
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)) {
|
for (key, value) in terminal_environment(&default_shell(None)) {
|
||||||
sidecar = sidecar.env(key, value);
|
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
|
// Pass through CLAUDE_CONFIG_DIR so the sidecar (Node.js) uses the same
|
||||||
// portable config directory. Also set XDG_CACHE_HOME to redirect the
|
// portable config directory. Also set XDG_CACHE_HOME to redirect the
|
||||||
// env-paths cache from %LOCALAPPDATA%\claude-cli-nodejs\ to alongside
|
// env-paths cache from %LOCALAPPDATA%\claude-cli-nodejs\ to alongside
|
||||||
@ -1920,12 +1965,11 @@ fn kill_windows_sidecars() {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
decode_terminal_output, default_utf8_locale, ensure_utf8_locale,
|
decode_terminal_output, default_utf8_locale, dir_has_portable_data, ensure_utf8_locale,
|
||||||
dir_has_portable_data, has_meaningful_intersection, is_persistable_window_state,
|
has_meaningful_intersection, is_persistable_window_state, normalize_terminal_bash_path,
|
||||||
normalize_terminal_bash_path, parse_env_block, resolve_desktop_terminal_shell,
|
parse_env_block, resolve_agent_powershell_path_override, resolve_desktop_terminal_shell,
|
||||||
resolve_terminal_cwd, run_notification_bridge,
|
resolve_terminal_cwd, run_notification_bridge, select_h5_dist_dir, DesktopTerminalConfig,
|
||||||
select_h5_dist_dir, DesktopTerminalConfig, StoredWindowState, TerminalHostPlatform,
|
StoredWindowState, TerminalHostPlatform, SERVER_BIND_HOST, SERVER_CONTROL_HOST,
|
||||||
SERVER_BIND_HOST, SERVER_CONTROL_HOST,
|
|
||||||
};
|
};
|
||||||
use std::{collections::HashMap, fs};
|
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]
|
#[test]
|
||||||
fn server_sidecar_binds_lan_but_reports_loopback_control_url() {
|
fn server_sidecar_binds_lan_but_reports_loopback_control_url() {
|
||||||
assert_eq!(SERVER_BIND_HOST, "0.0.0.0");
|
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 { realpath, stat } from 'fs/promises'
|
||||||
|
import { win32 as pathWin32 } from 'path'
|
||||||
import { getPlatform } from '../platform.js'
|
import { getPlatform } from '../platform.js'
|
||||||
|
import type { Platform } from '../platform.js'
|
||||||
import { which } from '../which.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> {
|
async function probePath(p: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
return (await stat(p)).isFile() ? p : null
|
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.
|
* Attempts to find PowerShell on the system via PATH.
|
||||||
* Prefers pwsh (PowerShell Core 7+), falls back to powershell (5.1).
|
* 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.
|
* Windows/macOS, PATH is sufficient.
|
||||||
*/
|
*/
|
||||||
export async function findPowerShell(): Promise<string | null> {
|
export async function findPowerShell(): Promise<string | null> {
|
||||||
|
const overridePath = await resolvePowerShellPathOverride()
|
||||||
|
if (overridePath) {
|
||||||
|
return overridePath
|
||||||
|
}
|
||||||
|
|
||||||
const pwshPath = await which('pwsh')
|
const pwshPath = await which('pwsh')
|
||||||
if (pwshPath) {
|
if (pwshPath) {
|
||||||
// Snap launcher hangs in subprocesses. Prefer the direct binary.
|
// Snap launcher hangs in subprocesses. Prefer the direct binary.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user