diff --git a/crates/agent-gui/src-tauri/src/runtime/shell_runner.rs b/crates/agent-gui/src-tauri/src/runtime/shell_runner.rs index f0127897..37f65d7d 100644 --- a/crates/agent-gui/src-tauri/src/runtime/shell_runner.rs +++ b/crates/agent-gui/src-tauri/src/runtime/shell_runner.rs @@ -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 { + 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, @@ -339,61 +398,75 @@ pub(crate) struct SpawnedPlatformShell { fn platform_shell_candidates(cmd: &str) -> Vec { #[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")] @@ -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"); @@ -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(); diff --git a/crates/agent-gui/src/lib/chat/runner/agentRunner.ts b/crates/agent-gui/src/lib/chat/runner/agentRunner.ts index 82d656a5..c85ab7ca 100644 --- a/crates/agent-gui/src/lib/chat/runner/agentRunner.ts +++ b/crates/agent-gui/src/lib/chat/runner/agentRunner.ts @@ -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.`, @@ -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", diff --git a/crates/agent-gui/src/lib/tools/shellTools.ts b/crates/agent-gui/src/lib/tools/shellTools.ts index 81c441e4..aa4022d4 100644 --- a/crates/agent-gui/src/lib/tools/shellTools.ts +++ b/crates/agent-gui/src/lib/tools/shellTools.ts @@ -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, @@ -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; @@ -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.`, ); } @@ -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.", ); @@ -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; @@ -1073,6 +1066,7 @@ export function createShellTools(params: { command, stdout: res.stdout || "", stderr: res.stderr || "", + shellFamily: res.shell_family, }) : ""; diff --git a/crates/agent-gui/test/chat/markdown-image-policy.test.mjs b/crates/agent-gui/test/chat/markdown-image-policy.test.mjs index 9dfb02ac..b453f73f 100644 --- a/crates/agent-gui/test/chat/markdown-image-policy.test.mjs +++ b/crates/agent-gui/test/chat/markdown-image-policy.test.mjs @@ -271,16 +271,17 @@ test("agent tool rules route installed Skill scripts through skill cwd", () => { assert.match(suffix, /Do not cd into ~\/\.liveagent\/skills or workspace skills\/ guesses/); }); -test("agent Bash rules are Windows-native when runtime platform is Windows", () => { +test("agent Bash rules are Git Bash-first when runtime platform is Windows", () => { const suffix = agentRunnerModule.buildToolsSuffix( "/workspace", ["Bash", "ManagedProcess"], "windows", ); assert.match(suffix, /Current platform: Windows/); - assert.match(suffix, /Use PowerShell syntax by default/); - assert.match(suffix, /avoid `export`, `nohup`, `\/dev\/null`/); - assert.match(suffix, /detached Windows process syntax/); + assert.match(suffix, /Git Bash with POSIX semantics/); + assert.match(suffix, /Write POSIX\/bash-compatible commands by default/); + assert.match(suffix, /shell_family: powershell/); + assert.match(suffix, /require `nohup` and log redirection/); }); test("fs tool descriptions keep Image as the only display path for images", () => { diff --git a/crates/agent-gui/test/tools/shell-tools.test.mjs b/crates/agent-gui/test/tools/shell-tools.test.mjs index 8cbaa624..18f1fa90 100644 --- a/crates/agent-gui/test/tools/shell-tools.test.mjs +++ b/crates/agent-gui/test/tools/shell-tools.test.mjs @@ -14,7 +14,7 @@ function createBashCall(command = "echo ready") { }; } -test("Bash tool keeps one Bash entry and uses Windows-native policy for Claude Code", async () => { +test("Bash tool keeps one Bash entry and uses Git Bash-first policy for Claude Code", async () => { const calls = []; const loader = createTsModuleLoader({ mocks: { @@ -24,10 +24,10 @@ test("Bash tool keeps one Bash entry and uses Windows-native policy for Claude C assert.equal(command, "shell_run"); return { exit_code: 0, - shell: "pwsh", + shell: "bash", platform: "windows", - profile: "windows-pwsh", - shell_family: "powershell", + profile: "windows-git-bash", + shell_family: "posix", stdout: "ready\n", stderr: "", stdout_truncated: false, @@ -50,8 +50,9 @@ test("Bash tool keeps one Bash entry and uses Windows-native policy for Claude C }); assert.match(bundle.tools[0].description, /Windows runs Bash commands/); - assert.match(bundle.tools[0].description, /pwsh first/); - assert.doesNotMatch(bundle.tools[0].description, /Git Bash first/); + assert.match(bundle.tools[0].description, /Git Bash \(POSIX semantics\)/); + assert.match(bundle.tools[0].description, /Write POSIX\/bash syntax by default/); + assert.doesNotMatch(bundle.tools[0].description, /native Windows shell chain/); const result = await bundle.executeToolCall(createBashCall()); @@ -60,10 +61,10 @@ test("Bash tool keeps one Bash entry and uses Windows-native policy for Claude C assert.equal(calls[0].args.provider_id, "claude_code"); assert.equal(calls[0].args.max_timeout_ms, 600_000); assert.match(result.content[0].text, /platform: windows/); - assert.match(result.content[0].text, /profile: windows-pwsh/); + assert.match(result.content[0].text, /profile: windows-git-bash/); }); -test("Bash tool uses the same Windows-native policy for Codex", async () => { +test("Bash tool uses the same Git Bash-first policy for Codex", async () => { const calls = []; const loader = createTsModuleLoader({ mocks: { @@ -73,10 +74,10 @@ test("Bash tool uses the same Windows-native policy for Codex", async () => { assert.equal(command, "shell_run"); return { exit_code: 0, - shell: "pwsh", + shell: "bash", platform: "windows", - profile: "windows-pwsh", - shell_family: "powershell", + profile: "windows-git-bash", + shell_family: "posix", stdout: "ready\n", stderr: "", stdout_truncated: false, @@ -99,7 +100,7 @@ test("Bash tool uses the same Windows-native policy for Codex", async () => { }); assert.match(bundle.tools[0].description, /Windows runs Bash commands/); - assert.match(bundle.tools[0].description, /PowerShell syntax/); + assert.match(bundle.tools[0].description, /Git Bash \(POSIX semantics\)/); assert.doesNotMatch(bundle.tools[0].description, /Codex-style auto shell selection/); const result = await bundle.executeToolCall(createBashCall()); @@ -278,7 +279,36 @@ test("Bash tool rejects background commands with only stderr append redirected", assert.deepEqual(calls, []); }); -test("Bash tool does not apply POSIX ampersand background validation on Windows", async () => { +test("Bash tool applies POSIX ampersand background validation on Windows", async () => { + const calls = []; + const loader = createTsModuleLoader({ + mocks: { + "@tauri-apps/api/core": { + async invoke(command, args) { + calls.push({ command, args }); + throw new Error("shell_run should not be invoked for rejected commands"); + }, + }, + }, + }); + + const { createShellTools } = loader.loadModule("src/lib/tools/shellTools.ts"); + const bundle = createShellTools({ + workdir: "/repo", + providerId: "codex", + runtimePlatform: "windows", + }); + + const result = await bundle.executeToolCall( + createBashCall("deno run --allow-net main.ts 2>> /tmp/server.err &"), + ); + + assert.equal(result.isError, true); + assert.match(result.content[0].text, /Background Bash commands must detach stdout and stderr/); + assert.equal(calls.length, 0); +}); + +test("Bash tool allows detached background commands on Windows", async () => { const calls = []; const loader = createTsModuleLoader({ mocks: { @@ -288,10 +318,10 @@ test("Bash tool does not apply POSIX ampersand background validation on Windows" assert.equal(command, "shell_run"); return { exit_code: 0, - shell: "pwsh", + shell: "bash", platform: "windows", - profile: "windows-pwsh", - shell_family: "powershell", + profile: "windows-git-bash", + shell_family: "posix", stdout: "ok\n", stderr: "", stdout_truncated: false, @@ -313,12 +343,72 @@ test("Bash tool does not apply POSIX ampersand background validation on Windows" runtimePlatform: "windows", }); - const result = await bundle.executeToolCall(createBashCall('& "C:/Program Files/App/app.exe"')); + const result = await bundle.executeToolCall( + createBashCall("nohup deno run main.ts > /tmp/liveagent-test.log 2>&1 < /dev/null &"), + ); assert.equal(result.isError, false); assert.equal(calls.length, 1); }); +function createWindowsFailureLoader(shellFamily, shell) { + return createTsModuleLoader({ + mocks: { + "@tauri-apps/api/core": { + async invoke(command, args) { + assert.equal(command, "shell_run"); + return { + exit_code: 1, + shell, + platform: "windows", + profile: shellFamily === "posix" ? "windows-git-bash" : "windows-pwsh", + shell_family: shellFamily, + stdout: "", + stderr: "export : The term 'export' is not recognized", + stdout_truncated: false, + stderr_truncated: false, + timed_out: false, + cancelled: false, + effective_timeout_ms: args.timeout_ms, + duration_ms: 12, + }; + }, + }, + }, + }); +} + +test("Bash tool hints about missing Git Bash when Windows falls back to PowerShell", async () => { + const loader = createWindowsFailureLoader("powershell", "pwsh"); + const { createShellTools } = loader.loadModule("src/lib/tools/shellTools.ts"); + const bundle = createShellTools({ + workdir: "/repo", + providerId: "claude_code", + runtimePlatform: "windows", + }); + + const result = await bundle.executeToolCall(createBashCall("export NAME=value")); + + assert.equal(result.isError, true); + assert.match(result.content[0].text, /Git Bash was not found/); + assert.match(result.content[0].text, /LIVEAGENT_GIT_BASH_PATH/); +}); + +test("Bash tool does not hint about Git Bash when a Windows failure ran under Git Bash", async () => { + const loader = createWindowsFailureLoader("posix", "bash"); + const { createShellTools } = loader.loadModule("src/lib/tools/shellTools.ts"); + const bundle = createShellTools({ + workdir: "/repo", + providerId: "claude_code", + runtimePlatform: "windows", + }); + + const result = await bundle.executeToolCall(createBashCall("exit 1")); + + assert.equal(result.isError, true); + assert.doesNotMatch(result.content[0].text, /Git Bash was not found/); +}); + test("Bash tool allows background commands with detached stdio", async () => { const calls = []; const loader = createTsModuleLoader({ @@ -475,29 +565,14 @@ test("ManagedProcess rejects nested shell background operators", async () => { assert.deepEqual(calls, []); }); -test("ManagedProcess does not apply POSIX ampersand background validation on Windows", async () => { +test("ManagedProcess rejects background ampersand commands on Windows", async () => { const calls = []; const loader = createTsModuleLoader({ mocks: { "@tauri-apps/api/core": { async invoke(command, args) { calls.push({ command, args }); - assert.equal(command, "managed_process_start"); - return { - process: { - id: "proc-win", - label: null, - command: args.command, - cwd: "C:\\repo", - shell: "pwsh", - pid: 456, - log_path: "C:\\Users\\me\\.liveagent\\process-logs\\proc-win.log", - started_at: 10, - finished_at: null, - exit_code: null, - running: true, - }, - }; + throw new Error("managed_process_start should not be invoked for rejected commands"); }, }, }, @@ -516,12 +591,13 @@ test("ManagedProcess does not apply POSIX ampersand background validation on Win name: "ManagedProcess", arguments: { action: "start", - command: '& "C:/Program Files/App/app.exe"', + command: "vite --port 5173 &", }, }); - assert.equal(result.isError, false); - assert.equal(calls.length, 1); + assert.equal(result.isError, true); + assert.match(result.content[0].text, /must be a foreground command/); + assert.equal(calls.length, 0); }); test("Bash tool marks stdio-open shell responses as errors", async () => {