Skip to content
Open
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
16 changes: 16 additions & 0 deletions crates/buzz-acp/src/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,19 @@ use crate::usage::{TurnUsage, UsageTracker};
/// Lines exceeding this limit are rejected to prevent OOM from rogue agents.
const MAX_LINE_SIZE: usize = 10_000_000; // 10 MB

/// Suppress a console window for agent adapter children on Windows (no-op elsewhere).
fn hide_console(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;
}
}

/// An MCP server configuration passed to `session/new`.
///
/// Corresponds to the `McpServerStdio` variant in the ACP schema.
Expand Down Expand Up @@ -466,6 +479,9 @@ impl AcpClient {
#[cfg(unix)]
cmd.process_group(0);

// Desktop launches buzz-acp windowless; adapters must not allocate consoles (#2292).
hide_console(&mut cmd);

let mut child = cmd.spawn()?;

let stdin = child
Expand Down
16 changes: 16 additions & 0 deletions crates/buzz-agent/src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,19 @@ fn windows_child_passthrough_env() -> impl Iterator<Item = &'static str> {
.chain(crate::WINDOWS_SHELL_RESOLUTION_ENV.iter().copied())
}

/// Suppress a console window for MCP children on Windows (no-op elsewhere).
fn hide_console(cmd: &mut Command) {
#[cfg(windows)]
{
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
cmd.creation_flags(CREATE_NO_WINDOW);
}
#[cfg(not(windows))]
{
let _ = cmd;
}
}

type Client = RunningService<RoleClient, ()>;

