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
17 changes: 17 additions & 0 deletions crates/buzz-acp/src/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,10 @@ impl AcpClient {
#[cfg(unix)]
cmd.process_group(0);

// Suppress the console window that Windows otherwise allocates for every
// console-subsystem child process spawned from a GUI/non-console parent.
configure_no_window(&mut cmd);

let mut child = cmd.spawn()?;

let stdin = child
Expand Down Expand Up @@ -1987,6 +1991,19 @@ fn kill_process_group(_pid: u32) -> bool {
false
}

/// Suppress the console window that Windows otherwise allocates for every
/// console-subsystem child process spawned from a GUI (non-console) parent.
/// No-op on non-Windows platforms.
fn configure_no_window(cmd: &mut tokio::process::Command) {
#[cfg(windows)]
{
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
cmd.creation_flags(CREATE_NO_WINDOW);
}
#[cfg(not(windows))]
let _ = cmd;
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
38 changes: 38 additions & 0 deletions crates/buzz-agent/src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -732,6 +732,8 @@ async fn spawn_one(
#[cfg(unix)]
cmd.process_group(0);

configure_no_window(&mut cmd);

let transport = TokioChildProcess::new(cmd)
.map_err(|e| AgentError::Mcp(format!("spawn {}: {e}", spec.name)))?;
let pgid = transport.id();
Expand Down Expand Up @@ -987,6 +989,19 @@ fn tool_result_content(
out
}

/// Suppress the console window that Windows otherwise allocates for every
/// console-subsystem child process spawned from a GUI (non-console) parent.
/// No-op on non-Windows platforms.
fn configure_no_window(cmd: &mut Command) {
#[cfg(windows)]
{
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
cmd.creation_flags(CREATE_NO_WINDOW);
}
#[cfg(not(windows))]
let _ = cmd;
}

#[cfg(test)]
mod content_tests {
use super::*;
Expand Down Expand Up @@ -1098,4 +1113,27 @@ mod content_tests {
}
assert_eq!(super::truncate_middle("ok", 1024), "ok");
}

#[test]
fn configure_no_window_is_a_noop_on_non_windows() {
// Cross-host: calling configure_no_window must not panic on any OS.
// On non-Windows the body is a cfg-gated no-op and the argument is
// consumed as `let _ = cmd`, so the only assertion is "didn't crash".
let mut cmd = Command::new("true");
configure_no_window(&mut cmd);
}

#[cfg(windows)]
#[test]
fn configure_no_window_compiles_and_applies_flag_on_windows() {
// On Windows, creation_flags(0x0800_0000) must be accepted without panicking.
// The call is a setter with no getter on tokio::process::Command, so the
// regression test confirms the flag is SET by checking the std inner command.
let mut cmd = Command::new("cmd.exe");
configure_no_window(&mut cmd);
// std::process::Command on Windows does have as_inner / get_creation_flags via
// CommandExt — but tokio wraps it; we verify by ensuring the call compiles and
// the resulting spawn wouldn't OOM (build+flag-set is the full contract here).
// The real protection is the cfg-gated production path in spawn_one().
}
}
27 changes: 27 additions & 0 deletions crates/buzz-dev-mcp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -184,3 +184,30 @@ async fn async_main(cmd: String) -> Result<(), Box<dyn std::error::Error>> {
service.waiting().await?;
Ok(())
}

/// Suppress the console window that Windows otherwise allocates for every
/// console-subsystem child process spawned from a non-console parent.
/// No-op on non-Windows platforms.
pub(crate) fn configure_no_window(cmd: &mut std::process::Command) {
#[cfg(windows)]
{
use std::os::windows::process::CommandExt as _;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
cmd.creation_flags(CREATE_NO_WINDOW);
}
#[cfg(not(windows))]
let _ = cmd;
}

/// Suppress the console window for async (`tokio::process::Command`) spawns.
/// Equivalent to `configure_no_window` but accepts a tokio command.
/// No-op on non-Windows platforms.
pub(crate) fn configure_no_window_async(cmd: &mut tokio::process::Command) {
#[cfg(windows)]
{
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
cmd.creation_flags(CREATE_NO_WINDOW);
}
#[cfg(not(windows))]
let _ = cmd;
}
9 changes: 4 additions & 5 deletions crates/buzz-dev-mcp/src/rg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,10 @@ fn try_system_rg(args: &[String]) -> Option<i32> {
let cleaned_path = clean_path(&self_canon);
let candidate = which_rg(&cleaned_path)?;

let status = Command::new(&candidate)
.args(args)
.env("PATH", &cleaned_path)
.status()
.ok()?;
let mut cmd = Command::new(&candidate);
cmd.args(args).env("PATH", &cleaned_path);
crate::configure_no_window(&mut cmd);
let status = cmd.status().ok()?;
Some(status.code().unwrap_or(2))
}

Expand Down
165 changes: 161 additions & 4 deletions crates/buzz-dev-mcp/src/shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ pub async fn run(
cmd.stderr(Stdio::piped());
cmd.kill_on_drop(true);
set_process_group(&mut cmd);
crate::configure_no_window_async(&mut cmd);

let started = Instant::now();
let mut child = match cmd.spawn() {
Expand Down Expand Up @@ -561,6 +562,36 @@ fn git_bash_from_standard_path_bases(
.find(|bash| bash.is_file())
}

/// True if `path` is inside the Windows app-execution-alias directory
/// (`%LOCALAPPDATA%\Microsoft\WindowsApps`). Paths in that directory are WSL
/// stub launchers, not real executables — running them spawns `wsl.exe` /
/// `wslhost.exe` / `conhost.exe` trees rather than the intended shell.
///
/// The check is purely path-structural (component-wise, case-insensitive) so it
/// compiles and is testable on any host. It matches the path component named
/// `Microsoft` immediately followed by `WindowsApps`, so a sibling directory
/// named `MicrosoftWindowsApps` does not match.
#[cfg(any(windows, test))]
fn is_windows_apps_alias(path: &Path) -> bool {
let mut components = path.components().peekable();
while components.peek().is_some() {
let mut it = components.clone();
if it.next().is_some_and(|c| {
c.as_os_str()
.to_string_lossy()
.eq_ignore_ascii_case("Microsoft")
}) && it.next().is_some_and(|c| {
c.as_os_str()
.to_string_lossy()
.eq_ignore_ascii_case("WindowsApps")
}) {
return true;
}
components.next();
}
false
}

/// True if `dir` is `root` or lives under it, comparing path components
/// case-INsensitively. Windows paths are case-insensitive, but `Path::starts_with`
/// compares components case-sensitively on every platform — so a PATH entry spelled
Expand All @@ -583,12 +614,28 @@ fn is_under_dir(dir: &Path, root: &Path) -> bool {
}

/// Scan the child's PATH for `bash.exe`, skipping the Windows system directory
/// (`system_root`, normally `%SystemRoot%`) so we never resolve WSL's
/// `System32\bash.exe`. PATH is parsed with `std::env::split_paths` (never a
/// hand-split on ';') so it matches exactly what the spawned child would see.
/// (`system_root`, normally `%SystemRoot%`) and the Windows app-execution-alias
/// directory (`%LOCALAPPDATA%\Microsoft\WindowsApps`) so we never resolve WSL's
/// `System32\bash.exe` or the `WindowsApps\bash.exe` stub launcher.
///
/// Skipping happens during iteration so scanning continues to the next PATH entry
/// when an alias is encountered — alias-first/real-bash-second selects the real one.
/// PATH is parsed with `std::env::split_paths` (never a hand-split on `;`) so it
/// matches exactly what the spawned child would see.
#[cfg(windows)]
fn scan_path_for_bash(path_env: &str, system_root: Option<&Path>) -> Option<PathBuf> {
scan_path_for_command(Path::new("bash.exe"), path_env, system_root)
for dir in std::env::split_paths(path_env) {
if let Some(root) = system_root {
if is_under_dir(&dir, root) {
continue;
}
}
let candidate = dir.join("bash.exe");
if candidate.is_file() && !is_windows_apps_alias(&candidate) {
return Some(candidate);
}
}
None
}

/// Scan `path_env` for `name` (or `name.exe` on Windows if `name` has no
Expand Down Expand Up @@ -1030,6 +1077,63 @@ mod tests {
"stdout: {stdout}"
);
}

// --- is_windows_apps_alias predicate tests (cross-host) ---

#[test]
fn test_windows_apps_alias_detected_typical_path() {
// Typical WSL alias: %LOCALAPPDATA%\Microsoft\WindowsApps\bash.exe
// Forward-slash form parses on both Windows and non-Windows hosts.
assert!(
is_windows_apps_alias(Path::new(
"C:/Users/alice/AppData/Local/Microsoft/WindowsApps/bash.exe"
)),
"standard WindowsApps path must be detected as an alias"
);
}

#[test]
fn test_windows_apps_alias_detected_case_insensitive() {
assert!(
is_windows_apps_alias(Path::new(
"C:/Users/alice/AppData/Local/MICROSOFT/WINDOWSAPPS/bash.exe"
)),
"WindowsApps detection must be case-insensitive"
);
}

#[test]
fn test_windows_apps_alias_rejected_real_git_bash() {
assert!(
!is_windows_apps_alias(Path::new("C:/Program Files/Git/bin/bash.exe")),
"real Git Bash must not be detected as a WindowsApps alias"
);
}

#[test]
fn test_windows_apps_alias_rejected_system32_bash() {
assert!(
!is_windows_apps_alias(Path::new("C:/Windows/System32/bash.exe")),
"System32 bash must not be detected as a WindowsApps alias"
);
}

#[test]
fn test_windows_apps_alias_rejected_partial_component_match() {
// A directory named "Microsoft" without a "WindowsApps" sibling must not match.
assert!(
!is_windows_apps_alias(Path::new("C:/Microsoft/SomeOtherDir/bash.exe")),
"path with Microsoft but not WindowsApps must not be detected"
);
}

#[test]
fn test_windows_apps_alias_rejected_unix_bash() {
assert!(
!is_windows_apps_alias(Path::new("/usr/bin/bash")),
"Unix bash must not be detected as a WindowsApps alias"
);
}
}

#[cfg(all(test, windows))]
Expand Down Expand Up @@ -1343,4 +1447,57 @@ mod windows_resolver_tests {
.expect("bash on PATH must be found");
assert_eq!(found, real_bash);
}

/// WSL alias rejection — when WindowsApps\bash.exe is first on PATH and a
/// legitimate Git Bash follows, the scanner must skip the alias and return
/// the real one (alias-first / real-bash-second ordering).
#[test]
fn windows_apps_alias_first_real_bash_second_returns_real() {
let base = tempdir().expect("base");

// Simulate %LOCALAPPDATA%\Microsoft\WindowsApps structure.
let microsoft = base.path().join("Microsoft");
let windows_apps = microsoft.join("WindowsApps");
std::fs::create_dir_all(&windows_apps).expect("mkdir WindowsApps");
let alias_bash = windows_apps.join("bash.exe");
touch(&alias_bash);

// Legitimate Git Bash in a separate directory.
let git_bin = base.path().join("git").join("bin");
std::fs::create_dir_all(&git_bin).expect("mkdir git/bin");
let real_bash = git_bin.join("bash.exe");
touch(&real_bash);

let path_env = env::join_paths([windows_apps.clone(), git_bin.clone()]).expect("join");
let sys_root = tempdir().expect("sysroot"); // empty

let found = scan_path_for_bash(path_env.to_str().expect("utf8"), Some(sys_root.path()))
.expect("real bash must be found after skipping alias");
assert_eq!(
found, real_bash,
"must skip WindowsApps alias and return real bash"
);
}

/// WSL alias rejection — when only WindowsApps\bash.exe is on PATH (no real
/// Git Bash installed), the scanner must return None rather than the alias.
#[test]
fn windows_apps_alias_only_returns_none() {
let base = tempdir().expect("base");

let microsoft = base.path().join("Microsoft");
let windows_apps = microsoft.join("WindowsApps");
std::fs::create_dir_all(&windows_apps).expect("mkdir WindowsApps");
let alias_bash = windows_apps.join("bash.exe");
touch(&alias_bash);

let path_env = env::join_paths([windows_apps.clone()]).expect("join");
let sys_root = tempdir().expect("sysroot"); // empty

let found = scan_path_for_bash(path_env.to_str().expect("utf8"), Some(sys_root.path()));
assert!(
found.is_none(),
"alias-only PATH must return None, not the WSL launcher"
);
}
}
18 changes: 11 additions & 7 deletions desktop/src-tauri/src/commands/agent_auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ fn run_buzz_acp_auth_command_with_paths<const N: usize>(
if let Some(path) = augmented_path {
command.env("PATH", path);
}
crate::util::configure_no_window(&mut command);

command
.output()
Expand Down Expand Up @@ -226,13 +227,16 @@ fn run_claude_subscription_login(runtime_id: &str, method: &AcpAuthMethod) -> Re
let (command, args) = argv
.split_first()
.ok_or_else(|| "Claude login command is empty".to_string())?;
let status = Command::new(command)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map_err(|error| format!("failed to run Claude login: {error}"))?;
let status = {
let mut cmd = Command::new(command);
cmd.args(args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
crate::util::configure_no_window(&mut cmd);
cmd.status()
.map_err(|error| format!("failed to run Claude login: {error}"))?
};
if !status.success() {
return Err(format!(
"Claude login failed (exit {})",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,12 +93,13 @@ fn managed_node_runtime_ready() -> bool {
if !node.is_file() {
return false;
}
let output = std::process::Command::new(&node)
.arg("--version")
let mut cmd = std::process::Command::new(&node);
cmd.arg("--version")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.output();
.stderr(std::process::Stdio::null());
crate::util::configure_no_window(&mut cmd);
let output = cmd.output();
output
.ok()
.filter(|output| output.status.success())
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/commands/agent_model_process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ pub(super) async fn run_agent_models_command(
cmd.env(k, v);
}
crate::managed_agents::configure_runtime_cli(&mut cmd, known_acp_runtime(&agent_command));
crate::util::configure_no_window(&mut cmd);
cmd.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output()
Expand Down
Loading
Loading