Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
220 changes: 170 additions & 50 deletions crates/agent-gui/src-tauri/src/runtime/shell_runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,65 @@ fn windows_cmd_command(cmd: &str) -> String {
format!("chcp 65001>nul & {cmd}")
}

#[cfg(windows)]
fn is_windows_system32_dir(dir: &Path) -> bool {
let Some(root) = std::env::var_os("SystemRoot") else {
return false;
};
let normalize = |p: &Path| {
p.to_string_lossy()
.trim_end_matches(['\\', '/'])
.to_ascii_lowercase()
};
normalize(dir) == normalize(&Path::new(&root).join("System32"))
}

/// Git Bash 解析(对标 Claude Code):env 覆盖 → PATH → Git for Windows 默认安装路径。
#[cfg(windows)]
fn find_git_bash() -> Option<PathBuf> {
for var in ["LIVEAGENT_GIT_BASH_PATH", "CLAUDE_CODE_GIT_BASH_PATH"] {
if let Ok(raw) = std::env::var(var) {
let trimmed = raw.trim().trim_matches('"');
if !trimmed.is_empty() {
let path = expand_tilde_path(trimmed);
if path.is_file() {
return Some(path);
}
}
}
}

let path_var = std::env::var_os("PATH").unwrap_or_default();
for dir in std::env::split_paths(&path_var) {
// System32 下的 bash.exe 是 WSL 启动器,不是 Git Bash。
if is_windows_system32_dir(&dir) {
continue;
}
let candidate = dir.join("bash.exe");
if candidate.is_file() {
return Some(candidate);
}
}

let roots = ["ProgramFiles", "ProgramW6432", "ProgramFiles(x86)"]
.iter()
.filter_map(|var| std::env::var_os(var).map(PathBuf::from))
.chain([
PathBuf::from(r"C:\Program Files"),
PathBuf::from(r"C:\Program Files (x86)"),
]);
for root in roots {
// bin\bash.exe 是带 MSYS 环境注入的启动器,优先于 usr\bin 的裸 bash。
for rel in [r"Git\bin\bash.exe", r"Git\usr\bin\bash.exe"] {
let candidate = root.join(rel);
if candidate.is_file() {
return Some(candidate);
}
}
}
None
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct ShellExecutionProfile {
pub platform: &'static str,
Expand All @@ -339,61 +398,75 @@ pub(crate) struct SpawnedPlatformShell {
fn platform_shell_candidates(cmd: &str) -> Vec<ShellCandidate> {
#[cfg(windows)]
{
let powershell_command = windows_powershell_command(cmd);
return vec![
ShellCandidate {
let mut candidates = Vec::new();
if let Some(bash) = find_git_bash() {
candidates.push(ShellCandidate {
profile: ShellExecutionProfile {
platform: "windows",
profile: "windows-pwsh",
shell_family: "powershell",
display_shell: "pwsh",
profile: "windows-git-bash",
shell_family: "posix",
display_shell: "bash",
},
program: PathBuf::from("pwsh"),
args: vec![
"-NoLogo".to_string(),
"-NoProfile".to_string(),
"-NonInteractive".to_string(),
"-Command".to_string(),
powershell_command.clone(),
],
program: bash,
// 非登录 -c:-lc 会执行 /etc/profile 并 cd $HOME,破坏 cwd 语义。
args: vec!["-c".to_string(), cmd.to_string()],
augment_macos_path: false,
});
}
let powershell_command = windows_powershell_command(cmd);
candidates.push(ShellCandidate {
profile: ShellExecutionProfile {
platform: "windows",
profile: "windows-pwsh",
shell_family: "powershell",
display_shell: "pwsh",
},
ShellCandidate {
profile: ShellExecutionProfile {
platform: "windows",
profile: "windows-powershell",
shell_family: "powershell",
display_shell: "powershell",
},
program: PathBuf::from("powershell.exe"),
args: vec![
"-NoLogo".to_string(),
"-NoProfile".to_string(),
"-NonInteractive".to_string(),
"-ExecutionPolicy".to_string(),
"Bypass".to_string(),
"-Command".to_string(),
powershell_command,
],
augment_macos_path: false,
program: PathBuf::from("pwsh"),
args: vec![
"-NoLogo".to_string(),
"-NoProfile".to_string(),
"-NonInteractive".to_string(),
"-Command".to_string(),
powershell_command.clone(),
],
augment_macos_path: false,
});
candidates.push(ShellCandidate {
profile: ShellExecutionProfile {
platform: "windows",
profile: "windows-powershell",
shell_family: "powershell",
display_shell: "powershell",
},
ShellCandidate {
profile: ShellExecutionProfile {
platform: "windows",
profile: "windows-cmd",
shell_family: "cmd",
display_shell: "cmd",
},
program: PathBuf::from("cmd.exe"),
args: vec![
"/D".to_string(),
"/S".to_string(),
"/C".to_string(),
windows_cmd_command(cmd),
],
augment_macos_path: false,
program: PathBuf::from("powershell.exe"),
args: vec![
"-NoLogo".to_string(),
"-NoProfile".to_string(),
"-NonInteractive".to_string(),
"-ExecutionPolicy".to_string(),
"Bypass".to_string(),
"-Command".to_string(),
powershell_command,
],
augment_macos_path: false,
});
candidates.push(ShellCandidate {
profile: ShellExecutionProfile {
platform: "windows",
profile: "windows-cmd",
shell_family: "cmd",
display_shell: "cmd",
},
];
program: PathBuf::from("cmd.exe"),
args: vec![
"/D".to_string(),
"/S".to_string(),
"/C".to_string(),
windows_cmd_command(cmd),
],
augment_macos_path: false,
});
return candidates;
}

#[cfg(target_os = "macos")]
Expand Down Expand Up @@ -760,8 +833,12 @@ mod tests {
let profile = default_platform_shell_profile();
if cfg!(windows) {
assert_eq!(profile.platform, "windows");
assert_eq!(profile.profile, "windows-pwsh");
assert_eq!(profile.shell_family, "powershell");
// 首候选取决于测试机是否装了 Git Bash。
match profile.profile {
"windows-git-bash" => assert_eq!(profile.shell_family, "posix"),
"windows-pwsh" => assert_eq!(profile.shell_family, "powershell"),
other => panic!("unexpected windows profile: {other}"),
}
} else if cfg!(target_os = "macos") {
assert_eq!(profile.platform, "macos");
assert_eq!(profile.profile, "posix-zsh");
Expand All @@ -773,6 +850,49 @@ mod tests {
}
}

#[cfg(windows)]
#[test]
fn windows_shell_chain_orders_git_bash_before_powershell_fallbacks() {
let profiles: Vec<&'static str> = super::platform_shell_candidates("echo hi")
.iter()
.map(|candidate| candidate.profile.profile)
.collect();
let tail = ["windows-pwsh", "windows-powershell", "windows-cmd"];
match profiles.len() {
4 => {
assert_eq!(profiles[0], "windows-git-bash");
assert_eq!(profiles[1..], tail);
}
3 => assert_eq!(profiles[..], tail),
other => panic!("unexpected windows candidate count: {other}"),
}
}

#[cfg(windows)]
#[test]
fn find_git_bash_env_override_prefers_liveagent_var() {
// 单个测试函数串行覆盖所有 env 场景,避免并行 env 竞态。
let dir = tempfile::tempdir().expect("tempdir");
let liveagent_bash = dir.path().join("liveagent-bash.exe");
let claude_bash = dir.path().join("claude-bash.exe");
fs::write(&liveagent_bash, b"").unwrap();
fs::write(&claude_bash, b"").unwrap();

std::env::set_var("LIVEAGENT_GIT_BASH_PATH", &liveagent_bash);
std::env::set_var("CLAUDE_CODE_GIT_BASH_PATH", &claude_bash);
assert_eq!(super::find_git_bash(), Some(liveagent_bash.clone()));

// LIVEAGENT 指向不存在的文件时回退 CLAUDE_CODE。
std::env::set_var(
"LIVEAGENT_GIT_BASH_PATH",
dir.path().join("missing-bash.exe"),
);
assert_eq!(super::find_git_bash(), Some(claude_bash.clone()));

std::env::remove_var("LIVEAGENT_GIT_BASH_PATH");
std::env::remove_var("CLAUDE_CODE_GIT_BASH_PATH");
}

#[test]
fn shell_registry_cancel_marks_registered_run() {
let registry = ShellRunRegistry::default();
Expand Down
12 changes: 5 additions & 7 deletions crates/agent-gui/src/lib/chat/runner/agentRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,10 +295,10 @@ export function buildToolsSuffix(
const bashPlatformLines =
runtimePlatform === "windows"
? [
`- Current platform: ${platformLabel}. Bash runs through Windows-native shells: pwsh, then Windows PowerShell, then cmd.`,
'- Use PowerShell syntax by default: `Write-Output`, `$env:NAME = "value"`, semicolon separators, and PowerShell quoting.',
"- Do not assume Git Bash or POSIX syntax on Windows: avoid `export`, `nohup`, `/dev/null`, and POSIX background detachment.",
"- For long-running Windows commands, dev servers, watchers, or detached processes, use ManagedProcess instead of background shell syntax.",
`- Current platform: ${platformLabel}. Bash runs through Git Bash with POSIX semantics; pwsh, Windows PowerShell, and cmd are fallbacks used only when Git Bash is not installed.`,
"- Write POSIX/bash-compatible commands by default: `export`, `&&`, `/dev/null`, forward-slash paths.",
"- Background commands using `&` must redirect stdout and stderr before detaching, for example `nohup command > /tmp/liveagent-task.log 2>&1 < /dev/null &`.",
"- If a Bash result header reports `shell_family: powershell` or `shell_family: cmd`, Git Bash is missing: switch to PowerShell syntax and suggest installing Git for Windows or setting `LIVEAGENT_GIT_BASH_PATH`.",
]
: [
`- Current platform: ${platformLabel}. Bash runs through POSIX shells.`,
Expand Down Expand Up @@ -349,9 +349,7 @@ export function buildToolsSuffix(

if (has("ManagedProcess")) {
const managedProcessPreference =
runtimePlatform === "windows"
? "- Prefer ManagedProcess over Bash for `pnpm dev`, `deno run main.ts`, `vite`, file watchers, local web servers, or commands that otherwise require detached Windows process syntax."
: "- Prefer ManagedProcess over Bash for `pnpm dev`, `deno run main.ts`, `vite`, file watchers, local web servers, or commands that otherwise require `nohup` and log redirection.";
"- Prefer ManagedProcess over Bash for `pnpm dev`, `deno run main.ts`, `vite`, file watchers, local web servers, or commands that otherwise require `nohup` and log redirection.";
sections.push(
[
"## ManagedProcess",
Expand Down
52 changes: 23 additions & 29 deletions crates/agent-gui/src/lib/tools/shellTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,12 +322,8 @@ function buildCancelledResult(params: {
exit_code: -1,
shell: params.shell || "unknown",
platform: params.runtimePlatform,
profile: params.runtimePlatform === "windows" ? "windows-pwsh" : undefined,
shell_family: params.runtimePlatform
? params.runtimePlatform === "windows"
? "powershell"
: "posix"
: undefined,
profile: params.runtimePlatform === "windows" ? "windows-git-bash" : undefined,
shell_family: params.runtimePlatform ? "posix" : undefined,
stdout: "",
stderr: "Cancelled",
stdout_truncated: false,
Expand Down Expand Up @@ -379,14 +375,12 @@ export function createShellTools(params: {
const platformLabel = runtimePlatformLabel(runtimePlatform);
const shellPolicy =
runtimePlatform === "windows"
? 'Windows runs Bash commands with the native Windows shell chain: pwsh first, then Windows PowerShell, then cmd. Use PowerShell syntax by default: `Write-Output`, `$env:NAME = "value"`, semicolon separators, and `Start-Process` when a process must be detached. Do not assume Git Bash, `export`, `nohup`, `/dev/null`, or POSIX background syntax.'
? "Windows runs Bash commands with Git Bash (POSIX semantics) when available, falling back to pwsh, then Windows PowerShell, then cmd only if Git Bash is not installed. Write POSIX/bash syntax by default: `export NAME=value`, `&&`, `/dev/null`, forward-slash paths. If the result header reports `shell_family: powershell` or `shell_family: cmd`, Git Bash is missing on this machine — switch to PowerShell syntax and suggest installing Git for Windows or setting LIVEAGENT_GIT_BASH_PATH."
: runtimePlatform === "macos"
? "macOS runs Bash commands with POSIX shell syntax: zsh first, then Bash, then sh."
: "Linux runs Bash commands with POSIX shell syntax: Bash first, then zsh, then sh.";
const backgroundPolicy =
runtimePlatform === "windows"
? "For dev servers, watchers, or long-running commands on Windows, use ManagedProcess instead of detached shell syntax."
: "Background commands using `&` must detach stdout and stderr first, for example `nohup command > /tmp/liveagent-task.log 2>&1 < /dev/null &`; otherwise the tool rejects them because inherited pipes can keep Bash running forever.";
"Background commands using `&` must detach stdout and stderr first, for example `nohup command > /tmp/liveagent-task.log 2>&1 < /dev/null &`; otherwise the tool rejects them because inherited pipes can keep Bash running forever. Prefer ManagedProcess for dev servers, watchers, or anything long-running.";
const workdir = params.workdir;
const allowSkillsRoot = params.skillsRootEnabled === true;
const allowManagedProcess = params.managedProcessEnabled !== false;
Expand Down Expand Up @@ -572,18 +566,19 @@ export function createShellTools(params: {
command: string;
stdout: string;
stderr: string;
shellFamily?: string;
}) {
const combined = [params.command, params.stdout, params.stderr].join("\n");
const hints: string[] = [];

if (
runtimePlatform === "windows" &&
/(^|[\s;&|])(?:export|nohup)\b|\/dev\/null|not recognized as|不是内部或外部命令|无法将.*识别为/i.test(
combined,
)
(params.shellFamily === "powershell" || params.shellFamily === "cmd")
) {
hints.push(
'Hint: This Bash tool is running with Windows-native shells. Use PowerShell syntax such as `Write-Output`, `$env:NAME = "value"`, and `;`, or use ManagedProcess for long-running commands.',
`Hint: Git Bash was not found, so this command ran under ${
params.shellFamily === "cmd" ? "cmd" : "PowerShell"
} where POSIX syntax like \`export\`, \`nohup\`, and \`/dev/null\` fails. Rewrite the command in PowerShell syntax for now, and suggest installing Git for Windows or setting LIVEAGENT_GIT_BASH_PATH to restore Bash semantics.`,
);
}

Expand Down Expand Up @@ -772,7 +767,7 @@ export function createShellTools(params: {
const command =
typeof toolCall.arguments?.command === "string" ? toolCall.arguments.command.trim() : "";
if (!command) throw new Error('ManagedProcess.command is required for action="start"');
if (runtimePlatform !== "windows" && scanShellSyntax(command).background) {
if (scanShellSyntax(command).background) {
throw new Error(
"ManagedProcess.command must be a foreground command. Remove `&`; ManagedProcess starts it in the background and captures logs automatically.",
);
Expand Down Expand Up @@ -995,20 +990,18 @@ export function createShellTools(params: {
};
}

if (runtimePlatform !== "windows") {
try {
validateBashBackgroundStdio(command);
} catch (err) {
return {
role: "toolResult",
toolCallId: toolCall.id,
toolName: toolCall.name,
content: [{ type: "text", text: asErrorMessage(err) }],
details: {},
isError: true,
timestamp: now,
};
}
try {
validateBashBackgroundStdio(command);
} catch (err) {
return {
role: "toolResult",
toolCallId: toolCall.id,
toolName: toolCall.name,
content: [{ type: "text", text: asErrorMessage(err) }],
details: {},
isError: true,
timestamp: now,
};
}

const timeoutRaw = toolCall.arguments?.timeout_ms;
Expand Down Expand Up @@ -1073,6 +1066,7 @@ export function createShellTools(params: {
command,
stdout: res.stdout || "",
stderr: res.stderr || "",
shellFamily: res.shell_family,
})
: "";

Expand Down
Loading
Loading