#[derive(Clone)]
Expand Down Expand Up @@ -732,6 +745,9 @@ async fn spawn_one(
#[cfg(unix)]
cmd.process_group(0);

// Windowless buzz-acp parent: MCP children must not allocate consoles.
hide_console(&mut cmd);

let transport = TokioChildProcess::new(cmd)
.map_err(|e| AgentError::Mcp(format!("spawn {}: {e}", spec.name)))?;
let pgid = transport.id();
Expand Down
1 change: 1 addition & 0 deletions crates/buzz-dev-mcp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ mod str_replace;
mod todo;
mod tree;
mod view_image;
mod windows_console;

#[derive(Clone)]
struct DevMcp {
Expand Down
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::windows_console::hide_std(&mut cmd);
let status = cmd.status().ok()?;
Some(status.code().unwrap_or(2))
}

Expand Down
62 changes: 41 additions & 21 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::windows_console::hide_tokio(&mut cmd);

let started = Instant::now();
let mut child = match cmd.spawn() {
Expand Down Expand Up @@ -393,13 +394,17 @@ fn resolve_bash(_path_env: &str) -> Result<(PathBuf, String), String> {
/// WITHOUT the System32 exclusion — the operator explicitly chose this shell,
/// and cmd.exe/powershell.exe live in System32 legitimately.
/// 2. `GIT_BASH` env override — legacy escape hatch (kept for back-compat).
/// 3. `bash.exe` on PATH, excluding System32 so we never resolve WSL's launcher.
/// 4. `git.exe` on PATH → its sibling `..\\bin\\bash.exe`. Git for Windows's
/// 3. `git.exe` on PATH → its sibling `..\\bin\\bash.exe`. Git for Windows's
/// recommended "Git from the command line" option adds `Git\\cmd` to PATH,
/// not `Git\\bin`, so this is the normal post-install route.
/// 5. Standard `ProgramFiles`, `ProgramFiles(x86)`, and `LocalAppData` paths
/// 4. Standard `ProgramFiles`, `ProgramFiles(x86)`, and `LocalAppData` paths
/// when the child inherited their parent environment.
/// 6. Git for Windows's machine then user registry `InstallPath`.
/// 5. Git for Windows's machine then user registry `InstallPath`.
/// 6. `bash.exe` on PATH, excluding System32 (WSL) and WindowsApps (App
/// Execution Alias → WSL; hangs #2328).
///
/// KEEP IN SYNC with `desktop/.../git_bash.rs` `resolve_git_bash_inner`
/// (same order after overrides; Doctor uses a pure inject-env API).
///
/// Returns `(resolved_path, display_name)`. The display name is derived from the
/// resolved path, guaranteeing the dialect hint and the spawned shell agree.
Expand All @@ -414,7 +419,7 @@ fn resolve_bash(path_env: &str) -> Result<(PathBuf, String), String> {
let name = shell_name_from_path(&p);
return Ok((p, name));
}
} else if let Some(found) = scan_path_for_command(&p, path_env, None) {
} else if let Some(found) = scan_path_for_command(&p, path_env, None, false) {
let name = shell_name_from_path(&found);
return Ok((found, name));
}
Expand All @@ -427,12 +432,7 @@ fn resolve_bash(path_env: &str) -> Result<(PathBuf, String), String> {
}
}

let system_root = std::env::var_os("SystemRoot").map(PathBuf::from);
if let Some(p) = scan_path_for_bash(path_env, system_root.as_deref()) {
return Ok((p, "bash".to_string()));
}

if let Some(git) = scan_path_for_command(Path::new("git.exe"), path_env, None) {
if let Some(git) = scan_path_for_command(Path::new("git.exe"), path_env, None, false) {
if let Some(bash) = bash_from_git(&git) {
return Ok((bash, "bash".to_string()));
}
Expand All @@ -446,6 +446,11 @@ fn resolve_bash(path_env: &str) -> Result<(PathBuf, String), String> {
return Ok((bash, "bash".to_string()));
}

let system_root = std::env::var_os("SystemRoot").map(PathBuf::from);
if let Some(p) = scan_path_for_bash(path_env, system_root.as_deref()) {
return Ok((p, "bash".to_string()));
}

Err(
"Git for Windows (Git Bash) is required but was not found. Checked \\
BUZZ_SHELL, GIT_BASH, bash.exe and git.exe on PATH, the standard Git install locations, \\
Expand Down Expand Up @@ -582,23 +587,24 @@ fn is_under_dir(dir: &Path, root: &Path) -> bool {
true
}

/// 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.
/// Scan the child's PATH for `bash.exe`, skipping System32 (WSL) and
/// WindowsApps (App Execution Alias → WSL hang, #2328).
///
/// KEEP IN SYNC with `desktop/.../git_bash.rs` (probe order + WindowsApps skip).
#[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)
scan_path_for_command(Path::new("bash.exe"), path_env, system_root, true)
}

/// Scan `path_env` for `name` (or `name.exe` on Windows if `name` has no
/// extension), skipping any directory under `system_root` to avoid resolving
/// WSL helpers. Returns the first absolute path found.
/// Scan `path_env` for `name` (or `name.exe` if `name` has no extension).
/// Skips dirs under `system_root` when set. When `skip_windows_apps`, skips
/// `…\Microsoft\WindowsApps`.
#[cfg(windows)]
fn scan_path_for_command(
name: &Path,
path_env: &str,
system_root: Option<&Path>,
skip_windows_apps: bool,
) -> Option<PathBuf> {
let needs_exe = name.extension().is_none();
for dir in std::env::split_paths(path_env) {
Expand All @@ -607,12 +613,13 @@ fn scan_path_for_command(
continue;
}
}
// Try as-is first.
if skip_windows_apps && path_looks_like_windows_apps(&dir) {
continue;
}
let candidate = dir.join(name);
if candidate.is_file() {
return Some(candidate);
}
// On Windows, also try with .exe suffix when the name has no extension.
if needs_exe {
let mut with_exe = dir.join(name);
with_exe.set_extension("exe");
Expand All @@ -624,6 +631,19 @@ fn scan_path_for_command(
None
}

/// True for `…\Microsoft\WindowsApps` (App Execution Alias dir), any casing.
/// KEEP IN SYNC with `desktop/.../git_bash.rs`.
#[cfg(windows)]
fn path_looks_like_windows_apps(dir: &Path) -> bool {
let parts: Vec<_> = dir
.components()
.filter_map(|c| c.as_os_str().to_str())
.collect();
parts.windows(2).any(|w| {
w[0].eq_ignore_ascii_case("Microsoft") && w[1].eq_ignore_ascii_case("WindowsApps")
})
}

#[cfg(unix)]
fn set_process_group(cmd: &mut Command) {
cmd.process_group(0);
Expand Down
32 changes: 32 additions & 0 deletions crates/buzz-dev-mcp/src/windows_console.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
//! Suppress console windows for child processes on Windows GUI hosts.
//!
//! Buzz desktop launches this MCP server windowless; children that allocate a
//! console flash a conhost. Use these helpers instead of inlining
//! `CREATE_NO_WINDOW` at each spawn site.

/// Hide the console for a `std::process::Command` (no-op on non-Windows).
pub(crate) fn hide_std(cmd: &mut std::process::Command) {
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
cmd.creation_flags(CREATE_NO_WINDOW);
}
#[cfg(not(windows))]
{
let _ = cmd;
}
}

/// Hide the console for a `tokio::process::Command` (no-op on non-Windows).
pub(crate) fn hide_tokio(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;
}
}
Loading